SYZOJ OJ前端改版:Express+EJS模板与评测状态轮询避坑
简介:ZHZXOJ-Web 是一套基于 SYZOJ 前端改版而来的在线评测系统前端工程,面向需要自建 OJ 的站长、校内竞赛平台维护者,以及想研究评测系统界面实现的 JavaScript 开发者。项目虽已停止维护,但页面结构与构建产物相对完整,可作二次开发或学习前端工程组织的参考底稿。压缩包共 524 个文件,约 12.48MB,以 211 个 js 脚本、105 个 css 样式表和 49 个 ejs 模板为主,另含 svg 图标与 ttf、woff、woff2、eot 等多套字体资源,以及 17 个 TypeScript 文件、若干 json 配置与 source map,基本覆盖页面渲染、组件样式与静态资源加载各环节。资源描述还给出了启用 HTTPS 时的 Nginx 配置片段,涉及 443 端口监听、HTTP 跳转重写与证书路径设置,并附可选的 TLS 协议与加密套件参数,便于部署时对照调整。目前已有 210 人学习下载,适合希望获得一份可读、可改的 OJ 前端模板的读者。
1. 一个 OJ 前端改版最难的不是配色:从评测状态那个小圆点说起
选手点下“提交”,页面右上角跳出一个小圆点,从灰色的 Waiting 变成黄色的 Judging,几秒后变绿——AC。这一串动作里,前端要处理的东西比想象中多:模板得把题目正文的 Markdown 正确渲染,提交页得校验语言和代码长度,状态得靠 JavaScript 定时去问后端,权限还得保证未公开的题目不会被猜 id 猜出来。这套基于 SYZOJ 前端改版的项目,改的就是这些地方,只是顺带把视觉也重做了一遍。它适合两类人:手上有一台现成的评测机、只想要一套干净前端的人;以及接手了老 OJ、被一堆内联 jQuery 和写死路径折磨过的维护者。别把它当成“换个皮”,真跑起来你会发现,动刀最多的是模板层和状态同步。
2. 技术栈与目录结构:Express + EJS 的老配方,改版后哪些层被换掉了
2.1 技术栈盘点:为什么还留着服务端渲染这套
这套前端的主体是 Node.js + Express,模板用 EJS,数据层走 Sequelize 连 MySQL/MariaDB,会话用 express-session 存在数据库里,实时状态的原版实现靠 socket.io 和评测机通信。前端资源是老搭档:Bootstrap 那一代的栅格和组件、jQuery 做 DOM 操作、marked 负责 Markdown 转 HTML、MathJax 或 KaTeX 处理公式、highlight.js 做代码高亮。
改版最容易犯的错,是一上来就把 EJS 换成 Vue/React 单页应用。OJ 的页面大多是“读多写少”的文档型页面:题目详情、比赛列表、榜单、提交记录。这类页面服务端渲染出来的首屏体验更好,SEO 和分享链接也正常,而且不用维护一套前后端分离的接口契约。我的建议是保留 EJS,只把页面里那些散落的内联脚本收敛成独立的 JavaScript 模块,用原生 fetch 替代 jQuery 的$.ajax。Bootstrap 可以换成更轻的自定义 CSS 变量方案,但没必要为了“现代化”三个字把整套渲染链路推翻——老项目里最贵的不是技术选型,是你改完之后没人敢再动它。
需要提醒的是依赖年龄。这套代码里的部分包停留在三四年前,node-sass、老版本sequelize、socket.io都有原生模块或 API 变更的坑,Node 版本建议压在 16 LTS 这个档位,别直接上最新的奇数版本。
2.2 目录对照:五层各管什么,改版动的是哪几层
把仓库摊开看,结构大致是这样几块,改版时你得知道每一层归谁管:
| 目录 | 职责 | 改版时的动作 |
|---|---|---|
routes/ | 路由与参数解析,把 URL 映射到模板 | 少量改,新增页面时加路由 |
modules/ | 业务逻辑,权限判断、提交入队、比赛状态 | 谨慎改,容易牵一发动全身 |
models/ | Sequelize 模型定义,字段与关联 | 基本不动,动了要迁移数据 |
views/ | EJS 模板,页面结构 | 改版主战场 |
static/ | CSS、JavaScript、图片、第三方库 | 改版主战场 |
middlewares/ | 登录态注入、权限拦截、验证码 | 看情况,权限收紧时改 |
interfaces/ | 与评测机通信的协议类型定义 | 别动,动了评测机收不到活 |
一个常见的误区是把views/当成纯静态页面来改,改完发现数据没出来——EJS 里每个变量名都得和路由res.render传的键对上,字段名错一个字母,页面就是一片空白,而且不报错,这是玄学感的来源之一。稳妥的做法是先跑通原始版本,再一个页面一个页面地替换,每次只改一个模板,用 Git 对比。
static/这一层要注意挂载点。原版通常把静态目录挂在/static这种绝对路径上,改版如果把目录改成assets或者要部署到子路径下(比如/oj/),模板里所有写死的/static/...全都要跟着改,否则就是整站样式消失。
2.3 一次题目页请求的完整链路
理解链路比背目录有用。用户访问/problem/1001,请求进入 Express,依次经过会话中间件(把当前用户塞进res.locals)、权限中间件、路由处理,路由去数据库捞题目,模板引擎把 EJS 和数据合成 HTML 返回。
app.js里的几个挂载点决定了这条链路的形状,改版时最先要看的就是这几行:
// app.js —— 改版时最先确认的挂载点 app.set('view engine', 'ejs'); // 模板引擎,改版后沿用,不要轻易换 app.set('views', path.join(__dirname, 'views')); // 模板目录,换目录这里必须同步 // 静态资源挂载:第一个参数是 URL 前缀,第二个是磁盘目录 // 部署到子路径时,这个前缀和模板里的引用要一起改 app.use('/static', express.static(path.join(__dirname, 'static'), { maxAge: '7d' // 生产环境给长缓存,改版后靠文件名 hash 或查询串刷新 })); // 会话:secret 从配置读,store 走数据库,重启不掉登录态 app.use(session({ secret: config.server.session_secret, store: sessionStore, resave: false, saveUninitialized: false, cookie: { maxAge: 1000 * 60 * 60 * 24 * 7 } // 一周 })); // 把当前用户注入模板,所有 EJS 里都能直接用 user app.use((req, res, next) => { res.locals.user = req.session.user || null; res.locals.csrfToken = req.session.csrfToken || ''; next(); }); app.use('/', routes);几个参数值得单独说。maxAge是浏览器缓存时间,改版期间建议先设成0或者给静态资源加版本查询串,否则你改了 CSS 刷新十次还是旧样式,会怀疑人生。saveUninitialized: false是为了不给出每个未登录访客都建一条 session 记录,OJ 这种流量下这个差别不小。res.locals.user注入之后,模板里判断登录态就是一句<% if (user) { %>,不用每个路由自己传。
这条链路里最容易出问题的是中间件顺序:权限中间件必须排在会话中间件之后,否则拿不到req.session;静态资源中间件最好排在会话之前,让图片和 CSS 请求不触发数据库查询。
3. 核心模块拆解:题目页、提交页、状态轮询三处代码逐段过
3.1 路由层:problem.js 怎么把 URL 参数变成模板数据
题目页是整个 OJ 里最复杂的单页:正文、输入输出格式、样例、时空限制、相关提交记录,还要处理“题目未公开但有人猜 id”的情况。路由的骨架大致长这样:
// routes/problem.js const express = require('express'); const router = express.Router(); const Problem = require('../models/problem'); const md = require('../libs/markdown'); // 包装过的 marked,统一配置 router.get('/:id', async (req, res, next) => { // 1. 参数校验:id 必须是正整数,否则直接交给 404 处理,不查库 const id = Number.parseInt(req.params.id, 10); if (!Number.isInteger(id) || id <= 0) return next(); // 2. 查库:只取页面需要的字段,避免把测试数据之类的字段带出来 const problem = await Problem.findByPk(id, { attributes: [ 'id', 'title', 'is_public', 'background', 'description', 'input_format', 'output_format', 'hint', 'sample_input', 'sample_output', 'time_limit', 'memory_limit' ] }); if (!problem) return next(); // 3. 权限:未公开题目只有登录用户(或管理员)能看 // 注意这里不能返回 404,否则管理页跳转过来会失效 const user = res.locals.user; if (!problem.is_public && !user) { return res.status(403).render('error', { message: '该题目暂未公开' }); } // 4. 渲染:Markdown 在这里统一转成 HTML,模板只负责套壳 res.render('problem', { problem, statementHtml: md.render(problem.description || ''), sampleHtml: md.render(problem.sample_input || ''), activeNav: 'problems' }); }); module.exports = router;逐段说。参数校验放在最前面,是因为 OJ 会被人用脚本爬,/problem/abc、/problem/999999999这种请求一天能来几千次,每次都不必要地打一次数据库,是很典型的性能损耗点。attributes白名单是个血泪经验:默认全字段查询很容易把不该给前端看的字段(比如测试数据路径、内部备注)一起塞进模板,虽然模板里不写就不会输出,但一旦有人写了个JSON.stringify(problem)打日志,就全泄了。
权限那段的选择值得展开。未公开题目返回 403 还是 404,是个产品决策:返回 403 会暴露“这个 id 存在”,返回 404 则更安全但管理员的预览链接会失效。我的做法是登录用户返回 403、未登录用户返回 404,兼顾体验和隐蔽性。
最后是渲染数据的命名。statementHtml这种带后缀的键名是个好习惯,模板里一眼能看出这是已经处理过的 HTML、不需要再转义。改版的时候模板要重写,键名保持稳定,能省掉大量对照调试的时间。
3.2 视图层:<%=和<%-差一个字符,差的是 XSS 和满屏乱码
EJS 有两个输出标签,新手最容易混:<%=会做 HTML 转义,<%-原样输出。题目正文是 Markdown 转来的 HTML,必须用<%-才能正常显示段落和代码块;而标题、用户名这类纯文本必须用<%=,否则一个叫<script>的用户名就能在榜单页执行脚本。
<!-- views/problem.ejs 片段 --> <div class="problem-head"> <!-- 标题是纯文本,转义输出 --> <h1><%= problem.id %>. <%= problem.title %></h1> <div class="problem-meta"> <span class="tag">时间 <%= problem.time_limit %> ms</span> <span class="tag">内存 <%= problem.memory_limit %> MB</span> </div> </div> <!-- 正文是富文本 HTML,只能用非转义输出,但前提是上游已经过滤过危险标签 --> <article class="markdown-body"><%- statementHtml %></article> <% if (sampleHtml) { %> <section class="problem-sample"> <h3>样例输入</h3> <pre><code><%= problem.sample_input %></code></pre> </section> <% } %>注意样例那块:sample_input直接进<pre><code>,用<%=转义。因为样例里可能有<、>、&这些字符,不转义会被浏览器当成标签吃掉,题面就“缺字”了,这种 bug 往往只有选手反馈才知道。而<%- statementHtml %>之所以敢用非转义,是因为md.render出来的 HTML 在libs/markdown.js里统一配置过,禁用原始 HTML 或者接一层白名单过滤。如果改版时把渲染函数换成默认配置的 marked,用户就能在题面里塞<img onerror=...>。
页面里的交互脚本同理,改成独立文件按页面注册:
// static/js/problem.js —— 只在题目页加载 document.addEventListener('DOMContentLoaded', () => { // 复制样例:用 Clipboard API,老浏览器留个 execCommand 兜底 document.querySelectorAll('[data-copy]').forEach(btn => { btn.addEventListener('click', async () => { const target = document.querySelector(btn.dataset.copy); if (!target) return; try { await navigator.clipboard.writeText(target.innerText); btn.textContent = '已复制'; setTimeout(() => (btn.textContent = '复制'), 1500); } catch (e) { // 非 HTTPS 环境下 clipboard 不可用,这是最常见的翻车点 console.warn('clipboard unavailable', e); } }); }); });navigator.clipboard只在安全上下文(HTTPS 或 localhost)可用,学校内网用 HTTP 部署的话复制按钮会静默失效,必须留兜底。这类问题不报错,只是“点了没反应”,排查起来很耗时间。
3.3 提交页与状态轮询:把开环刷新改成可控的退避轮询
原版靠 socket.io 推送评测状态,好处是实时,坏处是前端要维护连接、重连、心跳,改版时如果只换了 UI 没动这块,很容易出现“状态不动了”。改版里我一般会先落一个轮询方案,简单、可控、好排查:
// static/js/submission-status.js const POLL_INTERVAL = 800; // 起始间隔 const MAX_INTERVAL = 5000; // 最长间隔,避免服务器被打 const TERMINAL = ['accepted', 'wrong_answer', 'time_limit_exceeded', 'memory_limit_exceeded', 'runtime_error', 'compile_error', 'system_error', 'canceled']; async function pollStatus(submissionId, onUpdate) { let interval = POLL_INTERVAL; const tick = async () => { try { const res = await fetch(`/api/submission/${submissionId}/status`, { headers: { 'Accept': 'application/json' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); onUpdate(data); // 交给页面更新圆点和文案 if (TERMINAL.includes(data.status)) return; // 终态,停止轮询 // 还在评测队列里就把间隔拉长,减轻后端压力 interval = Math.min(interval * 1.4, MAX_INTERVAL); setTimeout(tick, interval); } catch (e) { // 网络抖动不要直接放弃,退避后重试,连续失败三次才停 interval = Math.min(interval * 2, MAX_INTERVAL); setTimeout(tick, interval); } }; tick(); }逻辑上有三个点。第一是终态判断必须和白名单对齐,后端返回的状态字符串一旦改名(比如accepted写成AC),轮询就永远停不下来,页面会一直转圈,这是最容易翻车的地方,前后端的状态枚举要放在同一个常量文件里。第二是退避,几百个选手同时提交的时候,固定 1 秒轮询就是一记小型 DDoS,起始 800ms 逐步退到 5 秒是折中。第三是异常不放弃,OJ 部署在内网,网络抖动很常见,连续失败才停,比一次失败就停要稳。
后端那个状态接口本身很简单,返回 id、status、score、time、memory 就够了,别把编译日志一次性塞进去——编译错误日志可能几 KB,轮询时每次都传会拖慢页面。
3.4 静态资源与主题层:改版真正动刀的地方
视觉层的改造,最容易做成“一堆散落的十六进制颜色值”,第二个人接手就没人敢改。稳妥的做法是把颜色、圆角、间距抽成 CSS 变量,主题切换只改:root上的几个值:
/* static/css/theme.css */ :root { --color-bg: #ffffff; --color-fg: #1f2328; --color-muted: #6b7280; --color-primary: #2563eb; --color-accepted: #16a34a; --color-wrong: #dc2626; --radius-card: 8px; --gap-page: 16px; } /* 暗色主题只需要覆盖变量,页面结构一行不动 */ [data-theme="dark"] { --color-bg: #16181d; --color-fg: #e6e6e6; --color-muted: #9ca3af; } body { background: var(--color-bg); color: var(--color-fg); margin: 0 auto; max-width: 1100px; padding: var(--gap-page); } /* 评测状态色统一走变量,榜单和提交页共用一套 */ .status-accepted { color: var(--color-accepted); } .status-wrong_answer { color: var(--color-wrong); }把状态色做成类名映射,好处是榜单、提交记录、题目页三处的颜色永远一致。改版里最容易出现的不一致就是“提交记录页 AC 是绿色,排行榜 AC 是蓝色”,看着别扭但没人说得清哪错了,本质就是颜色散落在三个文件里。
4. 本地跑起来:Node 版本、MySQL、config.json 与第一次启动
4.1 环境与依赖:Node 版本怎么选
依赖装不起来是这套老项目最高频的拦路虎,多数时候不是代码问题,是 Node 版本对不上。我的选择是 Node 16 LTS,理由很实际:node-sass这类包在 Node 18 以上需要重编译,Windows 上还要装编译工具链,新手在这一步就劝退了。如果你不需要 sass,最省事的做法是把源码里预编译好的 CSS 直接拿来用,把node-sass从package.json里摘掉,改成sass(dart-sass,纯 JavaScript 实现,没有原生依赖)。
# 确认版本,Node 16 是这套老依赖比较稳的档位 node -v # v16.x npm -v # 用 ci 而不是 install:有 lock 文件时保证依赖树完全一致 npm ci # 如果卡在 node-sass 编译,直接换成 dart-sass npm uninstall node-sass npm install --save-dev sassnpm ci和npm install的区别值得强调:前者严格按 lock 文件装,后者会去解最新的兼容版本。老项目里^号开头的依赖,install装出来的可能是比作者当年新两三个大版本的包,行为不一致,排查时会把注意力引向错误的方向。
4.2 配置文件:config.json 三段怎么填
配置一般是根目录的config.json(有的版本是config.example.json需要复制改名)。核心是三段:服务、数据库、评测机。
{ "server": { "host": "0.0.0.0", "port": 8080, "session_secret": "换成随机字符串" }, "db": { "database": "oj", "username": "oj_user", "password": "your_password", "host": "127.0.0.1", "port": 3306, "dialect": "mysql", "logging": false }, "judge": { "key": "judge-shared-key", "port": 2333 } }session_secret必须换掉,用默认值等于把会话签名公开。dialect一般是mysql,MariaDB 也走这个。logging: false在本地调试时可以先设true,能看到 Sequelize 打的每条 SQL,改版后页面数据不对,很多时候一眼就能看出是哪个字段名写错了。judge.key是 Web 端和评测机之间的共享密钥,两边必须一致,不一致的表现是评测机连上了但拿不到任务,页面永远停在 Waiting。
数据库字符集必须用utf8mb4,四字节字符(题面里的特殊符号、公式里的希腊字母)用utf8存会直接报错或者变问号。
4.3 建库、同步表结构与启动
# 1. 建库,字符集一定带上 utf8mb4 mysql -uroot -p -e "CREATE DATABASE oj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 2. 启动。首次启动 Sequelize 会 sync 出表结构 NODE_ENV=development node app.js # 3. 看到监听日志就说明 Web 层起来了 # Server listening on 0.0.0.0:8080首次启动会自动建表,但不会给你一个管理员账号。常见做法是先注册一个普通用户,然后直接改数据库把is_admin置为 1:
-- 把第一个注册的用户提升为管理员 UPDATE users SET is_admin = 1 WHERE id = 1;这一步是新建 OJ 绕不开的,很多人卡在“后台进不去”就是因为没做这步。另外NODE_ENV=development会关掉模板缓存,改 EJS 刷新即生效;生产环境用production时模板会被缓存,改完不重启看不到变化,这个差异坑过不少人。
4.4 不接评测机的前端调试法
改版主要动的是前端,但评测机是个独立的 C++ 守护进程,本地不一定能跑起来。这时候的策略是“只验渲染,不验评测”:题面、列表、榜单、提交记录这些页面都不依赖评测机,能正常渲染就说明模板改对了;提交后状态停在 Waiting 是正常现象,不用怀疑代码。
如果一定要看状态流转,可以在数据库里手动改一条提交记录的状态:
-- 手动模拟一次评测完成,用来调试前端状态渲染 UPDATE submissions SET status = 'accepted', score = 100, time_used = 120, memory_used = 2048 WHERE id = 1;刷新页面看到绿色 AC,说明状态映射和样式都对。这招在改版配色的时候特别好用,五种状态挨个改一遍,就能把颜色对照表验证完。
5. 避坑排查:改版过程中五个典型翻车现场
5.1 改了 EJS 刷新页面没变化
现象:模板里明明加了一段 HTML,浏览器刷新后看不到,清缓存、换浏览器都没用。
原因:生产模式下 Express 默认开启view cache,模板编译结果被缓存;另一种情况是启动的进程和你编辑的不是同一份代码(比如开了 pm2 或者 nodemon 没重启成功)。
解决:确认NODE_ENV不是production,或者在app.js里显式关掉:app.set('view cache', false)。用ps aux | grep node看有几个进程在跑,比清浏览器缓存管用得多。
5.2 题面里的小于号把后面的内容全吃掉了
现象:题目正文写着“输入保证 a < b”,页面上从<开始后半段全没了,或者代码块里的内容错乱。
原因:Markdown 渲染器把<b>之类当成了 HTML 标签;或者模板里该用<%=的地方写成了<%-,导致用户原始输入直接进了 DOM。
解决:渲染前先做 HTML 转义再交给 marked,或者给 marked 配置禁用内联 HTML 的选项。样例区块统一用<%=输出。改完之后拿一段<script>alert(1)</script>当题面测一遍,看到它是原样显示的文字而不是弹窗,才算过关。
5.3 整站样式消失,控制台一片 404
现象:HTML 出来了但完全没样式,Network 面板里/static/css/main.css全是 404。
原因:静态目录或挂载前缀被改了,但模板里还是写死的绝对路径/static/...;部署在子路径(如/oj/)下时,绝对路径会指向站点根,直接跑偏。
解决:模板里改用相对路径或统一的辅助函数生成前缀,express.static的挂载前缀和模板引用必须同步改。用curl -I http://127.0.0.1:8080/static/css/main.css单独确认这个 URL 通不通,比在浏览器里猜快。
5.4 提交后一直停在 Waiting,评测机日志没动静
现象:代码能提交,记录也进了数据库,但状态永远不变;评测机那边日志干干净净。
原因:Web 端和评测机的连接没建立,通常是judge.key两边不一致,或者评测机的连接地址/端口配错,或者防火墙拦了那个端口。也可能是只跑了 Web 没跑评测机——本地开发常见。
解决:先看 Web 端启动日志里有没有评测机连接记录,再看评测机日志有没有鉴权失败。只做前端调试的话,按 4.4 的方式手动改数据库状态即可,不要在这上面死磕。
5.5 比赛开始时间和实际差八小时
现象:设的是晚上八点开赛,到点页面还显示“未开始”。
原因:数据库连接时区没配,Sequelize 默认按 UTC 存取,服务器本地时区是东八区,一进一出就差 8 小时。
解决:在config.json的db段里加"timezone": "+08:00",并且建库时确认字段类型是DATETIME而不是TIMESTAMP(后者会跟着时区自动转换,更容易乱)。改完拿一条已有数据对比数据库里存的值和页面显示的值,比看文档靠谱。
6. 二次开发:把一次改版沉淀成一套能反复用的主题层
改版做完如果只留下“一堆被改过的 EJS”,那下一个接手的人会重新经历一遍你踩过的坑。我后来习惯在收尾时做三件事,把一次性的改造变成能复用的东西。
第一件,抽一个统一的渲染中间件,把所有页面都要的变量一次性注进去,模板里不再各传各的:
// middlewares/view-locals.js module.exports = (req, res, next) => { res.locals.siteName = config.server.site_name || '在线评测系统'; res.locals.user = req.session.user || null; res.locals.currentPath = req.path; // 导航高亮用 res.locals.formatTime = (ts) => { // 统一时间格式,避免各页各写 if (!ts) return '-'; const d = new Date(ts); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }; next(); };挂上去之后,views/里所有formatTime的调用都走同一份逻辑,改时区、改格式只改一处。currentPath是为了导航高亮,避免每个路由都传一次activeNav。
第二件,做一个主题目录,把布局拆成 partial,主布局只管引用:
| 改造点 | 涉及文件 | 风险等级 | 说明 |
|---|---|---|---|
| 换配色 | static/css/theme.css | 低 | 只动 CSS 变量 |
| 换布局 | views/layout.ejs+ partials | 中 | 注意每个页面的 block 名要对上 |
| 换 Markdown 渲染 | libs/markdown.js | 中高 | 改完必须回归样例和公式 |
| 加新页面 | routes/+views/+static/js/ | 低 | 三处同步加,别漏脚本引入 |
| 改状态枚举 | 前后端各一处常量 | 高 | 必须同时改,否则轮询不停 |
第三件,写一个三十行的冒烟脚本,改完随手跑一遍,比手动点五个页面快得多:
#!/usr/bin/env bash # smoke.sh —— 改完前端跑一遍,确认关键页面没有 500 BASE="http://127.0.0.1:8080" for p in "/" "/problems" "/problem/1" "/submissions" "/contests" "/user/1"; do code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$p") printf "%-16s %s\n" "$code" "$p" done只要全是 200 和 403(未登录访问受限页返回 403 是正常的),就说明模板层没有语法错误。模板报错在 Express 里通常表现为 500 加一段堆栈,浏览器上看不出是哪一行,用这个脚本能第一时间定位到是哪个页面挂的。
从那以后我每次动views/之前,都会先跑一遍这个脚本记下基线,改完再跑一遍对比。看起来是多此一举,但 OJ 这种页面多、模板互相引用的项目,改一个 partial 影响七八个页面是常事,没有基线就只能靠人肉点。希望这套改版的拆解能帮到你,至少别在 Node 版本和字符集上栽跟头。
本文还有配套的精品资源,点击获取