一个OJ系统的诞生(十)前端+部署 本文是一个OJ系统的诞生的最后一篇。前面9篇我们逐步剖析了整个后端现在讲解的是前端和部署。前端是5个原生HTML页面6个JS文件不依赖任何前端框架部署是start.shsystemd一键启动。1本篇模块project-cpp-oj-vibecoding/ ├── public/ ← ★ 前端所有用户看到的页面 ★ │ ├── index.html ← 首页题目列表 │ ├── login.html ← 登录页 │ ├── register.html ← 注册页 │ ├── problem.html ← 题目详情 代码编辑器 提交 │ ├── admin.html ← 管理后台 │ ├── css/ │ │ └── style.css ← 全局样式暗色主题 │ └── js/ │ ├── api.js ← fetch 封装统一调用后端 API │ ├── auth.js ← 登录状态检查 │ ├── problem.js ← 题目列表渲染 │ ├── problem_detail.js ← 题目详情 CodeMirror 编辑器 │ ├── submit.js ← 提交 轮询结果 │ └── admin.js ← 管理后台 CRUD 逻辑 ├── scripts/ │ ├── start.sh ← 一键编译 启动 │ └── oj-backend.service ← systemd 服务配置前端技术选型技术为什么选它原生 HTML CSS JS零依赖、零构建、打开即用。不需要 npm install、不需要 webpack、不需要 React/Vue。对于 5 个页面的小项目原生够了CodeMirror 5CDN最流行的网页代码编辑器。CDN 引入一行 HTML 就搞定。支持 C 语法高亮、行号、括号匹配、Tab 缩进fetch API浏览器原生支持的 HTTP 请求 API不需要 axios/jQuery2api.js——前后端通信的电话线所有前端页面都通过这个文件和后端通信。// // 文件名: api.js // 作用: 封装 fetch API统一处理所有后端请求 // // 每个页面调用后端 API 时都用这个函数 // api(/api/problems) → GET 请求 // api(/api/login, { body: JSON.stringify({...}) }) → POST 请求 // // 它会自动 // 1. 携带 Cookiecredentials: same-origin // 2. 设置 Content-Type 为 JSON // 3. 解析返回的 JSON // 4. 如果出错抛出异常 // async function api(path, options {}) { // 提取 headers如果有的话其他参数用 ...rest 接收 const { headers, ...restOptions } options; // 发送 HTTP 请求 const resp await fetch(path, { credentials: same-origin, // ★ 关键自动携带 Cookie // 这样后端才能获取 session_id // 没有这行 → 每次请求都是未登录状态 ...restOptions, // 其他参数method, body 等 headers: { Content-Type: application/json, // 默认 JSON 格式 ...headers, // 可覆盖 }, }); // 解析响应文本 const text await resp.text(); let data; // 尝试解析 JSON失败就用文本作为错误信息 try { data JSON.parse(text); } catch (e) { data { error: text || Unknown error }; } // 如果 HTTP 状态码不是 2xx抛出异常 if (!resp.ok) throw new Error(data.error || HTTP ${resp.status}); // 这样调用者可以用 try-catch 统一处理错误 return data; }使用事例// 在 login.html 中 async function handleLogin() { try { const data await api(/api/login, { method: POST, body: JSON.stringify({ username: alice, password: 123456 }), }); alert(登录成功欢迎 data.username); window.location.href /; // 跳转到首页 } catch (e) { alert(登录失败 e.message); // 比如 Invalid username or password } }3auth.js——登陆状态管理// // 文件名: auth.js // 作用: 检查用户是否登录 // // 每个需要登录才能访问的页面都在页面加载时调用 requireAuth() // 如果未登录自动跳转到登录页 // // 检查登录状态调用 /api/me 接口 async function checkAuth() { try { const data await api(/api/me); // 获取当前用户信息 return { ok: true, user: data }; // 已登录 } catch (e) { return { ok: false, error: e.message }; // 未登录或过期 } } // 强制要求登录如果未登录跳转到登录页 async function requireAuth(redirect /login.html) { const result await checkAuth(); if (!result.ok) { window.location.href redirect; // 跳转到登录页 return null; } return result.user; // 返回用户信息 }在页面中的使用!-- problem.html 页面加载时 -- script src/js/api.js/script script src/js/auth.js/script script // 页面加载时立即检查登录 // 如果未登录跳转到 login.html requireAuth(); // 或者可以拿到用户信息 requireAuth().then(user { console.log(当前用户, user.username); console.log(角色, user.role); }); /scriptCookie鉴权的完整流程浏览器 服务器 │ │ │ POST /api/login │ │ {username:alice} │ │──────────────────────→│ │ │ 验证密码 │ │ 生成 session_id │ │ 写 Session 文件 │ 200 OK │ │ Set-Cookie: │ │ session_ida1b2... │ │←──────────────────────│ │ │ │ ★ 浏览器自动保存 Cookie │ │ │ GET /api/me │ │ Cookie: session_id │ │ a1b2... │ │──────────────────────→│ │ │ 读 Session 文件 │ │ 返回用户信息 │ 200 OK │ │ {id:1,username: │ │ alice} │ │←──────────────────────│45个页面职责1index.html——题目列表┌─────────────────────────────────────────────────┐ │ 标题: OJ System │ │ 导航: [首页] [管理后台] [登出] │ ├─────────────────────────────────────────────────┤ │ ┌─────────────────────────────────────────────┐│ │ │ 题目列表 ││ │ ├──────┬──────────┬──────────┬──────────┬────┤│ │ │ ID │ 标题 │ 难度 │ 通过率 │ 提交││ │ ├──────┼──────────┼──────────┼──────────┼────┤│ │ │ 1 │ 两数相加 │ easy │ 65.5% │ 200││ │ │ 2 │ 反转链表 │ medium │ 80.0% │ 50 ││ │ │ 3 │ 红黑树 │ hard │ 10.0% │ 100││ │ └──────┴──────────┴──────────┴──────────┴────┘│ └─────────────────────────────────────────────────┘核心JS逻辑// problem.js — 加载并渲染题目列表 async function loadProblems() { const data await api(/api/problems); // GET /api/problems const table document.getElementById(problem-table); for (const p of data) { const row table.insertRow(); row.innerHTML td${p.id}/td tda href/problem.html?id${p.id}${p.title}/a/td tdspan classbadge-${p.difficulty}${p.difficulty}/span/td td${p.pass_rate.toFixed(1)}%/td td${p.total_submissions}/td ; } } // 页面加载时执行 loadProblems();2login.html——登陆页面·!DOCTYPE html html head title登录 - OJ System/title link relstylesheet href/css/style.css /head body div classform-container h2登录/h2 form idloginForm input typetext idusername placeholder用户名 required input typepassword idpassword placeholder密码 required button typesubmit登录/button /form p还没有账号a href/register.html注册/a/p /div script src/js/api.js/script script document.getElementById(loginForm).onsubmit async (e) { e.preventDefault(); const username document.getElementById(username).value; const password document.getElementById(password).value; try { const data await api(/api/login, { method: POST, body: JSON.stringify({ username, password }), }); // ★ 登录成功服务器设置了 Set-Cookie // 浏览器自动保存了 Cookie // 下次请求自动携带 alert(登录成功); window.location.href /; // 跳转到首页 } catch (e) { alert(登录失败 e.message); } }; /script /body /html3register.html——注册页面!-- 和登录页面非常相似只是调用 POST /api/register -- script document.getElementById(registerForm).onsubmit async (e) { e.preventDefault(); try { const data await api(/api/register, { method: POST, body: JSON.stringify({ username: document.getElementById(username).value, password: document.getElementById(password).value, }), }); alert(注册成功请登录); window.location.href /login.html; } catch (e) { alert(注册失败 e.message); } }; /script4problem.html——题目详细编辑器提交这是最核心的前端页面包含了题目展示、代码编辑器、提交按钮、结果展示。!DOCTYPE html html head title题目详情 - OJ System/title !-- CodeMirror 5CDN 引入不需要安装 -- link relstylesheet hrefhttps://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/codemirror.min.css script srchttps://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/codemirror.min.js/script script srchttps://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/clike/clike.min.js/script link relstylesheet href/css/style.css /head body div idproblem-container !-- 左侧题目描述 -- div idproblem-desc h2 idtitle/h2 div iddescription/div h3输入格式/h3 div idinput-desc/div h3输出格式/h3 div idoutput-desc/div h3示例/h3 div idsamples/div /div !-- 右侧代码编辑器 提交 -- div ideditor-panel textarea idcode-editor/textarea button idsubmit-btn提交/button div idresult/div /div /div script src/js/api.js/script script src/js/auth.js/script script src/js/problem_detail.js/script script src/js/submit.js/script /body /htmlproblem_detail.js —— 加载题目详情 初始化编辑器// // 文件名: submit.js // 作用: 提交代码 轮询判题结果 // // 这是异步判题的前端实现 // 1. 提交代码 → 后端返回 202 submission_id // 2. 开始轮询每隔 1 秒查一次结果 // 3. 等到状态变成 AC/WA/CE 等最终状态 // 4. 展示结果 // document.getElementById(submit-btn).onclick async () { const code editor.getValue(); // 从 CodeMirror 获取代码 if (!code.trim()) { alert(请先写代码); return; } const resultDiv document.getElementById(result); resultDiv.innerHTML 提交中...; document.getElementById(submit-btn).disabled true; try { // 第 1 步提交代码 const submitResult await api(/api/submit, { method: POST, body: JSON.stringify({ problem_id: parseInt(problemId), code: code, }), }); const submissionId submitResult.submission_id; resultDiv.innerHTML 判题中... (PENDING); // 第 2 步轮询结果 // 每隔 1 秒查询一次直到状态不再是 PENDING/JUDGING const poll setInterval(async () { try { const result await api(/api/submissions/${submissionId}); // 更新状态显示 resultDiv.innerHTML 判题中... (${result.status}); // 如果到了最终状态停止轮询并显示结果 if (![PENDING, JUDGING].includes(result.status)) { clearInterval(poll); document.getElementById(submit-btn).disabled false; // 根据状态显示不同的结果 if (result.status AC) { resultDiv.innerHTML div classresult-ac✅ 通过 (Accepted)/div div用时: ${result.time_used}ms/div div内存: ${result.memory_used}KB/div ; } else if (result.status WA) { resultDiv.innerHTML div classresult-wa❌ 答案错误 (Wrong Answer)/div div失败用例: #${result.failed_case}/div div你的输出: ${result.failed_actual || ?}/div div期望输出: ${result.failed_expected || ?}/div div输入: ${result.failed_input || ?}/div ; } else if (result.status CE) { resultDiv.innerHTML div classresult-ce⚠️ 编译错误 (Compilation Error)/div pre${result.error_msg}/pre ; } else if (result.status TLE) { resultDiv.innerHTML div classresult-tle⏱️ 超时 (Time Limit Exceeded)/div div用时: ${result.time_used}ms/div ; } else { resultDiv.innerHTML div状态: ${result.status}/div div${result.error_msg || }/div ; } } } catch (e) { clearInterval(poll); document.getElementById(submit-btn).disabled false; resultDiv.innerHTML 查询失败: ${e.message}; } }, 1000); // 1 秒轮询一次 } catch (e) { document.getElementById(submit-btn).disabled false; resultDiv.innerHTML 提交失败: ${e.message}; } };轮询流程图用户点击提交 │ ▼ POST /api/submit → 202 Accepted submission_id │ ▼ setInterval 开始轮询每 1 秒 │ ├── GET /api/submissions/42 → { status: PENDING } │ └── 显示 判题中... (PENDING) │ ├── GET /api/submissions/42 → { status: JUDGING } │ └── 显示 判题中... (JUDGING) │ ├── GET /api/submissions/42 → { status: AC, time_used: 15, ... } │ └── 显示 通过用时 15ms内存 2048KB │ └── clearInterval → 停止轮询 │ └──如果一直是 PENDING/JUDGING继续轮询5admin.html——管理后台管理员可以增删改题目和管理测试用例。主要功能┌─────────────────────────────────────────────────┐ │ 管理后台 │ ├─────────────────────────────────────────────────┤ │ ┌─────────────────────────────────────────────┐│ │ │ 题目列表 [新增题目] 按钮 ││ │ ├──────┬──────────┬──────────┬────────────────┤│ │ │ ID │ 标题 │ 难度 │ 操作 ││ │ ├──────┼──────────┼──────────┼────────────────┤│ │ │ 1 │ 两数相加 │ easy │ [编辑] [删除] ││ │ │ 2 │ 反转链表 │ medium │ [编辑] [删除] ││ │ └──────┴──────────┴──────────┴────────────────┘│ │ │ │ ┌─────────────────────────────────────────────┐│ │ │ 测试用例管理点击某道题的编辑后弹出 ││ │ │ 可以查看/添加/删除测试用例 ││ │ │ 每个用例可以设置is_sample 是否展示给用户 ││ │ └─────────────────────────────────────────────┘│ └─────────────────────────────────────────────────┘5style.css——暗色主题/* 全局设置 */ body { font-family: Segoe UI, sans-serif; background-color: #1a1a2e; /* 深色背景 */ color: #e0e0e0; /* 浅色文字 */ margin: 0; padding: 20px; } /* 表单容器登录/注册页 */ .form-container { max-width: 400px; margin: 100px auto; padding: 30px; background: #16213e; /* 稍亮的深色卡片 */ border-radius: 8px; } /* 输入框 */ input[typetext], input[typepassword], textarea { width: 100%; padding: 10px; margin: 8px 0; background: #0f3460; border: 1px solid #333; color: #fff; border-radius: 4px; } /* 按钮 */ button { background: #e94560; /* 红色按钮 */ color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; } button:hover { background: #c73e54; } /* 难度标签 */ .badge-easy { color: #4caf50; } /* 绿色 */ .badge-medium { color: #ff9800; } /* 橙色 */ .badge-hard { color: #f44336; } /* 红色 */ /* 判题结果 */ .result-ac { color: #4caf50; font-size: 1.2em; } /* 绿色通过 */ .result-wa { color: #f44336; font-size: 1.2em; } /* 红色错误 */ .result-ce { color: #ff9800; font-size: 1.2em; } /* 橙色编译错 */ .result-tle { color: #9c27b0; font-size: 1.2em; } /* 紫色超时 */6start.sh——布置脚本#!/bin/bash set -e # 任何命令失败就退出不继续执行 # 获取项目根目录路径 # $(dirname $0) 是脚本所在目录scripts/ # /.. 回到项目根目录 PROJ$(cd $(dirname $0)/.. pwd) BUILD_DIR$PROJ/build echo Creating runtime directories... # 创建 Session 目录和沙箱目录 sudo mkdir -p /var/oj/sessions /tmp/oj_sandbox # 把目录所有权给当前用户这样不需要 root 也能写文件 sudo chown -R $(whoami) /var/oj/sessions /tmp/oj_sandbox 2/dev/null || true echo Building... # 在 build/ 目录中生成 CMake 构建文件 cmake -S $PROJ -B $BUILD_DIR # 编译 oj_backend 可执行文件 cmake --build $BUILD_DIR --target oj_backend -j$(nproc) echo Starting oj_backend... cd $PROJ # exec 替换当前 shell 进程为 oj_backend # 这样 CtrlC 直接发给 oj_backend而不是 shell exec $BUILD_DIR/oj_backend $PROJ/config/config.json使用方法# 一键编译 启动bash scripts/start.sh# 如果不想编译直接运行已有的可执行文件./build/oj_backend config/config.json7systemd服务——开机自启# scripts/oj-backend.service # 这是一个 systemd 服务配置文件 # systemd 是 Linux 系统的进程管理器——负责启动、停止、监控服务 # # 安装方式 # sudo cp scripts/oj-backend.service /etc/systemd/system/ # sudo systemctl daemon-reload # sudo systemctl enable --now oj-backend # enable 是开机自启--now 是立即启动 [Unit] DescriptionOJ Backend Service # 服务描述 Afternetwork.target mysql.service # 在网络和 MySQL 之后启动 # 确保 MySQL 已经运行了 [Service] Typesimple # 简单类型主进程就是服务 Useroj # 以 oj 用户运行安全和权限考虑 WorkingDirectory/opt/oj # 工作目录 ExecStart/opt/oj/build/oj_backend /opt/oj/config/config.json # 启动命令 Restarton-failure # 失败时自动重启 RestartSec5 # 重启前等待 5 秒 [Install] WantedBymulti-user.target # 多用户模式下启动正常启动8从开发到部署的完整流程1开发环境# 1. 安装依赖 sudo apt install cmake g libmysqlclient-dev libseccomp-dev libssl-dev mysql-server # 2. 初始化数据库 sudo mysql database/init.sql # 3. 配置 config.json改数据库密码 vim config/config.json # 4. 编译 启动 bash scripts/start.sh # 5. 打开浏览器访问 # http://localhost:80802环境部署# 1. 把项目传到服务器 git clone gitgithub.com:yourname/project-cpp-oj-vibecoding.git cd project-cpp-oj-vibecoding # 2. 安装依赖 sudo apt install cmake g libmysqlclient-dev libseccomp-dev libssl-dev mysql-server # 3. 初始化数据库 sudo mysql database/init.sql # 4. 配置 config.json vim config/config.json # 修改数据库密码等 # 5. 安装 systemd 服务 sudo cp scripts/oj-backend.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now oj-backend # 6. 查看运行状态 sudo systemctl status oj-backend9项目完整回顾┌─────────────────────────────────────────────────────────────────────┐ │ 一个 OJ 系统的诞生 │ │ 12 篇博客 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ [01] 项目动机 技术选型 AI 辅助设计 │ │ 为什么做为什么用 C4 层架构长什么样 │ │ │ │ [02] 工具层 — Config Logger │ │ 单例模式、JSON 配置、日志级别、RAII 锁 │ │ 文件: src/utils/config.hpp/cc logger.hpp/cc │ │ │ │ [03] 数据库层 — MySQL 连接池 │ │ vector 存连接、mutex 保护、condition_variable 等待/通知 │ │ 文件: src/db/connection_pool.hpp/cc │ │ │ │ [04] 模型层 — 数据快递盒 │ │ 3 个 structUser / Problem / TestCase47 行代码 │ │ 文件: src/model/user.hpp / problem.hpp / test_case.hpp │ │ │ │ [05] Service 层上 — AuthService │ │ 注册/登录/bcrypt 密码哈希/Session 文件/限流/后台清理 │ │ 文件: src/service/auth_service.cc414 行 │ │ │ │ [06] Service 层中 — ProblemService │ │ LEFT JOIN 多表查询、封装 escape() 防 SQL 注入、级联删除 │ │ 文件: src/service/problem_service.cc291 行 │ │ │ │ [07] Service 层下 — ExecutorService │ │ 任务队列 Worker 线程、fork 子进程、seccomp 沙箱、 │ │ setrlimit 资源限制、编译运行比对 │ │ 文件: src/service/executor_service.cc506 行 │ │ │ │ [08] Handler 层 — HTTP 接线员 │ │ 15 个 API、12 种 HTTP 状态码、Cookie 设置/清除、6 道安检门 │ │ 文件: src/handler/ 下的 4 个文件 │ │ │ │ [09] main.cc — 总指挥 │ │ 初始化顺序、信号处理优雅关闭、CMake 构建 │ │ 文件: src/main.cc src/server/ │ │ │ │ [10] 前端 部署 — 最后一公里 │ │ 原生 HTML/JS/CSS、CodeMirror 编辑器、fetch API 轮询、 │ │ start.sh systemd 一键部署 │ │ 文件: public/ scripts/ │ │ │ └─────────────────────────────────────────────────────────────────────┘数据量统计维度数字C 后端约 1800 行不含第三方库前端5 个 HTML 页面、6 个 JS 文件、1 个 CSS数据库4 张表API15 个 REST 接口判题状态7 种PENDING → JUDGING → AC/WA/CE/RE/TLE/MLE安全防线5 层seccomp /setrlimit/prctl /pipe/ 超时监控代码总行数约 3000 行含前端10结束语从一个模糊的想法——我想做一个 OJ 系统经过 10 篇博客的逐层解剖我们看到了一个完整的在线判题系统是如何从零到一构建起来的。这个项目不大但五脏俱全它有**完整的用户系统**注册、登录、Session、限流它有**完整的题目系统**增删改查、测试用例管理它有**完整的判题引擎**编译、沙箱、逐用例运行、结果比对它有**完整的前端界面**5 个页面、代码编辑器、轮询它有**完整的部署方案**编译脚本、systemd 服务除了代码本身更重要的是代码背后的设计思想**单例模式**——全局唯一的配置、日志、连接池**分层架构**——Router → Handler → Service → Model → DB各司其职**防御性编程**——处处有默认值、处处有错误处理、处处有安全检查**最小可行产品**——不追求完美追求够用。文件 Session 而不是 Redis原生 JS 而不是 React够用就好