utils/api 改成基于 authFetch 的一层 fetch 封装,调用方用法不变;下载测试点也走 authFetch,token 过期时会换一张重试。换 token 那一步断网时,AI 流式接口和自学上报 原来会拿掉 token 重发、弹登录框,现在和普通请求一样报「网络异常」。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
OJ2 的前端(OJ2/apps/web),起点是上一代 ojnext/ 的代码,之后大部分页面已重写
(ojnext 与 ../OnlineJudge 都已下线且完全冻结,一行都不改)。Vue 3 + TypeScript,
Vite(Rolldown 内核)、Naive UI、Pinia、Vue Router。
浏览器基线是 Chrome 105(机房部分电脑那一档,其余更新 —— 按最低那档定基线):
@vitejs/plugin-legacy留着,别删:vite 8 的默认构建 target 是chrome111,比 105 高。 这个插件同时把build.target压到es2020/chrome105、给现代产物补 core-js polyfill (toSorted/Set运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。modernTargets不写, 用插件自带的基线(chrome>=105),正好是这一档。polyfill 清单写死在vite.config.ts, 升级前端依赖后重新审计:先临时把modernPolyfills改成true,再跑DEBUG=vite:legacy bun run build才会打印探测到的全集(写死清单时它只回显清单)。- View Transitions 要 111,105 没有,
darkTransition.ts的降级分支是真在用的。
Commands
前端一般不单独起,OJ2/ 根目录 bun run dev 会把 api + worker + web 一起拉起来。
只跑前端或要验证时:
bun run dev # 只起前端 dev server(5173),后端得另外起
bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
bun run build # 生产构建
⚠️ 验证只认 bun run type-check。 vue-tsc --noEmit -p tsconfig.json 会静默
通过——那个 tsconfig 是 files: [] + references 的壳,真正的配置在
tsconfig.app.json(0.2 秒跑完就是没在检查的信号);vite build 也不做类型检查。
不写测试(沿用项目约定),验证靠实跑。格式化和 lint 走 Vite+,脚本在仓库根目录
(cd ../.. && bun run fmt / bun run lint,一把把后端、契约、前端全过一遍),配置在
根目录的 vite.config.ts,不是本目录那份(本目录的 vite.config.ts 只管前端构建)。
Architecture
Directory Structure
src/
├── shared/ # Cross-cutting concerns: layout, stores, composables, API
├── oj/ # User-facing features (problems, submissions, contests, etc.)
├── admin/ # Admin panel features
├── utils/ # Constants, types, HTTP client, helpers
├── routes.ts # Route definitions (two top-level: ojs, admins)
├── main.ts # App entry point
└── App.vue # Root component with Naive UI theme setup
Module Pattern
Each feature module (under oj/ or admin/) typically has:
- 页面组件直接放模块根下(
problem/list.vue、problem/detail.vue),没有views/这一层 components/— feature-specific componentscomposables//utils/— 模块自己的组合式函数与纯函数(按需,不是每个模块都有)
API 调用不按模块分:学生端全在 oj/api.ts、后台全在 admin/api.ts、
跨端的(登录、资料、标签、验证码)在 shared/api.ts。
Shared logic lives in shared/:
store/— Pinia stores:user(auth/roles),config(site-wide settings),authModal(登录框、注册框开没开),collab(help-request queue + collab room),achievement(解锁弹窗队列),myFlowchart(题面「你画的流程图」那张 A/S 图的 mermaid 源码)composables/—aiStream,pagination(URL-synced),websocket(reconnect + heartbeat),collabDoc(Yjs binding for the collab channel),configUpdate(WS-pushed config sync),useMermaid(lazy Mermaid render,渲染器是 pele 不是 mermaid),darkTransition(View Transitions,111 以下走降级分支),leaveStudents(课堂看板的「请假」),problemJump(按题号直达),teacherHelp(老师接单 / 打开学生编辑器),chartTheme,breakpoints,raritylayout/—default.vueandadmin.vuelayout wrappersapi.ts— shared API calls (auth, profile, tags, captcha)
Auto-Imports
Configured via unplugin-auto-import and unplugin-vue-components. You do not need to manually import:
- Vue APIs (
ref,computed,watch, etc.) - Vue Router (
useRouter,useRoute) - Pinia (
defineStore,storeToRefs) - VueUse composables
- Naive UI composables (
useDialog,useMessage,useNotification,useLoadingBar) - Naive UI components (all
N*components) - Naive UI types (
DataTableColumn,FormRules,FormItemRule,SelectOption,UploadCustomRequestOptions,UploadFileInfo,MenuOption,DropdownOption)
Generated type declaration files: src/auto-imports.d.ts, src/components.d.ts.
Path Aliases
utils → ./src/utils
oj → ./src/oj
admin → ./src/admin
shared → ./src/shared
只在 tsconfig.app.json 的 paths 里写一份,vite.config.ts 用 resolve.tsconfigPaths
直接读它 —— 加别名只改 tsconfig。
HTTP Client
utils/api.ts — 薄薄一层 fetch 封装(没有 axios),前缀 /api,get / post / put / delete
四个方法。它剥掉后端的 { data } 信封,调用方直接拿到业务数据;失败抛 ApiError。
dev 下由 vite.config.ts 的 proxy 转给后端。
登录态是 Authorization: Bearer 的 access token(15 分钟)+ refresh token,都在
localStorage,换 token 的逻辑全在 utils/auth.ts。所有请求都从 authFetch 出门
(utils/api 也是),要自己发 fetch(流式、keepalive、下载二进制)时用它代替裸
fetch,否则不带 token、也不会在 token-expired 时换一张重试。
WebSocket 的 token 借子协议带(浏览器设不了请求头),见契约 WS_AUTH_PROTOCOL。
Contract guard (utils/contract.ts)
@oj2/contract 的 zod schema 是前后端唯一的形状来源,utils/types.ts 只做
z.infer 派生与少量前端专有的收窄(都写了理由)。
运行时闸门只挂三处:题目详情、提交详情、shared/api.ts 的用户资料 ——
原本就写了 .parse() 的那三条。留着它们的理由是别抛错,不是校验:
// 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
// 而 parse 抛错会让整个题目页白屏
return contract("GET /problems/:id", problemDetailSchema, value)
失败时记一条控制台日志再放行原始数据,页面照常渲染。
不要把它铺到更多端点上。 试过一次(41 个),收益是 41 次 safeParse 加一条
没人读的 console.error:前后端同仓、共享同一份 schema,「后端改字段前端不知道」
tsc 已经抓了。
什么该收紧,什么不该
JSONB 原文(submission.info / statistic_info / exercise.data)不在读出侧
校验。 它们的形状真相在写入侧 —— 判题机、services/exercise.ts。在读出侧再收
一遍的结果实测过两次:
info按采样键集收紧后,124192 条提交里 9163 条(RE、TLE、MLE 全中)对不上, 被 union 的空对象分支静默剥成{},管理员的测试点表格无声消失;exercise.data按题型收紧后,后端读路径(routes/content.ts硬 parse)变成 一道闸,一行脏数据能让整条练习列表 500。
后端出参已经不 parse 了(原来 136 处,全部改成 satisfies;撤的时候炸出两个
一直存在的线上 500,见 ../api/CLAUDE.md 的「出参不 parse,用 satisfies」)。
所以现在收紧一个字段的直接后果落在 tsc 编译期,而不再是运行时 500 —— 这是好事,
但别因此就放心大胆收:契约里的形状仍然要对得上库里的存量数据,前端拿到对不上的值
一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍,
尤其要看空值而不只是键集合。
时间一律按东八区展示,不跟浏览器走
显示时间走 utils/functions.ts 的 parseTime();要日历部件走 zonedParts() /
zonedYear()。 不要在组件里写 new Date(x).getFullYear() / getMonth() /
getDate() / toLocaleDateString() / toLocaleTimeString() —— 那些取的是
浏览器本地时区。机房电脑、学生手机平时都在东八区所以看不出来,但只要有人
(比如时区没设对的机房机器、或在外地的老师)从别的时区打开,同一张提交记录表就会
显示成另一个时间,和榜单、统计、成就里的日期对不上。
时区常量在契约 @oj2/contract 的 TIME_ZONE_OFFSET_MINUTES,和后端 time.ts 共用。
实现是「平移固定偏移 + 读 getUTC*」,不用 Intl 的时区选项:东八区没有夏令时,
纯算术在表格里逐格调用也不费事,和后端 time.ts 算得一模一样。
n-date-picker 要平移(admin/contest/detail.vue、admin/problemset/edit.vue):
Naive 的日期选择器按浏览器本地时区渲染、没有 timezone 属性,所以绑定值走
toPickerValue(),取回来走 fromPickerValue()。显示时间不要用这对函数。
Key Utilities
utils/constants.ts— Judge status codes, language IDs, difficulty levels, contest typesutils/types.ts— 契约类型的派生与前端专有收窄(不是手写的一份平行类型)utils/contract.ts— 运行时契约闸门,见上utils/functions.ts—parseTime/zonedParts/zonedYear(东八区时间口径,见上)、duration、压缩与剪贴板等杂项utils/judge.ts— Judge-related utilities
Environment Variables
Variables prefixed with PUBLIC_ are injected at build time,声明在 src/env.d.ts。
Env files: .env(本机)、.env.production(服务器)、.env.staging / .env.test(机房)。
| Variable | Purpose |
|---|---|
PUBLIC_ENV |
环境角标:test → 「测试版」,dev → 「开发版」,其余不显示 |
PUBLIC_CODE_URL |
代码分享服务(提交详情、题目页的「分享」) |
PUBLIC_ICONIFY_URL |
自建 Iconify 图标源,不设则走公共 CDN |
后端地址不在这里:utils/api.ts 写死前缀 /api,dev 由 vite.config.ts 的
proxy 转给 3000,线上由 Caddy 同源伺服。
Routing
Routes are defined in src/routes.ts with two root routes: ojs (user-facing) and admins (admin panel). Route meta fields used:
requiresAuth— redirect to login if not authenticatedrequiresSuperAdmin— super admin onlyrequiresTeacherAdmin— 老师及以上(isTeacherOrAbove)requiresProblemPermission— problem management access
Real-time Features
- WebSocket via composable in
shared/composables/for submission status updates - Yjs over the
/ws/collabchannel for classroom help requests and collaborative code editing (students raise a hand, teachers join their editor). The server is a dumb relay — it authenticates, assigns rooms, and forwards frames without parsing them. Seedocs/specs/2026-08-28-collab-help-request-design.md.
Related Repository
后端就在同一个仓库的 ../api(Bun + Hono + Drizzle,编译成单二进制),
契约在 ../../packages/contract。不要再去看 OnlineJudge/ —— 那是已下线的
Django 后端,只作参照、完全冻结。详见 ../../CLAUDE.md。