Vue前端重构实战:拆解ZHZXOJ-Web如何改造开源OJ界面 简介基于SYZOJ前端深度改版的在线评测系统界面方案以JavaScript为主要技术栈适合OJ系统维护者、自建判题平台的技术人员以及想了解评测系统前端架构的前端开发者参考。工程完整包含524个文件压缩包大小12.48MB其中211个JavaScript文件承担核心页面交互逻辑105个CSS样式文件涵盖Semantic UI体系构建设计风格49个EJS模板实现页面动态渲染与多页面复用另配有SVG图标及TTF、WOFF等字体资源目录组织遵循前端工程常见规范。项目虽已停止活跃维护但整体前端分层与配置思路仍有借鉴价值从模板渲染、样式定制到静态资源组织均有完整示例可循能直观看到OJ前端从页面骨架到交互反馈的落地过程。同时包内附带基于Nginx开启HTTPS访问的配置片段含SSL证书路径指定、非443端口请求重写跳转等规则为实际部署环节提供直接参考。已有210人浏览学习适合作为OJ前端改版的参考样例。1. ZHZXOJ-Web把开源 OJ 前端改到能上线总共分几步如果你接手过一套老牌开源 OJ 的前端多半能对上这个画面题库、提交、评测状态都在但界面还停留在五年前的审美想加一个「本周热题」模块得先和一堆互相引用的 Vue 单文件组件搏斗半天。ZHZXOJ-Web 正是基于 SYZOJ 前端做的一次改版——不推倒重写把评测链路原样保留重点重构了前端工程路由、状态管理、接口层全部重新梳理换成一套标准写法的 JavaScript 单页应用。这篇笔记会带你过一遍它的目录、核心代码、运行方式和二次开发入口。打算自建 OJ、或者正准备改造某个 Vue 前端工程的人可以照着抄作业。2. 项目结构与技术栈从 SYZOJ 到 ZHZXOJ-Web 动了哪三层改版这种事最怕改完只剩一个壳。ZHZXOJ-Web 的做法是把前端拆成「视图层、交互层、接口层」三个层面逐层处理视图层重新设计布局交互层统一收敛到状态管理接口层集中封装请求。下面先看工程结构再逐层讲改动逻辑。2.1 目录结构改版后的前端工程长什么样拿到源码包后第一件事不是急着npm install而是先看目录。ZHZXOJ-Web 的目录是典型的 Vue 全家桶工程核心代码全部收敛在src下zhzxj-oj-web/ ├── public/ │ └── index.html ├── src/ │ ├── api/ │ │ ├── auth.js # 登录、注册、退出 │ │ ├── problem.js # 题库列表、题目详情 │ │ └── submit.js # 提交代码、获取评测状态 │ ├── assets/ │ ├── components/ │ │ ├── ProblemCard.vue # 题目卡片 │ │ ├── CodeEditor.vue # 代码编辑器封装 │ │ └── StatusTag.vue # 评测状态标签 │ ├── router/ │ │ └── index.js │ ├── store/ │ │ └── modules/ │ │ ├── user.js # 登录态、用户信息 │ │ └── problem.js # 题库查询条件 │ ├── utils/ │ │ ├── request.js # axios 实例与拦截器 │ │ └── judge.js # 评测状态机与轮询 │ ├── views/ │ │ ├── ProblemList.vue │ │ ├── ProblemDetail.vue │ │ ├── Submission.vue │ │ └── Login.vue │ ├── App.vue │ └── main.js ├── .env.development ├── .env.production ├── vue.config.js └── package.json这个结构和原版最大的区别在于原版前端习惯把请求直接写在组件里改一个接口地址要满项目搜字符串ZHZXOJ-Web 把所有接口调用收进src/api组件里只出现类似fetchProblemList({ page: 1 })的调用。这样做的好处很直接——后端接口调整时只改一个文件组件层完全不感知。store/modules里只保留了两种全局状态用户登录态和题库查询条件。其他像「当前编辑器里的代码」这种临时状态仍然留在组件内部用ref或data管理。这个取舍值得记一下不是所有数据都该进全局 store推进去越多排查问题时越像在迷宫里找针。2.2 技术选型为什么是 JavaScript 生态而不是推倒重来改版面临的第一道选择题是继续用 Vue还是换 React甚至换 TypeScriptZHZXOJ-Web 最终保留了 JavaScript Vue 的路子这个决定在工程上是划算的。原因有三一是原前端就是 Vue 写的组件里大量的模板语法和生命周期可以直接复用换框架等于把题库、编辑器、状态标签重写一遍二是 OJ 的前端业务并不复杂列表、详情、提交、状态展示全是标准 CRUD 场景Vue 的响应式模板恰好匹配三是后端评测逻辑和前端通过 HTTP 接口解耦前端框架不是性能瓶颈真正吃性能的评测机在后端。组件库方面常见做法是配一套成熟 UI 库ZHZXOJ-Web 用的 Element 系组件配合自定义主题变量。代码编辑器这块老 OJ 偏爱 CodeMirror新一点的有用 Monaco 的。ZHZXOJ-Web 保留了 CodeMirror 封装原因很实际它体积小、对 OJ 这种单文件编译场景够用而且主题风格和整体 UI 更好协调。如果你拿到手想换成 Monaco核心工作只在CodeEditor.vue一个文件里后面第五章会说怎么动刀。2.3 三层改造视图层、交互层、接口层分别做了什么视图层的改动最直观。原版的页面密度高、留白少导航和信息层级靠颜色硬撑改版后卡片化明显题目列表每一行都有独立的题目卡片状态列用彩色标签区分。深浅色切换也是在这一层做的思路是维护一套 CSS 变量主题切换时批量替换变量值而不是给每个组件单独写样式。交互层的重点是状态收敛。原版用data字段在各个组件之间传递登录信息刷新页面就丢改版后统一走 Vuexuser模块在初始化时从localStorage恢复 token任何组件要判断登录状态直接读store.getters.isLogin不需要自己维护一份。这一步看似只是挪了个位置实际把「登录态丢失」这类问题从根源上按住了。接口层的改动决定了好不好联调。axios 实例统一了baseURL从环境变量读取响应拦截器统一处理 401 跳登录和错误提示。组件里不再出现axios.get(/api/xxx)这种裸调用全部走src/api下的具名函数。这样做的直接收益是后端接口路径变了全局只有一处要改接口报错了拦截器统一给用户弹提示而不是每个组件各自弹一个风格不同的报错框。3. 核心模块拆解一道题从列表到提交的完整链路OJ 前端的核心链路不复杂但每一步都有细节题库分页怎么传参、提交时代码怎么校验、评测状态怎么判断结束。这一章按「列表 → 详情 → 提交 → 轮询」的顺序逐段拆代码都是从 ZHZXOJ-Web 源码里提炼出来的核心片段。3.1 题库列表与题目详情请求怎么进来数据怎么落地先看接口封装。src/api/problem.js里的代码很薄职责只有一件事把组件需要的参数翻译成后端接口的 request。// src/api/problem.js import request from /utils/request export function fetchProblemList(params) { return request({ url: /problems, method: get, params: { page: params.page, limit: params.limit, keyword: params.keyword || } }) } export function fetchProblemDetail(problemId) { return request({ url: /problems/${problemId}, method: get }) }request是src/utils/request.js里创建的 axios 实例baseURL来自环境变量所以这里写的是相对路径。分页参数里page从 1 开始limit常见取值是 20 或 50keyword用于模糊搜索题号或题名。后端返回结构约定为{ code: 0, data: { list: [], total: 100 } }code非 0 时由拦截器统一弹错误组件里不需要手动处理接口异常。组件侧调用同样简单列表页用一个loadList函数统一驱动分页组件翻页时、搜索框确认时都调用它重新拉数据。// src/views/ProblemList.vue片段 import { fetchProblemList } from /api/problem async function loadList() { loading.value true try { const res await fetchProblemList({ page: page.value, limit: limit.value, keyword: keyword.value }) list.value res.data.list total.value res.data.total } finally { loading.value false } }这里有个容易被新手忽略的点finally里关 loading而不是在try的正常流程里关。一旦请求超时或拦截器报错走了catchloading 也会被正确复位不会出现按钮永久转圈的情况。如果你在原版组件里见过「报错之后按钮一直 loading」的 bug多半就是 loading 复位写错了位置。点击列表项进入详情页时路由带problemId详情页在onMounted里调fetchProblemDetail(route.params.id)拿题目内容。题面通常是 HTML 或 Markdown 字段前端用v-html渲染这也是 OJ 前端的标准做法——题面公式、图片都由后端富文本生成前端只负责展示。3.2 提交代码语言选择、代码校验与提交动作提交模块是 OJ 前端最容易出乱子的地方。ZHZXOJ-Web 把提交动作拆成两部先本地校验再发请求。本地校验能拦住大部分低级操作避免把空代码或明显残缺的代码送到评测机。// src/views/ProblemDetail.vue片段 import { submitCode } from /api/submit function beforeSubmit() { if (!selectedLanguage.value) { ElMessage.warning(请先选择语言) return false } if (code.value.trim().length 8) { ElMessage.warning(代码太短确认没有粘贴错) return false } return true } async function handleSubmit() { if (!beforeSubmit()) return const res await submitCode({ problemId: route.params.id, language: selectedLanguage.value, code: code.value }) if (res.code 0) { router.push({ name: submission, params: { id: res.data.submissionId } }) } }submitCode的封装在src/api/submit.js里是个标准的 POST 请求把problemId、language、code三个字段塞进 body。提交成功后立刻跳转到提交详情页这个路由跳转很关键——用户提交完最关心的是评测结果跳转到状态页能立刻看到「等待评测 → 编译中 → 通过」的实时变化反馈链路短体验自然好。语言参数的映射需要和后端对齐。常见做法是一张固定的语言表language 值语言标识扩展名0C.cpp1Java.java2Python3.py不同 OJ 这套表不一样有的把 Go、Rust 也排进去有的语言值从 1 开始。我在改版时吃过这个亏前端显示的是「C」传值传成了cpp字符串后端期望的是0提交后直接 400。所以拿到后端接口文档时第一件事就是对语言表前端下拉框的label和value必须跟后端约定一致。3.3 评测状态机与轮询怎么判断「还在跑」和「已经翻车」提交之后是评测状态。SZJOJ 系的后端评测状态通常是一组字符串码比如PENDING、COMPILING、RUNNING、ACCEPTED、WRONG_ANSWER。前端要做的不是猜状态而是把状态码表对齐然后按这个码表决定 UI 展示和轮询终止条件。// src/utils/judge.js const FINAL_STATUS [ ACCEPTED, WRONG_ANSWER, COMPILE_ERROR, RUNTIME_ERROR, TIME_LIMIT_EXCEEDED, MEMORY_LIMIT_EXCEEDED, SYSTEM_ERROR ] export function pollSubmission(id, onUpdate, interval 1000) { let tries 0 const timer setInterval(async () { tries 1 try { const res await fetchSubmissionStatus(id) const status res.data.status onUpdate(status) if (FINAL_STATUS.includes(status) || tries 60) { clearInterval(timer) } } catch (err) { if (tries 5) { clearInterval(timer) onUpdate(NETWORK_ERROR) } } }, interval) return timer }这段代码处理了两个边界一是网络请求失败时连续失败 5 次就放弃轮询避免无限重试压垮页面二是总轮询次数超过 60 次时强制停止防止评测机卡死导致前端永远在转圈。onUpdate是回调函数每次拿到新状态就通知组件刷新 UI组件里用它配合clearInterval在组件销毁时清理定时器。用轮询而不是 WebSocket是这套系统刻意的保守。评测过程可能持续几十秒WebSocket 长连接的确更实时但 OJ 这种场景对毫秒级反馈没有要求轮询实现简单、断线自愈容易后端也不用维护连接状态。改版时保留轮询、把间隔参数抽出来已经是性价比最高的方案。状态标签组件StatusTag.vue负责把状态码转成 UI 文案和颜色PENDING显示灰色「等待评测」RUNNING显示蓝色「评测中」ACCEPTED显示绿色「通过」其他终结状态显示红色。颜色映射集中在一个computed里改版时想调整配色只动一个文件。4. 本地运行与调试启动步骤、代理配置和避坑指南源码拿到手能不能跑起来是第一个关卡。这一章按实际操作顺序讲环境准备、启动命令、接口代理、登录态处理最后是几条我自己踩过的坑。4.1 环境准备Node 版本、包管理器与初始化ZHZXOJ-Web 是标准的 Vue CLI 工程环境要求不苛刻。Node 版本建议不低于 14稳妥起见用 16 LTSnpm 一般随 Node 一起装好。装了 yarn 的团队也可以直接用 yarn命令差异只在包安装这一步其他脚本在package.json里都配好了。初始化顺序就三条命令npm install npm run serveinstall时间取决于网络。装完后npm run serve默认起本地开发服务器vue.config.js里配的端口是 3000浏览器访问http://localhost:3000就能看到前端页面。如果端口被占用命令行会直接报EADDRINUSE解决办法见 4.3 的避坑记录。环境变量文件长这样作用是把「环境相关」的配置从代码里剥出来# .env.development VUE_APP_API_BASE_URL/api VUE_APP_WS_URLws://127.0.0.1:8080/ws提示环境变量名必须以VUE_APP_开头Vue CLI 才会把它注入到前端代码里。写成API_BASE_URL不生效这是新人最容易踩的沉默错误——代码不报错但请求全打到当前域名下。4.2 接口对接代理配置、axios 拦截器与登录态前端开发服务器和后端服务通常不同端口跨域问题靠 devServer 代理解决。ZHZXOJ-Web 的vue.config.js做了这样一段配置// vue.config.js const { defineConfig } require(vue/cli-service) module.exports defineConfig({ devServer: { port: 3000, proxy: { /api: { target: http://127.0.0.1:8080, changeOrigin: true } } } })请求路径里所有以/api开头的请求都会被代理转发到http://127.0.0.1:8080。所以前端代码里写request({ url: /problems })实际发出的是/api/problems代理再转发到后端的/api/problems。这样联调时不用改任何代码生产环境把VUE_APP_API_BASE_URL改成完整后端地址即可。axios 实例的拦截器在src/utils/request.js里统一处理了错误和登录态// src/utils/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: process.env.VUE_APP_API_BASE_URL, timeout: 10000 }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( (res) res.data, (error) { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request请求拦截器负责带头响应拦截器负责处理公共错误。401 跳登录是整个前端登录态管理的核心token 过期、用户被踢、权限不足统一处理组件层不需要为 401 写任何业务逻辑。如果你要接自己的后端只需要改baseURL和Authorization的拼接方式其他不用动。4.3 避坑记录启动白屏、端口占用、轮询卡死与登录态丢失这一节是改版过程中真实踩过的坑每条都按「现象 → 原因 → 解决」写希望能帮你省下排查时间。坑一启动后页面白屏控制台报Cannot read properties of undefined (reading length)。现象是npm run serve正常启动浏览器打开一片空白。原因多半是后端没启动或者代理指向的端口不对前端请求全部失败列表组件拿到undefined后直接炸了。解决方法是先确认后端服务在8080端口可访问再按 4.2 检查代理配置。这个错和前端代码没关系排查顺序一定是先接口后组件。坑二npm run serve报Error: listen EADDRINUSE: address already in use :::3000。现象是端口被占用服务起不来。常见原因是上一个开发服务器没关或者别的程序占了 3000。解决方法是改vue.config.js里的port字段换一个端口比如3001或者找到占用进程杀掉。我当时在三个项目之间切换每次都忘记关前一个 dev server最后养成了习惯切换项目前先看一眼本机 3000 端口。真正省时间。坑三代码提交后状态永远停在「等待评测」轮询不结束。现象是提交成功、也跳到了状态页但状态一直是 PENDING控制台里轮询还在发请求。原因一般是前后端状态码表对不上前端把AC当终结状态后端实际返回ACCEPTED或者结束状态里漏了RUNTIME_ERROR导致运行时报错永远等不到结束。解决方法是把 3.3 的FINAL_STATUS数组和后端返回的真实状态逐一对照一个都不能漏。吃这个亏后我把状态码表打印出来贴在工位上再没出过第二次。坑四F5 刷新页面后登录态丢失又要重新登录。现象是登录后一切正常一刷新就回到登录页。原因是 token 只存在 Vuex 里刷新后 store 重新初始化原来的 token 没了。解决方法是把 token 持久化到 localStorage并在 store 初始化时恢复登录成功时localStorage.setItem(token, data.token)store/modules/user.js的state.token初始值直接读localStorage.getItem(token)。这样一来刷新前后 token 都在登录态就稳了。坑五在 CodeMirror 编辑器里按 Tab 键焦点跳出编辑器而不是缩进代码。现象是写代码时按 Tab 想缩进结果焦点跑到下一个表单元素上编辑体验割裂。原因是 CodeMirror 默认把 Tab 当成焦点切换OJ 场景需要覆盖默认行为。解决方式是给编辑器配置extraKeys把 Tab 映射为插入两个空格或跳到下一个 Tab 位// src/components/CodeEditor.vue片段 const editorOptions { tabSize: 2, extraKeys: { Tab: (cm) cm.replaceSelection( , end) } }原地插入四个空格缩进行为就和主流 IDE 对齐了。这个小配置在新手手里能省一整天崩溃时间写代码缩进全靠鼠标点的人太多了。5. 二次开发示例换肤、加自测面板和发布验证最后落地到「我要拿它改成自己的 OJ」。这里给两个具体的改法一是全局换肤二是给题目页加一个自测用例入口。两个都从实际需求出发改动量小、见效快。5.1 换肤一处改变量全站跟着变ZHZXOJ-Web 的样式抽了一套 SCSS 变量集中在src/styles/_variables.scss。想换主色改这里就够了// src/styles/_variables.scss $primary: #2b6de8; $success: #1fa855; $warning: #e6a23c; $danger: #f56c6c; $sidebar-width: 220px;不同组件库覆盖变量的方式略有差异但思路一致。改完重新npm run serve按钮、链接、选中态、加载条全部跟着换。我给内部团队做的 OJ 皮肤就是只改了这套变量加一个 logo半天交付。如果你连导航布局都想动重点看App.vue里的侧边栏和顶栏结构这两个文件是页面骨架其余组件都在内容区里不碰骨架就不会打散布局。5.2 加功能在题目详情页挂一个「自测用例」面板想在提交流程前让用户先跑一组小数据最简单的方式是在题目详情页加一个折叠面板不放后端评测机前端本地做一次假流程。模板片段如下template el-card v-ifshowSelfTest classself-test template #header自测用例/template el-input v-modelselfTestInput typetextarea :rows4 placeholder输入测试数据一行一组 / el-button clickrunSelfTest :loadingselfTesting 运行自测 /el-button /el-card /templaterunSelfTest的实现可以调后端一个轻量接口也可以在纯前端做字符串比对。我的建议是接后端——自测的语义是「用我的代码跑我的数据」得让评测机真正执行前端只能拿到输入数据、展示返回结果。这个面板的好处是不动原有提交链路新功能隔离在一个组件里和主流程完全解耦上线出问题可以直接摘掉不影响核心功能。5.3 发布与验收改完之后进入发布验证npm run build npx serve -s dist -l 5000build产物在dist目录用npx serve起一个静态服务模拟生产环境重点检查三件事主题变量是否全局生效、路由在history模式下刷新是否 404、登录态在生产地址下是否还能正常保持。前两件事和构建配置有关第三件事涉及后端域名和 cookie 域联调时最容易翻车。说到检查想起一次改版收尾的事我把状态码表里的ACCEPTED看成了AC轮询永远等不到结束提交反馈全卡在 Pending查了一个下午才发现是结束态列表少了几个字母。从那以后每次给 OJ 前端加功能我拿到源码第一件事就是先把后端评测状态码表拉出来对一遍再动轮询和展示逻辑。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取