
简介一套基于SYZOJ前端框架进行二次改版的OJ系统前端资源包面向在线评测系统OJ开发者、前端工程师及运维人员适合已有JavaScript与模板渲染基础、希望改造或部署OJ站点的读者。资源不仅重写了原有界面与交互逻辑还补充了启用HTTPS所需的Nginx配置片段覆盖443端口监听、SSL证书引入及可选安全协议设置便于直接落到真实竞赛或校内练习场景。资源共包含524个文件压缩包约12.48MB以JS、CSS、EJS模板为主体JS负责页面交互与控制逻辑CSS完成界面样式EJS承担服务端页面渲染另收录SVG图标、TTF/WOFF/WOFF2/EOT字体及JSON配置文件可支撑前端资源替换、字体图标定制与二次开发。已有210人学习/下载。整套内容保留了原项目的目录层级和静态资源组织方式可快速定位模板、样式、脚本改动位置同时附带HTTPS配置说明为OJ站点安全接入与Nginx排错提供了直接参考资料。1. SYZOJ前端改版到底在改什么一个保留后端、重建前端的务实方案接手一套还活着的OJ最常见的状态是后台评测、用户数据、题目数据都正常但打开页面那一刻的体验还停留在上一个时代。基于SYZOJ前端改版目标不是把整棵代码树重写而是把“前端”这层壳换掉保留后端评测逻辑不动按照现代Web应用的习惯把题目列表、提交状态、排行这些核心页面重新做一遍。这个判断背后是一个朴素的工程事实OJ的复杂度集中在评测机和数据库侧前端敢于动前提是接口边界清晰。这篇文章适合手上有SYZOJ实例、想优化前端体验但不打算重写后端的开发者或OJ维护者按本文的路径从选型、落地到踩坑按顺序做就能跑出一版可交付的改版。2. 看清SYZOJ前端的家底两种形态、接口边界与改版范围在动手写第一行代码前我习惯先把原版跑起来花半天时间做两件事看页面结构、抓接口。SYZOJ这类在线评测系统前端形态常见的有两种。一种是老式的模板渲染登录态靠Cookie页面由后端拼好再返回刷新后页面状态不丢另一种是前后端分离后的SPA登录后拿token页面路由和渲染都在浏览器侧。判断方法很简单打开开发者工具的Network面板随便点一个页面如果返回的HTML里直接能看到题目文本就是第一种如果HTML是空壳数据全是XHR拉的json就是第二种。2.1 SYZOJ前端两种形态改版思路完全不同不同形态对应不同改版策略。第一种的改版成本高因为你不光要重写页面还要把原来藏在模板里的逻辑搬到前端接口调用里一期建议只挑题目浏览和提交两条链路做第二种是在已有的SPA基础上做重点是替换组件库、调整交互和补样式体系。常见做法是用表格先把差异列清楚再决定是局部改还是整包换。形态页面渲染位置登录态改版策略典型工作量模板渲染后端拼接Cookie整体迁移到SPA大已有SPA前端路由token/Cookie组件与交互调整中具体到我手上的ZHZXOJ-Web我倾向“保留后端、前端重建”的方案。原因是OJ后端最值钱的逻辑是判题服务和数据模型这两个地方很少动去动它们风险很高而前端重建的风险可控坏了就回退不影响已提交的数据。这个判断也回答了很多人改版前的纠结不是非要把整套技术栈换掉前端重建后接口对齐才是关键。2.2 改版前的接口清单先把边界拉清楚改版第二步是拉接口清单。我一般会打开原版页面开着开发者工具走一遍用户主流程注册登录、进题目列表、开一道题、提交代码、看结果、点排行榜。每走一步就把请求的URL、方法、参数和返回结构记下来整理成一张接口与页面映射表。这张表是后端和前端的契约也是排期的依据。常见OJ的接口模块大概分五块把这五个模块在表格里一行行列出来标注“以当前后端实际路由为准”再拿这张表去对后端路由缺什么补什么免得前端做到一半才发现接口签名对不上。模块接口示意路径关键参数返回要点用户/api/loginusername、passwordtoken、用户信息题目/api/problemspage、pageSize、keyword、tagId题目列表、总数题目详情/api/problem/:id无标题、描述、时限、内存提交/api/submitproblemId、language、codesubmissionId结果/api/submission/:id无状态、得分、用时、测试点排行榜/api/rankingpage、pageSize排名列表、总人数这里最容易翻车的是分页参数名。有的后端用page和pageSize有的用pageNum和limit还有的从0开始计数。不核对原版请求就写死参数名联调时一定会有一方先炸。另外接口返回结构不统一也是常态有的直接返回裸对象有的外层包code和data。建议在整理接口清单时就把返回结构样例贴进表格备注里后续封装request层时按这个结构来处理。2.3 改版范围怎么定功能照搬、交互重做、工程化重构改版范围我建议框死三句话功能照搬不新增交互重做让路径变短工程化重构让后续能维护。功能照搬的意思是原版能提交、能看名单、能后台管理新版这些一个都不能少但没有必要趁改版加“站内信”“好友系统”这类新功能范围一扩交付就遥遥无期。交互重做要对着原版找别扭点比如原版题目列表只有上一页下一页没有按标签过滤这就是改版要解决的原版提交后要手动刷新才能看到结果改成自动轮询也是改版要解决的。工程化重构是搭好Vite、目录分层、请求统一封装、构建产物可回滚这些是“换皮”和“改版”的分水岭。我习惯把项目目录按src/api、src/views、src/components、src/stores划分。api目录放接口封装views放页面级路由components放题目卡片、提交状态标签这类复用组件stores放登录态、主题设置这类全局状态。这样划分是为了后端接口变动时只动api目录不至于满项目找请求。完成这一层整理之后新前端的架子就立住了可以开始搭建工程。3. 把新前端跑起来Vite初始化、接口转发与登录态接入改版不是从零写一个OJ是从零写一个能对接现有后端的前端。所以第一件事不是画页面是让开发环境能稳定连接到SYZOJ后端。这里的核心有三个选一个启动快的构建工具、配好devServer转发、封装一个统一的请求模块。这三个做完后面所有页面开发都是在同一个地基上垒墙。3.1 用Vite初始化工程装齐OJ必用依赖选Vite而不是老构建工具原因就一条启动快。OJ前端页面多开发时改一行代码要是等十秒热更新改版节奏立刻慢下来。Vite的devServer能直接做接口转发不用后端配合开CORS省掉很多沟通成本。初始化命令很固定在ZHZXOJ-Web里我是这样建的npm create vitelatest zhzx-oj-web -- --template vue cd zhzx-oj-web npm install npm install vue-router4 pinia axios element-plus npm install markdown-it highlight.jsvue-router管页面路由pinia管登录态这类全局状态axios封装请求element-plus提供表格、分页、表单、消息提示这些基础组件。markdown-it和highlight.js是为题目详情页准备的OJ的题面几乎都是Markdown题面里的代码块需要高亮。依赖装齐后先跑一次npm run dev确认脚手架本身没问题再去做转发配置。3.2 devServer转发配置把 /api 送到 SYZOJ 后端开发时直接让前端请求http://后端地址/api/...会遇到CORS问题后端不开跨域就请求失败。常见做法是不去改后端而是利用Vite的devServer转发浏览器只管请求当前域名下的/apiVite收到后把请求转发到真正的后端。配置写在项目根目录的vite.config.js里import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, // 开发时把 /api 开头的请求转发到 SYZOJ 后端 proxy: { /api: { target: http://127.0.0.1:5280, // 改成你后端实际地址 changeOrigin: true, // 如果后端路由不带 /api 前缀就把下面这行取消注释 // rewrite: (path) path.replace(/^\/api/, ), } } } })两个参数要重点说明。changeOrigin: true是为了让后端认为请求来自它自己的域名有些后端接口按Host做校验不设这个就会拒绝。rewrite是去前缀用的如果后端路由本身带有/api就不需要rewrite如果不带打开注释把/api去掉。我遇到过前后端联调对不上十有八九是这里多配少配了一段。还要注意target里写127.0.0.1而不是localhost。有些Node服务只监听IPv4地址写localhost时解析到::1就连不上。如果你和SYZOJ后端不在同一台机器把target换成后端机器的局域网IP保证网络能互通即可。3.3 request模块与token注入统一处理登录失效和错误提示接口清单整理好之后封装request模块是最值得花时间的一步。SYZOJ这类系统的接口返回结构可能不统一有的直接返回裸对象有的外层包code和data。统一拦截器的价值在于代码里只关心业务数据不用每个页面都写一遍错误弹窗和401跳转。下面是我在ZHZXOJ-Web里的request封装import axios from axios import { ElMessage } from element-plus import router from ../router const service axios.create({ baseURL: /api, // 配合 3.2 的转发规则 timeout: 15000 // 15 秒超时超过就提示 }) // 请求拦截器把登录后拿到的 token 塞进 header service.interceptors.request.use(config { const token localStorage.getItem(zhzx_token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error)) // 响应拦截器统一处理后端 code401 时踢回登录页 service.interceptors.response.use(response { const { code, msg, data } response.data if (code ! 0) { ElMessage.error(msg || 请求失败) return Promise.reject(new Error(msg)) } return data }, error { if (error.response?.status 401) { localStorage.removeItem(zhzx_token) router.push(/login) } ElMessage.error(error.message || 网络异常) return Promise.reject(error) }) export default servicetimeout给15秒是有讲究的提交判题接口可能耗时较长但登录、列表这类普通接口不应该超过这个数。如果普通接口频繁超时先看后端日志多半是接口内部报错而不是网络问题。401统一跳登录是必须的不然token过期后用户会在某个页面反复看到报错弹窗。另一个细节是如果后端返回结构不是{ code, msg, data }这种包装而是直接返回对象或数组这里的拦截器要按真实结构改写常见的做法是兼容两种取response.data后判断是否带code字段。4. 核心页面改版实操登录、题目、提交与排行榜的关键代码地基打好之后改版才进入真正让使用者有感知的部分。按用户主流程走优先级从高到低是登录、题目列表、题目详情、提交代码、提交结果、排行榜。这几个页面串起了OJ的完整使用链路任何一个断掉都会让改版无法交付。4.1 登录页把token存好请求拦截器才用得起来登录是第一个要调通的接口。常见流程是先取验证码再POST用户名密码拿到token然后把token存到localStorage后续请求都带上Authorization头。验证码一般要先调一个获取验证码的接口拿图片和captchaId登录时把captchaId和用户输入一起提交。登录成功后的处理逻辑如下script setup import { ref } from vue import { useRouter } from vue-router import request from ../utils/request import { ElMessage } from element-plus const form ref({ username: , password: , captcha: }) const router useRouter() async function handleLogin() { if (!form.value.username || !form.value.password) { ElMessage.warning(用户名和密码不能为空) return } const data await request.post(/login, form.value) localStorage.setItem(zhzx_token, data.token) localStorage.setItem(zhzx_user, JSON.stringify(data.user)) router.replace(/) } /script这段代码里有两个关键点。一是localStorage.setItem(zhzx_token, data.token)要和3.3里的请求拦截器保持一致key拼错会导致拦截器拿不到token登录成功后请求依然401。二是不要只存token不存用户信息。OJ很多页面要显示当前用户名和权限登录响应里通常带着user对象一起存localStorage页面启动时从pinia里读出来挂到全局状态这样头像、昵称、管理员入口能直接渲染。如果后端有“记住我”功能把token同时写进localStorage并延长过期时间即可。4.2 题目列表分页参数、关键词搜索与标签筛选题目列表页改版最容易做“假分页”前端把返回的列表直接全渲染出来数据一多就卡。正确做法是请求时带page和pageSize由后端分页前端只渲染当前页。配合element-plus的el-pagination组件切换页码时重新请求。核心请求函数长这样const query reactive({ page: 1, pageSize: 20, keyword: , tagId: null }) const list ref([]) const total ref(0) async function loadProblems() { const params { page: query.page, pageSize: query.pageSize, keyword: query.keyword || undefined, tagId: query.tagId || undefined } const data await request.get(/problems, { params }) list.value data.rows total.value data.total }keyword为空时用|| undefined把它从参数里去掉这一点很关键。有些后端会把空字符串当成一个真实关键词去匹配返回空结果集。tagId同理点击标签时重新赋值query.tagId再调loadProblems。标签筛选不要用前端过滤SYZOJ这类系统的标签是挂在题目上的多对多关系题目数量多时前端拿全量再本地过滤会卡死一定要把tagId传给后端让后端按标签把题目集查出来。4.3 题目详情Markdown渲染、数学公式与代码编辑器接入题目详情页的核心是题面渲染。SYZOJ的题面多是Markdown带LaTeX公式直接用markdown-it会遇到公式乱码需要在markdown-it基础上接KaTeX。另外要把html: false关掉防止题面里的脚本直接执行这属于XSS防护的基本操作。题面渲染配置如下import MarkdownIt from markdown-it import hljs from highlight.js const md new MarkdownIt({ html: false, // 禁掉题面里的原始HTML防XSS linkify: true, // 把裸链接自动转成可点击的 highlight(str, lang) { if (lang hljs.getLanguage(lang)) { try { return pre classhljscode hljs.highlight(str, { language: lang }).value /code/pre } catch (e) { /* 语言不识别时走默认转义 */ } } return pre classhljscode md.utils.escapeHtml(str) /code/pre } })html: false是第一道防线很多从老系统导入的题面里会混入神秘代码开了html渲染等于把后门开到页面上。highlight里的try-catch不能省用户可能在代码块里写一个后端不认识的语言标记hljs.getLanguage返回空时要走转义逻辑。公式部分建议再接markdown-it-katex否则LaTeX公式会以反斜杠原样显示后面避坑章节会专门讲这个问题。代码编辑器我习惯接CodeMirror 5或者Monaco具体包名看你的技术选型。接入时要注意语言枚举和原版保持一致。---你负责的提交接口payload大概这样const payload { problemId: route.params.id, language: editorLanguage.value, code: code.value } const data await request.post(/submit, payload) router.push(/submission/ data.submissionId)language字段传什么不是拍脑袋定的。常见语言在SYZOJ里一般是C、Python 3、Java但不同后端枚举值不一样。在原版页面提交一次看Network面板里language字段传的是什么照抄进新版的下拉框。提交按钮要加loading态防止用户连点两次生成两条重复提交。4.4 提交状态与排行榜用轮询代替手动刷新原版最让人难受的交互就是提交后要手动刷新页面才能看到结果。新版改成自动轮询。后端如果不支持WebSocket推送轮询是最省事的方案。评测一般几秒出结果间隔定2秒比较合适间隔太小会给后端加压力太大用户会着急。轮询逻辑用递归setTimeout而不是setInterval避免请求耗时和间隔时间叠加导致节奏漂移let timer null async function pollStatus(submissionId) { timer setTimeout(async () { const data await request.get(/submission/${submissionId}) status.value data.status if (isFinished(data.status)) { // 结束后再拉一次完整结果展示测试点明细 detail.value await request.get(/submission/${submissionId}/detail) } else { pollStatus(submissionId) // 递归调用继续等下一轮 } }, 2000) } function isFinished(status) { return [Accepted, Wrong Answer, Runtime Error, Time Limit Exceeded].includes(status) }组件卸载时清理timer是必须做的。单页应用里切换路由后组件会被销毁如果不清理轮询还在后台跑页面已经切走了控制台里全是请求报错。我在onUnmounted里加一句clearTimeout(timer)就解决了。排行榜页的刷新逻辑同理只是间隔可以放到10到30秒不需要那么频繁。5. 改版路上常见的五个坑从登录401到生产环境404的排查记录改版过程中遇到的坑大多数不是前端语法问题而是“前端和后端之间契约不对”的问题。这一章写五个我踩过的、也最常被问到的坑。每一条都按现象、原因、解决的顺序展开方便你排查时直接对照。5.1 登录一直401token和Cookie混用现象前端登录接口返回401后端日志看到请求是通的但始终拿不到用户态。原因令牌没搭上或者后端要的是Cookie。改版时常常会遇到后端是老式Session认证登录成功响应头里Set-Cookie才是关键前端的Authorization头拿不到登录态。我在第一次改版时就栽在这里原版页面在iframe里跑Cookie由浏览器自动带新版是前后端分离请求由axios发出Cookie不会自动跨域带过去。解决先在Network里看登录成功后的响应头确认是返回token还是Set-Cookie。如果是Cookieaxios需要配置withCredentials: true如果既返回token又写Cookie优先用Authorization头并在后续请求中带上后端一般两套都认。不要写两套逻辑选择一种前后端对齐。确认方式很简单登录成功后随便调一个需要登录态的接口看返回是200还是401。5.2 题面里的数学公式乱码忘了接KaTeX现象题目里本来就是一条条公式渲染出来变成一行行的反斜杠符号或者一堆乱码。原因markdown-it默认不解析LaTeX语法。OJ题库很多题面带$内容$或$$内容$$这种公式语法在标准Markdown里没有定义缺KaTeX插件就是乱码。这个问题一点都不玄学就是插件没装。解决装markdown-it-katex在4.3的md实例上挂插件。配置时要注意让公式的解析在markdown-it的inline和block规则里都生效同时把KaTeX渲染后的CSS引入项目否则公式虽然解析了样式却是错的。引入方式是全局加载katex/dist/katex.min.css两手都要有缺一个都会表现为“公式显示不对”。5.3 CtrlEnter提交代码后页面刷新写一半的代码全丢了现象用户在编辑器里写了一大段代码按下CtrlEnter提交整个页面重新加载代码没提交还全没了。原因编辑器所在的外层DOM被包在form里keydown事件没有阻止默认行为浏览器把CtrlEnter当成了隐式提交表单。这类问题最常出现在接CodeMirror这类第三方编辑器时只监听了编辑器的keydown没管外层form。解决监听window的keydown或编辑器实例的keymap命中CtrlEnter时先调用event.preventDefault()再执行提交函数同时保证提交按钮的type不是submit避免回车触发原生表单提交。提交过程中做loading态提交成功后禁用按钮双重提交的问题也能一起解决。5.4 测试点结果对不上后端数据是异步补充的现象提交历史里显示Accepted点进去测试点却有些没显示或者第2个测试点显示TLE但总得分却比预想高。原因SYZOJ这类系统里提交的头信息和测试点明细经常是异步写库的。前端只拉了一次详情接口拿到的可能是不完整的临时数据。我一度以为是接口写错了反复检查才发现是数据源自身没写完。解决详情页按两次请求处理先拉状态再在状态为非排队中后拉完整测试点列表如果明细为空加一个短轮询兜底。展示时按subtask结构渲染不要假设测试点一定是平铺数组先看返回字段再做解析。这种问题排查时最有效的手段是直接看后端数据库里这条提交记录的字段更新时间确认是前端解析问题还是写入时序问题。5.5 打包后接口全部404dev转发和生产转发是两回事现象本地开发一切正常打包部署后打开首页行点进某个题目或提交页就404接口也报404。原因Vite devServer的proxy只在开发时存在生产包是纯静态文件所有请求要由外层nginx转发而且history路由模式下后端没有配置fallback时只认index.html。我在部署ZHZXOJ-Web的第一版时就吃了这个亏本地跑得好好的上线全挂。解决部署时在nginx的server块里给/api加location转发到SYZOJ后端同时配location / { try_files $uri $uri/ /index.html; }。如果接口路径带/api而后端不带记得在nginx转发时也做rewrite保持和dev一致。这一条不写在部署文档里改版项目就很难真正交付所以最后再确认一遍。6. 后端没就绪时用Mock顶住按核心链路自测一遍验收改版改版过程中最磨人的是“等后端”。后端接口没完全就绪时前端可以先通过Mock把页面流程跑通等联调时再切回真实接口。Mock不是造假数据是把真实接口的返回结构先本地模拟一份让开发不被阻塞也让后端同学少被问“接口好了没”。// 只在开发环境且打开开关时启用Mock const useMock import.meta.env.DEV import.meta.env.VITE_USE_MOCK true if (useMock) { service.interceptors.request.use(config { if (config.url /problems) { // 返回一组模拟题目请求不会真实发出 return Promise.resolve({ code: 0, data: { rows: mockProblems, total: mockProblems.length } }) } return config }) }Mock数据要尽量贴真实接口结构不然切到真实后端还要返工。开关用.env文件里的VITE_USE_MOCK控制生产构建时自动关闭。只Mock流程不Mock评测耗时的细节避免轮询逻辑在Mock下跑通、真实环境反而出问题。自测时按核心链路走一遍登录拿token题目列表翻页和标签筛选打开题目详情确认题面公式正常提交代码后看到轮询更新状态排行榜翻页不丢参数。这几步走完改版成果就能拿去给使用者试跑。最后说一个我踩过的教训改版最忌讳一上来就调样式样式是最后5%的功夫前面95%是接口契约和数据结构。我第一版就是先花了三周调页面最后才发现登录态走的是Cookie整个request层推倒重来。后来养成习惯先抓包、再Mock、最后做交互和样式后两个版本都是一周出头就交付。希望帮到你。本文还有配套的精品资源点击获取