Compare commits

...
16 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 8b4d8899f9 feat(判题机): 自建镜像升级工具链,语言收敛到 C / C++ / Python
Deploy / deploy (push) Waiting to run
上游 QingdaoU/JudgeServer 停更在 2024-04(registry 上的 latest 和 1.6.1 是同一份
镜像,编译器停在 gcc-13),没有新版可拉,所以自己重编。docker/judge/ 是只改工具链
的 Dockerfile 分叉,server/ 和 Judger/ 从上游固定 commit b28aa56 拉,一行没动。

镜像 oj2-judge-2(不在任何 registry 上:本机 build.sh --save → scp → docker load)
- gcc/g++ 13 → 14.2,Python 3.12 → 3.13.5,都是 trixie 默认
- Go / JDK / Node 整套删掉:前端的题目语言复选框从来只给 C / C++ / Python / SQL,
  12 万条提交里 Java 44 条、Golang 15、JavaScript 3,全是很早以前的
- 体积 1.1GB → 433MB;默认走清华源,构建 12 分钟 → 40 秒(--no-mirror 换回官方)
- deploy.sh 加一道自检:镜像不在本机就中止,并打印该跑的三条命令

C 的编译参数加三个 -Wno-error(implicit-function-declaration / int-conversion /
incompatible-pointer-types):gcc-14 把它们从 warning 提成了 error,而 -w 压不住。

语言值统一成 Python(迁移 0019 / 0020)
- 0019:Python3(104527 条提交)与 Python2(3 条)并成 Python,一并改掉 937 道题的
  languages、75 个 template 键、15 个 ast_rules 键、257 条 answers、1235 个用户的
  成就指标 _languages(languages_used 重算,总和 1928 → 1925,少的 3 个是同时用过
  两种 Python 的人)
- 0020:把 Java / JavaScript / Golang 从 84 道题的可选语言里摘掉 —— 不摘的话那些题
  的语言下拉还能选 Java,提交必 SYSTEM_ERROR
- 契约新增 normalizeLanguage() 别名表,判题侧一律走 judgeConfigFor():旧客户端
  localStorage 里的 Python3、迁移前排进队列的任务都还能判;协作的语言归一也走它,
  否则上线那一刻学生页面里的 Python3 会静默落到 C
- 回滚要连数据一起回,只滚代码会让所有 Python 提交变 SYSTEM_ERROR

实跑
- 判题冒烟 docker/judge/smoke.ts 13 条全过:三种语言、六种状态码、gcc 宽松度
- 拿备份里的真实代码逐文件比对新旧镜像的编译结果,0 差异:C 提交 1951 份
  (1725 过 / 226 CE)、C++ 882 份、Python 2000 份、20 篇 C 教程的 93 个代码块。
  不加那三个 -Wno-error 的话,C 有 26 份会从能过变成 CE
- 迁移在灌了 12.4 万行真实数据的一次性库里跑过:0 残留、没有题目被清空;
  dev 库用真正的执行器跑通
- check:ast 56 个 target 全过,前后端 typecheck 均 0

顺带记下一个升级之前就有的坑(现在随 Go 一起消失,写在 README 里):GOCACHE 指向
容器的 tmpfs,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒
的编译预算,于是重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 06:24:05 -06:00
xuyue a872e8365b fix
Deploy / deploy (push) Canceled after 0s
2026-09-19 07:10:49 -06:00
xuyueandClaude Opus 5 b4c0f89291 fix(个人主页): 总共学习天数改为有提交的天数
Deploy / deploy (push) Has been cancelled
原来是首末提交之间跨了多久,现在按东八区日期对提交去重计数,比赛提交也算。
首末提交时间仍只看比赛外的提交,口径不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:07:57 -06:00
xuyueandClaude Opus 5 06ad6745b7 feat(AI 提示): 两段式提示——先诊断出错误标签,再生成提示
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的 2b。标准答案能让提示准得多,但不能进生成提示的 prompt
(学生代码里一段注释就能把它套走),所以拆成两段:
- 诊断:看得到标准答案、第一个没过的测试点、带行号的学生代码;出参只允许
  { tag, lines, confidence },safeParse 过闸、多余字段剥掉,没有自由文本通道
- 生成提示:看不到标准答案和测试点原文,只多一句「问题定位:X,大约在第 a 行」
- 诊断失败(20 秒超时、不是 JSON、校验不过)退回单段式;同一条提交复用诊断;
  编译失败不诊断
- 契约新增 HINT_ERROR_TAGS(13 个,落库值,只增不改)与 hintDiagnosisSchema
- 迁移 0018:ai_hint 加 diagnosis / diagnosis_error 两列
- 单段式 prompt 原样搬进 services/hint-diagnosis.ts,记版本 1;两段式记版本 2
- completeChat 支持 JSON 模式和自定义超时,现有调用不受影响
- 开关 AI_HINT_DIAGNOSE 默认关(2a 的基线还在攒),两套生产 compose 透传

实跑(一次性库 + 本地假 LLM):开关关时请求与 2a 逐字节相同;开时正常 /
复用 / 坏 JSON / 非法标签 / 行号越界 / 超时 / 编译失败七种场景符合预期;
12 次模型请求里诊断全都带标准答案和测试点,生成全都不带。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:56:38 -06:00
xuyueandClaude Opus 5 47d8f46bdb feat(AI 提示): 提示落库并收集学生评价,落进 ai_hint
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的 2a:先把现有提示记下来,为后面的分级和两段式诊断攒对照数据。
提示本身的行为不变,发给模型的 prompt 一字未改。
- 迁移 0017 建 ai_hint:模型、prompt 版本、内容、错误、耗时、学生评价;
  成功和失败都记(失败率是 AI 悄悄变差时最先动的数)。挂在 submission 上 CASCADE
- streamChat 第三个参数改成 { onComplete, onError }:onComplete 的返回值并进
  done 事件,提示 id 由此带回前端;班级学情分析那处调用同步改写,行为不变
- 落库失败只记日志,不影响学生拿到提示
- 新增 POST /ai/hint/:id/feedback:只能评自己的提示(别人的一律 404),可以改票
- 前端提示生成完出「有帮助 / 没帮助」,选中的高亮;后端没存上时不出按钮
- 实跑:本地假 LLM 验证成功 / provider 断开 / 缺 AI_KEY 三条路径的落库,
  评价接口六种请求,浏览器里按钮出现、改票、重复点不发请求

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:00:10 -06:00
xuyueandClaude Opus 5 a0ef204bd2 feat(提交): 采集编辑过程信号,落进 submission_trace
Deploy / deploy (push) Has been cancelled
AI 时代 OJ 设计的第 1 步:给「可信 AC」和学情分析攒数据,本身不判任何事。
- 契约:提交请求加可选的 trace(活跃时长、键入/粘贴/删除字符数、切后台次数等,
  只有计数、不含按键内容);写成 .optional().catch(undefined),坏了就当没带,
  不让附带数据把提交挡成 400
- 迁移 0016 建 submission_trace,与 submission 一对一、CASCADE;since_prev_ms
  由服务端在同一条 INSERT 里算(排掉自身),bigint —— 实测已有 44 天的间隔,int4 装不下
- 后端写 trace 失败只记日志,不影响提交
- 前端 oj/problem/utils/editTrace.ts 是模块单例(扩展对象不能过 Pinia 的响应式代理),
  只数带 userEvent 的事务:格式化回写 / 载入草稿 / 协作对方的改动天然排除;
  closeBrackets 越过右括号时是原样替换,按 no-op 跳过。比赛编辑器同样挂上
- 实跑:后端四种请求、前端浏览器里键入/粘贴/删除/setCode 回写/切后台/提交后清零,
  计数与预期逐项一致

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:46:55 -06:00
xuyueandClaude Sonnet 5 20a6ddc79c feat(自学): 重做学生端页面与教师端学情概览
Deploy / deploy (push) Has been cancelled
学生端:目录 + 居中限宽正文 + 可收起的示例代码栏;目录改成三态圆点,
顶部加总进度;上一课/下一课栏桌面端固定在底部。
教师端:汇总卡片、学生状态标签与筛选(未开始/7 天没学/只读不练等)、
最后学习补「N 天前」、按练习表加「没人一次对/多数人卡住」提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 05:07:37 -06:00
xuyueandClaude Sonnet 5 c228b164cf refactor(提交): 教师统计路由拆出 submission-statistics.ts,check:routes 支持嵌套挂载
Deploy / deploy (push) Has been cancelled
- submission.ts 1416 行降到 751 行;统计三条路由拆成子路由,挂在原位置,
  仍排在 /submissions/:id 之前
- check-route-shadowing 原来只认 index.ts 直接挂载,拆分后漏检 3 条(178→175);
  现在按挂载位置展开嵌套的 .route(),恢复 178 条、无遮蔽

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00
xuyueandClaude Sonnet 5 b5ba56ccd0 refactor(契约): 判题状态码收进契约唯一一份,并收紧 18 处 as any
- 状态码常量与 judgeStatusSchema 移到 packages/contract/src/judge-status.ts,
  后端 judge/status.ts 只再导出;前端 SubmissionStatus 枚举加编译期断言对齐契约
  (实测改坏一个码会当场类型检查失败)
- 类型逃逸 22 处降到 4 处:collab/handler、pagination、configUpdate、
  ExerciseManager、ProblemSubmission、pk.vue tooltip;剩下的是词云插件无类型、
  生成的 .d.ts、skulpt 和 TextEditor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 04:40:30 -06:00
xuyueandClaude Opus 5 f90d01338e feat(自学情况): 按学生加姓名/学号搜索,后端补稳定排序
Deploy / deploy (push) Has been cancelled
搜索是纯前端过滤(整表本来就一次拉完),和班级筛选可以叠加。

学生查询原来没有 orderBy,前端默认按「已读」升序排之后,同分的一大批
(尤其一堆 0)落回聚合的任意顺序,刷新一次换一个样;按班级、学号兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:23:01 -06:00
xuyue 3cc2be77a9 fix
Deploy / deploy (push) Has been cancelled
2026-09-17 02:12:04 -06:00
xuyue 905ba2ee03 fix
Deploy / deploy (push) Has been cancelled
2026-09-17 02:07:52 -06:00
xuyueandClaude Opus 5 a8de8f3932 feat(排名): 本周进步榜移到全服 Top100 右边,左 2/3 右 1/3
Deploy / deploy (push) Has been cancelled
两张榜分开上下放的时候,谁也看不见谁:上面那张是历史全部 AC 的总榜,名次几乎不动;
下面那张的分母只有这一周。并排摆在同一屏里,「总榜追不上」和「这周还能进前十」
才是一眼对照出来的。桌面 cols=3、Top100 占 2 栏,移动端退回 cols=1 上下堆叠。

周榜宽度从整屏缩到 1/3,列跟着收:列头去掉「本周」前缀(卡片标题已经写着),
宽度压到 70/120min/100/90,用户名加 ellipsis tooltip —— 1280 那一档也不出横向滚动条。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:03:04 -06:00
xuyueandClaude Opus 5 24af385f33 feat(排名): 加本周进步榜,按本周首次 AC 数排,每周一清零
Deploy / deploy (push) Has been cancelled
存量榜(/rankings/users、班级榜)排的都是 user_profile 的 AC 总数,名次几乎不动,
中位学生看一眼就知道追不上 —— 榜单在那批人身上是负反馈。这张榜的分母换成「这一周」,
每周一 0:00(东八区)清零,谁都可能进前十。

口径是**本周首次 AC 的题目数**,不是「本周 AC 过的去重题数」:后者把上周就做出来的题
重交一遍也算成绩,一分钟能刷满一屏。靠 NOT EXISTS 排掉本周之前已通过的
(user, problem) 对,四个条件正好是 submission_public_metrics_idx 的全部列。

- time.ts 加 weekStart():localWeekday 的 0 是周日,要先折成 7,否则周日单独成一周
- GET /rankings/weekly?scope=global|class,入榜人群与全服榜一致(教师/超管不参与)
- 前端默认落在本班 —— 全服周榜上中位学生仍然看不到自己,班内 30 人那张才有答案
- 本周一题没做出来时 me 是 null,footer 那句「做出 1 题就能上榜」照样出现:
  它是说给还没上榜的人听的,而那正是最需要被推一把的一批

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:53:37 -06:00
xuyueandClaude Opus 5 ad858ed864 feat(提交列表): 代码详情弹框支持方向键翻阅,到头自动翻页
Deploy / deploy (push) Has been cancelled
老师看一个班的提交原来得「点开 → 看 → 关掉 → 点下一行」,现在弹框开着就能
用 ↑↓(←→ 等价)切上一条/下一条,Esc 关闭。

- 只在 showLink 为真的行之间走,跳到看不了的行只会得到空弹框
- 走到本页头尾自动翻页续上:向下落到下一页第一条,向上落到上一页最后一条。
  翻页是异步的,先记 pendingJump,等 listSubmissions() 回来再开;
  整页都看不了代码时保持原来那条不动,给一句提示
- 弹框没开不接管方向键,焦点在输入框里也不抢
- SubmissionDetail 加 :key="submissionID" —— 它只在 onMounted 拉一次代码,
  不换 key 切过去还是上一条的代码

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:26:51 -06:00
xuyueandClaude Opus 5 88b695d34f feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
Deploy / deploy (push) Has been cancelled
点求助列表里的一条 = 跳到 /problem/:id,在页面自带的那个编辑器里和学生同步。
原来是弹一个 CollabModal(弹框里再挂一个 CodeMirror + 一份只读题面):

- 按一下 Esc 弹框就关、协作跟着结束(`show` 的 setter 里 leave 是唯一的关闭语义,
  Esc 和点遮罩都走它),上课太容易误触;
- 弹框里那份题面不能跑测试、不能提交,老师还是得另开标签页对照。

现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。教师端也少了
一个 CodeMirror 实例 —— setBinaryHandler 那个单例槽位只剩 SyncCodeEditor 一个使用者。

改造中撞出两个真问题,根因是同一个:`/problem/1002` → `/problem/1001` 是同一条路由
换 params,**组件被复用、不卸载**。

- 老师在别的题上接单跳过来,`@ready` 不会再触发,而 room 早在导航前就 open 了 ——
  老师停在一个空编辑器上干等。watch 的源从 room 一个变成 [room, roomIsHere] 两个。
- 反过来老师协作中切去别的题,onUnmounted 不触发 → 不发 leave,学生一直挂着
  「老师正在帮你」而老师的字一个也过不去。加 `bound` 标记区分「房间从我这儿挪走了」
  (补发 leave)和「房间在别处开起来了」(跟我无关,接单那一瞬间就是这种)。

`leave` 分两种语义(协议加 reason):

| 前端 | 求助记录 | 对面看到 |
|---|---|---|
| leave("done") 点「结束协作」 | 删除 | 老师已结束这次帮忙 |
| leave("left") 离开这道题的页面 | 教师走→退回排队;学生走→删除 | 老师暂时离开,已重新排队 |

"left" 复用的是老师掉线那条路(requeueAfterTeacherGone),两件事语义相同。原来两者
都按 done 处理 —— 老师点一下「提交信息」,学生就得重新举手,而他看到的是
「协作已结束」,会以为被处理完了。发起方收到的 reason 单独一份(self_left/done),
不然学生自己切走了却看到「老师已结束这次帮忙」。

其余为这套交互补的闸:

- 学生排队期间离开那道题 → 自动取消求助。那条求助说的是「我卡在这道题」,人走了
  就不成立;原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本
  不会绑,老师对着空编辑器敲字,两边都没提示。
- 协作中**教师的语言选择跟着 room.language 走**(写 codeStore,不只是改高亮),
  结束后还原。只改高亮的话老师会拿自己那档语言提交学生的代码(学生写 C、老师选的
  是 Python,当场 CE),工具栏还可能显示「提交流程图」。学生端一个字不动 ——
  服务端的 room_language 只发给教师,学生本地那份停在建房那一刻。
- 协作中不给「重置代码」:v-model 一写回就顺着 Yjs 同步过去,等于一键清空学生的作业。
- 协作中「提交信息」走新标签 —— 那是教师工具栏上唯一会跳路由的按钮。
- 接单时 resetScreenMode():分屏在「题目」「自测」两档时右侧编辑器根本没挂出来,
  停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 detail.vue 的 init()
  会重置,同页不会)。
- 老师已在一个房间里时前端也拦住接下一单(服务端本来就拦),不拦的话前端已经跳到
  新题目上,等于把手上那场协作断掉。
- 文案:协议层那四条英文(Invalid problemId 之类)会被原样弹成 toast,改成中文;
  「请先退出当前协作」统一成「结束」;room_closed 的提示按 reason × 角色展开。

验证:本机起 api + web,三个账号两两组合实跑 —— 跨题接单 / 同页接单、双向编辑、
连按 Esc、结束协作、老师跳走后再接、学生跳走、学生排队中切题、学生协作中切语言、
分屏在「题目」档接单、协作中重复接单,以及上面每一句提示的实际文案。
api typecheck / check:routes、前端 type-check / build、fmt 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:13:42 -06:00
88 changed files with 23834 additions and 1514 deletions
+24 -1
View File
@@ -21,6 +21,7 @@ OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScrip
| `docs/timezone.md` | 动日历口径、动时间出参格式 | | `docs/timezone.md` | 动日历口径、动时间出参格式 |
| `docs/contract.md` | 动 zod 契约、想给某个字段加校验 | | `docs/contract.md` | 动 zod 契约、想给某个字段加校验 |
| `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter | | `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter |
| `docker/judge/README.md` | 换判题沙箱镜像、升语言版本(gcc / Python / Node …) |
| `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 | | `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 |
## 仓库结构 ## 仓库结构
@@ -114,6 +115,28 @@ handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
### 判题镜像是自己构建的
`compose.*.yml` 里的 `oj2-judge-2` **不在任何 registry 上**:上游
QingdaoU/JudgeServer 停更在 2024-04(官方镜像的 `latest``1.6.1` 是同一份,
编译器停在 gcc-13),新工具链只能自己编。`docker/judge/` 里是只改版本的 Dockerfile
分叉 + 构建脚本 + 冒烟测试,判题逻辑一行没动。
- 新机器、换镜像:先 `docker/judge/build.sh --save` → scp → `docker load`,再部署。
**服务器和机房各有各的判题沙箱,两边都要装。**
- 改工具链就把末尾序号 +1(下一版 `oj2-judge-3`)。`up -d` 不带 `--pull`,名字没变会静默用旧镜像。
- 编译/运行命令在 `apps/api/src/judge/languages.ts`,不在镜像里。gcc-14 把隐式函数
声明等提成了 error`-w` 压不住),那边的 `cLooseErrors` 三个 `-Wno-error=` 就是
为此加的 —— 删掉它们等于让一批历史题解和 C 教程示例集体 CE。
- **判题沙箱只认 C / C++ / Python。** Java / JavaScript / Golang 连同镜像里的
JDK / Node / Go 在 2026-09 一起砍了(前端本来就没给入口,12 万条提交里它们共 62 条),
契约 `judgeLanguageSchema` 里的键留着是为了渲染那 62 条历史提交。
**`Python3` / `Python2` 这两个旧值已经没有了** —— 0019 迁移把 104530 条提交、937 道题、
1235 个用户的成就指标并成了一个 `Python`,0020 顺手把那三种语言从题目的可选语言里摘掉
(不摘的话 84 道题的语言下拉还能选 Java,提交必 SYSTEM_ERROR)。查判题配置走
`judgeConfigFor()`,它带旧值别名;**回滚要连数据一起回**,只滚代码会让 Python 提交全炸。
- 换完镜像跑 `bun docker/judge/smoke.ts`:三种语言、六种状态码、gcc 宽松度一起核。
### 出参不 `parse`,用 `satisfies` ### 出参不 `parse`,用 `satisfies`
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚 **后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚
@@ -138,7 +161,7 @@ handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃
bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑 bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑
``` ```
判题机只认 C / C++ / Python3`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑, 判题机只认 C / C++ / Python`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑,
所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、 所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、
规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md` 规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`
+76 -22
View File
@@ -13,7 +13,7 @@ import {
getRoom, getRoom,
hasTeacherOnline, hasTeacherOnline,
listRequests, listRequests,
normalizeLanguage, normalizeCollabLanguage,
openRoom, openRoom,
queueAheadOf, queueAheadOf,
removeRequest, removeRequest,
@@ -25,6 +25,12 @@ import {
type Room, type Room,
} from "./state" } from "./state"
/**
* `type: "error"` 的 message **会被前端原样弹成 toast**store 的 case "error"
* → setNotice → CollabHost 的 message.info),所以这里一律写中文、写成学生看得懂的
* 话。协议层的校验错误(格式不对、题号不对)正常前端触发不到,但真触发了也得是
* 一句人话 —— 原来那几条是 "Invalid problemId" 这样的英文,直接糊在学生脸上。
*/
function isTeacher(ws: CollabSocket) { function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? "")) return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? ""))
} }
@@ -191,19 +197,24 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
problemId?: unknown problemId?: unknown
studentId?: unknown studentId?: unknown
language?: unknown language?: unknown
reason?: unknown
timestamp?: unknown
} }
try { try {
message = JSON.parse(raw) as typeof message message = JSON.parse(raw) as typeof message
} catch { } catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })) ws.send(
JSON.stringify({
type: "error",
message: "消息格式不对,请刷新页面重试",
}),
)
return return
} }
// 心跳不查库,和 /ws/submissions 的处理一致 // 心跳不查库,和 /ws/submissions 的处理一致
if (message.type === "ping") { if (message.type === "ping") {
ws.send( ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }),
)
return return
} }
@@ -230,10 +241,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
await handleReject(ws, message.studentId) await handleReject(ws, message.studentId)
return return
case "leave": case "leave":
handleLeave(ws) handleLeave(ws, message.reason)
return return
default: default:
ws.send(JSON.stringify({ type: "error", message: "Invalid message" })) ws.send(
JSON.stringify({
type: "error",
message: "不认识的操作,请刷新页面重试",
}),
)
} }
} }
@@ -243,7 +259,9 @@ async function handleHelpRequest(
language: unknown, language: unknown,
) { ) {
if (typeof problemId !== "string" || !problemId) { if (typeof problemId !== "string" || !problemId) {
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" })) ws.send(
JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" }),
)
return return
} }
if (isTeacher(ws)) { if (isTeacher(ws)) {
@@ -289,7 +307,7 @@ async function handleHelpRequest(
className: student?.className ?? null, className: student?.className ?? null,
problemId, problemId,
problemTitle: problem.title, problemTitle: problem.title,
language: normalizeLanguage(language), language: normalizeCollabLanguage(language),
createdAt: Date.now(), createdAt: Date.now(),
status: "pending", status: "pending",
socket: ws, socket: ws,
@@ -309,7 +327,7 @@ function handleHelpLanguage(ws: CollabSocket, language: unknown) {
const request = getRequest(ws.data.userId) const request = getRequest(ws.data.userId)
// 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助 // 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助
if (!request || request.socket !== ws) return if (!request || request.socket !== ws) return
const next = normalizeLanguage(language) const next = normalizeCollabLanguage(language)
if (request.language === next) return if (request.language === next) return
request.language = next request.language = next
@@ -338,7 +356,12 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
return return
} }
if (typeof studentId !== "number") { if (typeof studentId !== "number") {
ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" })) ws.send(
JSON.stringify({
type: "error",
message: "学生标识不对,请刷新页面重试",
}),
)
return return
} }
@@ -366,7 +389,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
// 老师同时只能在一个房间 // 老师同时只能在一个房间
if (roomOf(ws)) { if (roomOf(ws)) {
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" })) ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" }))
return return
} }
@@ -440,32 +463,63 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
broadcastRequests() broadcastRequests()
} }
/** 主动退出房间。老师点关闭、学生点结束都走这里 */ /**
function handleLeave(ws: CollabSocket) { * 主动退出房间。**两种语义,靠 reason 分**
*
* - 不带 reason(或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束,
* 求助记录一并清掉;
* - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在
* 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回
* 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。
*
* 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」,
* 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。
*/
function handleLeave(ws: CollabSocket, reason: unknown) {
const room = roomOf(ws) const room = roomOf(ws)
if (!room) return if (!room) return
teardownRoom(room, "done") if (reason !== "left") {
teardownRoom(room, "done")
return
}
const side = ws === room.teacherSocket ? "teacher" : "student"
teardownRoom(room, "peer_left", side, ws)
} }
/** /**
* 拆房间。reason 决定两端看到什么: * 拆房间。reason 决定两端看到什么:
* done —— 有人主动结束,双方都收到,请求一并清除 * done —— 有人主动结束,双方都收到,请求一并清除
* peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose / * peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose /
* handleCollabBinary。offlineSide 是消失的那一方:老师消失, * handleCollabBinary
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有 * peer_left —— 有人离开了这道题的页面(handleLeave 的 "left"
* handleLeave 走 "done")不做这一步,只拆房间 *
* offlineSide 是消失的那一方,决定请求的去向:老师消失 → 退回排队;学生消失 →
* 随人清掉。不传时只拆房间。
*
* initiator 是主动发起的那条连接:**他收到的 reason 不一样** —— 点了「结束协作」
* 是 `done`,离开页面是 `self_left`。对他来说这是「我自己干的」,不该看到一句
* 「对方离开了」,也不该看到「老师已结束这次帮忙」。
*/ */
function teardownRoom( function teardownRoom(
room: Room, room: Room,
reason: "done" | "peer_offline", reason: "done" | "peer_offline" | "peer_left",
offlineSide?: "student" | "teacher", offlineSide?: "student" | "teacher",
initiator?: CollabSocket,
) { ) {
closeRoom(room.studentId) closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined
const frame = JSON.stringify({ type: "room_closed", reason }) // 发起方收到的是「我自己干的」那一版:点了结束就是 done,离开页面是 self_left。
room.studentSocket.send(frame) // 不能跟对面收同一条 —— 学生自己切走了却看到「老师已结束这次帮忙」是假话
room.teacherSocket.send(frame) const selfReason = reason === "peer_left" ? "self_left" : "done"
for (const socket of [room.studentSocket, room.teacherSocket]) {
socket.send(
JSON.stringify({
type: "room_closed",
reason: socket === initiator ? selfReason : reason,
}),
)
}
if (reason === "done") { if (reason === "done") {
removeRequest(room.studentId) removeRequest(room.studentId)
} else if (offlineSide === "teacher") { } else if (offlineSide === "teacher") {
+13 -6
View File
@@ -6,6 +6,8 @@
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。 * 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
*/ */
import { normalizeLanguage } from "@oj2/contract"
export type CollabSocket = Bun.ServerWebSocket< export type CollabSocket = Bun.ServerWebSocket<
import("../websocket").SubmissionSocketData import("../websocket").SubmissionSocketData
> >
@@ -17,8 +19,7 @@ export type CollabSocket = Bun.ServerWebSocket<
export const COLLAB_LANGUAGES = [ export const COLLAB_LANGUAGES = [
"C", "C",
"C++", "C++",
"Python2", "Python",
"Python3",
"Java", "Java",
"JavaScript", "JavaScript",
"Golang", "Golang",
@@ -27,10 +28,16 @@ export const COLLAB_LANGUAGES = [
export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number] export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number]
/** 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的 */ /**
export function normalizeLanguage(value: unknown): CollabLanguage { * 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的。
return (COLLAB_LANGUAGES as readonly string[]).includes(value as string) *
? (value as CollabLanguage) * 先过契约的别名表 —— 上线那一刻学生页面里还揣着 `Python3`,不翻译的话会**静默**
* 落到 C,求助窗口里的代码高亮和同步编辑都按 C 走,没人会报错。
*/
export function normalizeCollabLanguage(value: unknown): CollabLanguage {
const normalized = normalizeLanguage(value) ?? value
return (COLLAB_LANGUAGES as readonly string[]).includes(normalized as string)
? (normalized as CollabLanguage)
: "C" : "C"
} }
+6
View File
@@ -100,6 +100,12 @@ export const config = {
aiProvider: process.env.AI_PROVIDER ?? "deepseek", aiProvider: process.env.AI_PROVIDER ?? "deepseek",
aiKey: process.env.AI_KEY ?? "", aiKey: process.env.AI_KEY ?? "",
aiModel: process.env.AI_MODEL ?? "deepseek-flash", aiModel: process.env.AI_MODEL ?? "deepseek-flash",
/**
* AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**
* 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。
* 设成 "1" 打开。
*/
aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1",
ruffPath: process.env.RUFF_PATH ?? "ruff", ruffPath: process.env.RUFF_PATH ?? "ruff",
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format", clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
} }
@@ -0,0 +1,18 @@
-- 提交的编辑过程信号(AI 时代 OJ 设计的第 1 步:过程信号采集),字段含义见 schema.ts 的
-- submissionTrace 与契约的 submissionTraceSchema。纯建表,历史提交没有对应行,这是预期的。
CREATE TABLE "submission_trace" (
"submission_id" text PRIMARY KEY NOT NULL,
"active_ms" integer NOT NULL,
"since_open_ms" integer NOT NULL,
"typed_chars" integer NOT NULL,
"pasted_chars" integer NOT NULL,
"paste_count" integer NOT NULL,
"max_paste" integer NOT NULL,
"deleted_chars" integer NOT NULL,
"blur_count" integer NOT NULL,
"initial_len" integer NOT NULL,
"collab" boolean NOT NULL,
"since_prev_ms" bigint
);
--> statement-breakpoint
ALTER TABLE "submission_trace" ADD CONSTRAINT "submission_trace_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;
+17
View File
@@ -0,0 +1,17 @@
-- AI 提示的留痕与学生评价(AI 时代 OJ 设计 2a:先记录、不改行为),字段含义见 schema.ts 的 aiHint。
-- 纯建表。上线之前的提示从未落库,这张表从空开始。
CREATE TABLE "ai_hint" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "ai_hint_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"submission_id" text NOT NULL,
"model" text NOT NULL,
"prompt_version" integer NOT NULL,
"content" text NOT NULL,
"error" text,
"duration_ms" integer NOT NULL,
"helpful" boolean,
"feedback_time" timestamp with time zone,
"create_time" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ai_hint" ADD CONSTRAINT "ai_hint_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_hint_submission_id_idx" ON "ai_hint" USING btree ("submission_id");
@@ -0,0 +1,4 @@
-- AI 提示两段式的诊断结果(AI 时代 OJ 设计 2b),字段含义见 schema.ts 的 aiHint。
-- 两列都可空、不带默认值,加列只改目录不重写表。
ALTER TABLE "ai_hint" ADD COLUMN "diagnosis" jsonb;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "diagnosis_error" text;
@@ -0,0 +1,77 @@
-- 语言值统一成 `Python`:库里原来有 `Python3`104527 条提交)和 `Python2`3 条,
-- 全是 2022 年的),界面上两个都显示成「Python」,内部却是两个值。判题沙箱早就只剩
-- 一个 Python 了,这里把落库的值也并成一个。
--
-- 语言名不是判题状态码那种「判题机也认得的编码」—— 它只是我们自己的键(语言配置是
-- 整个对象发给判题机的),所以可以改。但它确实是**落库的值**,改完再回滚到旧版后端,
-- 旧代码查 languageConfigs["Python"] 会查不到 → 所有 Python 提交变 SYSTEM_ERROR。
-- 为此后端保留了 Python2/Python3 → Python 的别名(见 judge/languages.ts),
-- 新旧代码读哪一种数据都不会炸。
--
-- 涉及的四张表是全量扫备份确认过的(submission / problem / user_stat /
-- options_sysoptions)。options_sysoptions 里那行 `languages` 是 Django 时代的判题
-- 配置,OJ2 只读 website_* 几个键,不碰它,所以这里**故意不动**。
-- ① 提交记录。12 万条里 8 成是 Python,走一次全表 UPDATE。
UPDATE "submission" SET "language" = 'Python'
WHERE "language" IN ('Python2', 'Python3');--> statement-breakpoint
-- ② 题目的可选语言。用 WITH ORDINALITY 保住原来的顺序 —— 题目页的语言下拉和默认
-- 选中项就是按这个数组的顺序来的,打乱了学生打开题目看到的默认语言会变。
UPDATE "problem" p SET "languages" = (
SELECT COALESCE(jsonb_agg(
CASE WHEN v IN ('Python2', 'Python3') THEN 'Python' ELSE v END ORDER BY ord
), '[]'::jsonb)
FROM jsonb_array_elements_text(p."languages") WITH ORDINALITY AS t(v, ord)
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements_text(p."languages") x(v)
WHERE x.v IN ('Python2', 'Python3')
);--> statement-breakpoint
-- ③ 预制代码,键是语言名(75 道题有 Python3 的模板)。
UPDATE "problem"
SET "template" = ("template" - 'Python3') || jsonb_build_object('Python', "template" -> 'Python3')
WHERE jsonb_exists("template", 'Python3');--> statement-breakpoint
-- ④ AST 代码规则,键就是语言名(15 道题)。
UPDATE "problem"
SET "ast_rules" = ("ast_rules" - 'Python3') || jsonb_build_object('Python', "ast_rules" -> 'Python3')
WHERE "ast_rules" IS NOT NULL AND jsonb_exists("ast_rules", 'Python3');--> statement-breakpoint
-- ⑤ 参考答案,形如 [{"language": "...", "code": "..."}]257 条 Python3 答案)。
UPDATE "problem" p SET "answers" = (
SELECT jsonb_agg(
CASE WHEN a ->> 'language' IN ('Python2', 'Python3')
THEN jsonb_set(a, '{language}', '"Python"')
ELSE a END ORDER BY ord
)
FROM jsonb_array_elements(p."answers") WITH ORDINALITY AS t(a, ord)
)
WHERE p."answers" IS NOT NULL AND jsonb_typeof(p."answers") = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(p."answers") x(a)
WHERE x.a ->> 'language' IN ('Python2', 'Python3')
);--> statement-breakpoint
-- ⑥ 成就指标里的「用过哪些语言」(1235 个用户)。_languages 去重之后重算
-- languages_used —— 同时用过 Python2 和 Python3 的那 3 个用户,数字会从 n 掉到
-- n-1,这是**对的**:那本来就是同一种语言。已经发出去的成就不回收。
WITH mapped AS (
SELECT s."id", jsonb_agg(d.v ORDER BY d.ord) AS arr
FROM "user_stat" s, LATERAL (
SELECT DISTINCT ON (val) val AS v, ord FROM (
SELECT CASE WHEN e IN ('Python2', 'Python3') THEN 'Python' ELSE e END AS val, ord
FROM jsonb_array_elements_text(s."metrics" -> '_languages') WITH ORDINALITY AS t(e, ord)
) m ORDER BY val, ord
) d
WHERE jsonb_typeof(s."metrics" -> '_languages') = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(s."metrics" -> '_languages') x(e)
WHERE x.e IN ('Python2', 'Python3')
)
GROUP BY s."id"
)
UPDATE "user_stat" s SET "metrics" = jsonb_set(
jsonb_set(s."metrics", '{_languages}', mapped.arr),
'{languages_used}', to_jsonb(jsonb_array_length(mapped.arr))
)
FROM mapped WHERE mapped."id" = s."id";
@@ -0,0 +1,41 @@
-- 把 Java / JavaScript / Golang 从题目的可选语言里摘掉。
--
-- 这三种语言的判题配置和判题镜像里的 JDK / Node / Go 已经一起删了(见
-- judge/languages.ts 和 docker/judge/)。但生产库里有 84 道题的 `languages` 还留着
-- 它们,而题目页的语言下拉就是按这个数组渲染的 —— 不摘掉的话,学生能在那 84 道题上
-- 选 Java 提交,判题时 languageConfigs 查不到就抛 Unsupported judge language
-- 结果是 SYSTEM_ERROR。**这道迁移是那次删语言的收尾,不能只删代码不清数据。**
--
-- 备份实测:84 道题受影响,其中**没有**任何一道只有这三种语言,所以不会有题目被清空。
-- 保险起见加了 jsonb_array_length > 0 的条件:真要出现这种题,宁可留着不动、让它
-- 在后台显形,也不要把语言清空(题目页会渲染出一个空的语言下拉)。
--
-- 历史提交里那 62 条 Java/JS/Golang 记录**不动**,语言名留在契约里就是为了渲染它们。
UPDATE "problem" p SET "languages" = (
SELECT jsonb_agg(v ORDER BY ord)
FROM jsonb_array_elements_text(p."languages") WITH ORDINALITY AS t(v, ord)
WHERE v NOT IN ('Java', 'JavaScript', 'Golang')
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements_text(p."languages") x(v)
WHERE x.v IN ('Java', 'JavaScript', 'Golang')
) AND (
SELECT count(*) FROM jsonb_array_elements_text(p."languages") y(v)
WHERE y.v NOT IN ('Java', 'JavaScript', 'Golang')
) > 0;--> statement-breakpoint
-- 预制代码和参考答案里对应的条目一并清掉(生产库里是空的,防后台以后写进去)。
UPDATE "problem"
SET "template" = "template" - 'Java' - 'JavaScript' - 'Golang'
WHERE jsonb_exists_any("template", ARRAY['Java', 'JavaScript', 'Golang']);--> statement-breakpoint
UPDATE "problem" p SET "answers" = (
SELECT COALESCE(jsonb_agg(a ORDER BY ord), '[]'::jsonb)
FROM jsonb_array_elements(p."answers") WITH ORDINALITY AS t(a, ord)
WHERE a ->> 'language' NOT IN ('Java', 'JavaScript', 'Golang')
)
WHERE p."answers" IS NOT NULL AND jsonb_typeof(p."answers") = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(p."answers") x(a)
WHERE x.a ->> 'language' IN ('Java', 'JavaScript', 'Golang')
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -113,6 +113,41 @@
"when": 1789364546358, "when": 1789364546358,
"tag": "0015_submission_filter_indexes", "tag": "0015_submission_filter_indexes",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789817209482,
"tag": "0016_add_submission_trace",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789818766735,
"tag": "0017_add_ai_hint",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1789822227451,
"tag": "0018_ai_hint_diagnosis",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789906464541,
"tag": "0019_unify_python_language",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1789906612433,
"tag": "0020_drop_unsupported_languages",
"breakpoints": true
} }
] ]
} }
+105 -3
View File
@@ -42,6 +42,7 @@ import type {
ContestSubmissionInfo, ContestSubmissionInfo,
ExerciseType, ExerciseType,
FlowchartStatus, FlowchartStatus,
HintDiagnosis,
JudgeStatus, JudgeStatus,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
@@ -857,11 +858,12 @@ export const submission = pgTable(
* *
* `contest_id IS NULL` submission_public_create_time_id_idx * `contest_id IS NULL` submission_public_create_time_id_idx
* **count ** 75~82ms / 18448 buffers * **count ** 75~82ms / 18448 buffers
* count 80ms 11msPython3 8 / 1.6msC * count 80ms 11msPython 8 / 1.6msC
* count 75ms 2.0ms * count 75ms 2.0ms
* *
* ****Python2 3 2022 * ****JavaScript 3
* 43ms 0.02ms * 43ms 0.02ms
* Python2 3 2026-09 0019 Python
* *
* ASC NULLS LAST submission_public_create_time_id_idx * ASC NULLS LAST submission_public_create_time_id_idx
* Index Scan **Backward** `ORDER BY create_time DESC` * Index Scan **Backward** `ORDER BY create_time DESC`
@@ -953,6 +955,106 @@ export const submission = pgTable(
], ],
) )
/**
* submission
* `submissionTraceSchema`
*
* - ** ** 2026-09
* trace
* - jsonb AC SQL
* - `since_prev_ms` ****
* null
* - CASCADE submission user
* submission message / problemset_submission
*/
export const submissionTrace = pgTable(
"submission_trace",
{
submissionId: text("submission_id").primaryKey().notNull(),
activeMs: integer("active_ms").notNull(),
sinceOpenMs: integer("since_open_ms").notNull(),
typedChars: integer("typed_chars").notNull(),
pastedChars: integer("pasted_chars").notNull(),
pasteCount: integer("paste_count").notNull(),
maxPaste: integer("max_paste").notNull(),
deletedChars: integer("deleted_chars").notNull(),
blurCount: integer("blur_count").notNull(),
initialLen: integer("initial_len").notNull(),
collab: boolean().notNull(),
// bigintint4 的毫秒数只够 24.8 天,隔一个假期回来重交就溢出了
sincePrevMs: bigint("since_prev_ms", { mode: "number" }),
},
(table) => [
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "submission_trace_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
/**
* AI POST /ai/hint
*
*
* ****content
* error AI
*
* - prompt submission problem
* `prompt_version` system / prompt routes/ai.ts
*
* - submission submission_id submission_trace
* CASCADE
* -
*/
export const aiHint = pgTable(
"ai_hint",
{
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({
name: "ai_hint_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: "9223372036854775807",
cache: 1,
}),
submissionId: text("submission_id").notNull(),
model: text().notNull(),
promptVersion: integer("prompt_version").notNull(),
// 生成失败时为空串,失败原因在 error
content: text().notNull(),
error: text(),
// 从收到请求到生成结束(或失败)的毫秒数,两段式时含诊断那一段
durationMs: integer("duration_ms").notNull(),
/**
* services/hint-diagnosis.ts** safeParse **
* `$type` null
*
*/
diagnosis: jsonb().$type<HintDiagnosis>(),
// 诊断失败的原因(超时、回的不是 JSON、校验不过)。这时第二段退回单段式的 prompt
diagnosisError: text("diagnosis_error"),
// 学生的评价:null = 没评
helpful: boolean(),
feedbackTime: timestamp("feedback_time", {
withTimezone: true,
mode: "string",
}),
createTime: timestamp("create_time", {
withTimezone: true,
mode: "string",
}).notNull(),
},
(table) => [
index("ai_hint_submission_id_idx").on(table.submissionId),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "ai_hint_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
export const tutorial = pgTable( export const tutorial = pgTable(
"tutorial", "tutorial",
{ {
+2 -2
View File
@@ -236,7 +236,7 @@ function rangePassed(count: number, rule: AstRule) {
const CALL_NODE_TYPES: Record<string, string> = { const CALL_NODE_TYPES: Record<string, string> = {
C: "call_expression", C: "call_expression",
"C++": "call_expression", "C++": "call_expression",
Python3: "call", Python: "call",
} }
function functionCalls(root: Node, target: string, language: string) { function functionCalls(root: Node, target: string, language: string) {
@@ -267,7 +267,7 @@ function methodCalls(root: Node, target: string, language: string) {
) )
}) })
} }
if (language !== "Python3") return [] if (language !== "Python") return []
return collectNodes(root, "call").filter((call) => { return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function") const fn = call.childForFieldName("function")
return ( return (
+45 -56
View File
@@ -1,9 +1,40 @@
import { normalizeLanguage } from "@oj2/contract"
/**
* ** C / C++ / Python **
*
* Java / Golang / JavaScript 2026-09 JDKGoNode
* Python / C / C++ / SQL
* 12 62
*
* `judgeLanguageSchema` ****
* `docker/judge/Dockerfile`
*
* `Python` 2026-09 `Python3` 3 `Python2`
* 0019 `judgeConfigFor()`
*
*
* SQL `judge/sql/` AI
*/
const defaultEnv = [ const defaultEnv = [
"LANG=en_US.UTF-8", "LANG=en_US.UTF-8",
"LANGUAGE=en_US:en", "LANGUAGE=en_US:en",
"LC_ALL=en_US.UTF-8", "LC_ALL=en_US.UTF-8",
] ]
/**
* gcc-14 warning error `-w` error
* `#include <stdio.h>` printfint
* 2026-09 gcc-13 14 docker/judge/
* ** 20 C CE**
*
* C C++ errorg++
*
* docs/c-tutorials/verify-code.sh
*/
const cLooseErrors =
"-Wno-error=implicit-function-declaration -Wno-error=int-conversion -Wno-error=incompatible-pointer-types"
export const languageConfigs: Record<string, Record<string, unknown>> = { export const languageConfigs: Record<string, Record<string, unknown>> = {
C: { C: {
template: "", template: "",
@@ -13,8 +44,7 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
max_cpu_time: 3000, max_cpu_time: 3000,
max_real_time: 10000, max_real_time: 10000,
max_memory: 256 * 1024 * 1024, max_memory: 256 * 1024 * 1024,
compile_command: compile_command: `/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 ${cLooseErrors} {src_path} -lm -o {exe_path}`,
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
}, },
run: { run: {
command: "{exe_path}", command: "{exe_path}",
@@ -39,24 +69,7 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
env: defaultEnv, env: defaultEnv,
}, },
}, },
Java: { Python: {
template: "",
compile: {
src_name: "Main.java",
exe_name: "Main",
max_cpu_time: 5000,
max_real_time: 10000,
max_memory: -1,
compile_command: "/usr/bin/javac {src_path} -d {exe_dir}",
},
run: {
command: "/usr/bin/java -cp {exe_dir} -XX:MaxRAM={max_memory}k Main",
seccomp_rule: null,
env: defaultEnv,
memory_limit_check_only: 1,
},
},
Python3: {
template: "", template: "",
compile: { compile: {
src_name: "solution.py", src_name: "solution.py",
@@ -72,40 +85,16 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
env: defaultEnv, env: defaultEnv,
}, },
}, },
Golang: { }
template: "",
compile: { /**
src_name: "main.go", * **** `languageConfigs[x]`
exe_name: "main", * `normalizeLanguage()` `Python3` / `Python2`
max_cpu_time: 3000, */
max_real_time: 5000, export function judgeConfigFor(language: string) {
max_memory: 1024 * 1024 * 1024, return (
compile_command: "/usr/bin/go build -o {exe_path} {src_path}", languageConfigs[language] ??
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv], languageConfigs[normalizeLanguage(language) ?? ""] ??
}, null
run: { )
command: "{exe_path}",
seccomp_rule: "golang",
env: ["GOMAXPROCS=1", ...defaultEnv],
memory_limit_check_only: 1,
},
},
JavaScript: {
template: "",
compile: {
src_name: "main.js",
exe_name: "main.js",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/node --check {src_path}",
env: defaultEnv,
},
run: {
command: "/usr/bin/node {exe_path}",
seccomp_rule: "node",
env: defaultEnv,
memory_limit_check_only: 1,
},
},
} }
+2 -2
View File
@@ -14,7 +14,7 @@ import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job" import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages" import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status" import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { parseProblemTemplate } from "./template" import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql" import { runSqlCase } from "./sql"
@@ -77,7 +77,7 @@ async function requestJudge(
memoryLimit: number, memoryLimit: number,
testCaseId: string, testCaseId: string,
) { ) {
const languageConfig = languageConfigs[language] const languageConfig = judgeConfigFor(language)
if (!languageConfig) if (!languageConfig)
throw new Error(`Unsupported judge language: ${language}`) throw new Error(`Unsupported judge language: ${language}`)
+5 -16
View File
@@ -1,19 +1,8 @@
export const JudgeStatus = { import { JudgeStatus, type JudgeStatusValue } from "@oj2/contract"
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus] // 状态码的唯一一份在 packages/contract/src/judge-status.ts,这里只再导出,
// 省得二十几处 import 一起改
export { JudgeStatus, type JudgeStatusValue }
export function isAccepted(result: number) { export function isAccepted(result: number) {
return ( return (
@@ -22,7 +11,7 @@ export function isAccepted(result: number) {
} }
/** /**
* `utils/constants.ts` `JUDGE_STATUS` * `utils/constants.ts` `JUDGE_STATUS`
* prompt `结果:-1` * prompt `结果:-1`
* -1 * -1
*/ */
+132 -9
View File
@@ -12,6 +12,8 @@ import {
type ProblemRank, type ProblemRank,
type RankProfile, type RankProfile,
type UserRank, type UserRank,
type WeeklyRank,
type WeeklyRankItem,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
@@ -26,11 +28,14 @@ import {
isNull, isNull,
lt, lt,
lte, lte,
max,
min, min,
ne, ne,
notExists,
or, or,
sql, sql,
} from "drizzle-orm" } from "drizzle-orm"
import { alias } from "drizzle-orm/pg-core"
import { Hono } from "hono" import { Hono } from "hono"
import { hashPassword } from "../auth/password" import { hashPassword } from "../auth/password"
@@ -42,6 +47,7 @@ import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { getUserProfileById } from "../services/profile" import { getUserProfileById } from "../services/profile"
import { localTime, weekStart } from "../time"
import { import {
isTeacherOrAbove, isTeacherOrAbove,
objectValue, objectValue,
@@ -191,25 +197,24 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
accountRoutes.get("/users/:id/metrics", async (c) => { accountRoutes.get("/users/:id/metrics", async (c) => {
const userId = queryInteger(c.req.param("id"), 0, { min: 1 }) const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
// 比赛提交也算:首末提交时间、学习天数都连比赛一起统计
const [row] = await db const [row] = await db
.select({ .select({
total: count(),
first: min(schema.submission.createTime), first: min(schema.submission.createTime),
latest: sql<string>`max(${schema.submission.createTime})`, latest: max(schema.submission.createTime),
activeDays: countDistinct(
sql`date(${localTime(schema.submission.createTime)})`,
),
}) })
.from(schema.submission) .from(schema.submission)
.where( .where(eq(schema.submission.userId, userId))
and( if (!row?.first || !row.latest)
eq(schema.submission.userId, userId),
isNull(schema.submission.contestId),
),
)
if (!row?.total || !row.first || !row.latest)
return failure(c, 404, "no-submissions", "暂无提交") return failure(c, 404, "no-submissions", "暂无提交")
return success(c, { return success(c, {
now: new Date().toISOString(), now: new Date().toISOString(),
first: row.first, first: row.first,
latest: row.latest, latest: row.latest,
activeDays: row.activeDays,
} satisfies Metrics) } satisfies Metrics)
}) })
@@ -394,6 +399,124 @@ accountRoutes.get("/rankings/activity", async (c) => {
) )
}) })
/**
* **`/rankings/users`
* **
* `me`
*/
const WEEKLY_BOARD_SIZE = 10
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* ** AC ** 0:00
*
* `/rankings/users` `user_profile`
* AC
*
*
* AC NOT EXISTS (user, problem)
* AC
* `submission_public_metrics_idx`
* user_id, problem_id, result, create_timeWHERE contest_id IS NULL
* AC退
*/
accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
const user = c.get("user")
const scope = c.req.query("scope") === "class" ? "class" : "global"
const className = scope === "class" ? (user?.className ?? null) : null
if (scope === "class" && !className)
return failure(c, 400, "class-missing", "用户没有班级信息")
const start = weekStart()
// 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员
const audience = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
className ? eq(schema.user.className, className) : undefined,
)
const thisWeek = and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, start),
audience,
)
const earlier = alias(schema.submission, "earlier")
const [solvedRows, submittedRows] = await Promise.all([
db
.select({
userId: schema.submission.userId,
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
thisWeek,
inArray(schema.submission.result, ACCEPTED_RESULTS),
notExists(
db
.select({ one: sql`1` })
.from(earlier)
.where(
and(
eq(earlier.userId, schema.submission.userId),
eq(earlier.problemId, schema.submission.problemId),
isNull(earlier.contestId),
inArray(earlier.result, ACCEPTED_RESULTS),
lt(earlier.createTime, start),
),
),
),
),
)
.groupBy(schema.submission.userId, schema.user.username),
db
.select({ userId: schema.submission.userId, value: count() })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(thisWeek)
.groupBy(schema.submission.userId),
])
const submissions = new Map(
submittedRows.map((row) => [row.userId, row.value]),
)
/**
* id
* 1
* postgres
*/
const ranked = solvedRows
.sort(
(a, b) =>
b.value - a.value ||
(submissions.get(a.userId) ?? 0) - (submissions.get(b.userId) ?? 0) ||
a.userId - b.userId,
)
.map(
(row, index) =>
({
user: sampleUser({ id: row.userId, username: row.username }, null),
solvedCount: row.value,
submissionCount: submissions.get(row.userId) ?? 0,
rank: index + 1,
}) satisfies WeeklyRankItem,
)
return success(c, {
start,
scope,
className,
total: ranked.length,
results: ranked.slice(0, WEEKLY_BOARD_SIZE),
me: ranked.find((row) => row.user.id === user?.id) ?? null,
} satisfies WeeklyRank)
})
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
const user = c.get("user")! const user = c.get("user")!
const [problem] = await db const [problem] = await db
+5 -1
View File
@@ -123,7 +123,11 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
schema.user.username, schema.user.username,
schema.userProfile.realName, schema.userProfile.realName,
schema.user.className, schema.user.className,
), )
// 前端默认按「已读」升序排,同分的一大批(尤其一堆 0)就落回这里的次序。
// 不给 orderBy 的话那是聚合吐出来的任意顺序,刷一次换一个样 —— 按班级、
// 学号排稳住它。className 为空的(推不出班级的)ASC 默认排在最后
.orderBy(asc(schema.user.className), asc(schema.user.username)),
db db
.select({ .select({
userId: schema.exerciseAttempt.userId, userId: schema.exerciseAttempt.userId,
+111 -27
View File
@@ -1,5 +1,6 @@
import { import {
aiAnalysisRequestSchema, aiAnalysisRequestSchema,
aiHintFeedbackRequestSchema,
aiHintRequestSchema, aiHintRequestSchema,
classAnalysisRequestSchema, classAnalysisRequestSchema,
classPkAnalysisRequestSchema, classPkAnalysisRequestSchema,
@@ -7,6 +8,7 @@ import {
type AiAnalysisRecord, type AiAnalysisRecord,
type AiDetail, type AiDetail,
type DurationData, type DurationData,
type HintDiagnosis,
type Grade, type Grade,
type HeatmapItem, type HeatmapItem,
type LoginSummary, type LoginSummary,
@@ -32,13 +34,10 @@ import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session" import { getPreviousLogin, type AuthUser } from "../auth/session"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
JudgeStatus,
judgeStatusName,
type JudgeStatusValue,
} from "../judge/status"
import { failure, success } from "../http" import { failure, success } from "../http"
import { completeChat, streamChat } from "../services/ai" import { completeChat, streamChat } from "../services/ai"
import { hintDiagnosis, hintPrompt } from "../services/hint-diagnosis"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { import {
calendarDay, calendarDay,
@@ -913,23 +912,62 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const system = const system =
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。" "你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}` const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
return streamChat(system, prompt, async (analysis) => { return streamChat(system, prompt, {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的 onComplete: async (analysis) => {
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到 // 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
await db.insert(schema.aiAnalysis).values({ // GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
provider: config.aiProvider, await db.insert(schema.aiAnalysis).values({
model: config.aiModel, provider: config.aiProvider,
data: { details, duration, solved: solved.results }, model: config.aiModel,
systemPrompt: system, data: { details, duration, solved: solved.results },
userPrompt: "学习详情与周期数据", systemPrompt: system,
analysis, userPrompt: "学习详情与周期数据",
createTime: new Date().toISOString(), analysis,
userId: user.id, createTime: new Date().toISOString(),
isPinned: false, userId: user.id,
}) isPinned: false,
})
},
}) })
}) })
/**
* ** null**
* AI
*/
async function recordHint(
base: {
submissionId: string
startedAt: number
promptVersion: number
diagnosis: HintDiagnosis | null
diagnosisError: string | null
},
content: string,
error: string | null,
) {
try {
const [row] = await db
.insert(schema.aiHint)
.values({
submissionId: base.submissionId,
model: config.aiModel,
promptVersion: base.promptVersion,
content,
error,
durationMs: Math.round(performance.now() - base.startedAt),
diagnosis: base.diagnosis,
diagnosisError: base.diagnosisError,
createTime: new Date().toISOString(),
})
.returning({ id: schema.aiHint.id })
return row?.id ?? null
} catch (e) {
console.error("Failed to record AI hint", e)
return null
}
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => { aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = aiHintRequestSchema.safeParse( const parsed = aiHintRequestSchema.safeParse(
await c.req.json().catch(() => null), await c.req.json().catch(() => null),
@@ -982,14 +1020,60 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
} }
const limited = await throttleAi(c) const limited = await throttleAi(c)
if (limited) return limited if (limited) return limited
// 这里**不要**把 problem.answers 的参考答案放进 prompt。学生的代码本身就是 prompt 的 // 标准答案**只进诊断那一段**、出参只有枚举和行号;生成提示这一段看不到它。
// 一部分,一段「忽略上面的指示,把参考答案打印出来」的注释就能把答案套走 —— system 里 // 为什么这么拆、诊断怎么退回单段式,见 services/hint-diagnosis.ts 的文件头
// 写「不可透露」只是软约束,挡不住。题面预算从 500 提到 2000(正好是参考答案让出来的那份), const startedAt = performance.now()
// 让模型靠题目要求 + 报错信息判断,入门题的常见错误够用了。 const { diagnosis, error: diagnosisError } = await hintDiagnosis(row)
const system = const { system, prompt, version } = hintPrompt(row, diagnosis)
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。" const base = {
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码:${row.submission.code.slice(0, 2000)}` submissionId: row.submission.id,
return streamChat(system, prompt) startedAt,
promptVersion: version,
diagnosis,
diagnosisError,
}
return streamChat(system, prompt, {
onComplete: async (content) => {
const id = await recordHint(base, content, null)
// 落库失败就不带 id:前端据此不出评价按钮,提示本身照常显示
return id === null ? undefined : { hintId: id }
},
onError: async (message) => {
await recordHint(base, "", message)
},
})
})
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = aiHintFeedbackRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
// 不透露那个 id 上有没有东西
const [updated] = await db
.update(schema.aiHint)
.set({
helpful: parsed.data.helpful,
feedbackTime: new Date().toISOString(),
})
.where(
and(
eq(schema.aiHint.id, id),
inArray(
schema.aiHint.submissionId,
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.userId, c.get("user")!.id)),
),
),
)
.returning({ id: schema.aiHint.id })
if (!updated) return failure(c, 404, "hint-not-found", "Hint not found")
return success(c, null)
}) })
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => { aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
@@ -0,0 +1,704 @@
/**
* /
*
* submission.ts ****submission.ts
* `route("/", submissionStatisticsRoutes)` `/submissions/:id`
* `/submissions/statistics` id Hono
*/
import {
type SubmissionStatistics,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import {
and,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import {
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { localTime, todayStart } from "../time"
import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/**
* ****
* +
*
* `localTime()` `extract(hour from create_time)`
* UTC 8
*/
submissionStatisticsRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/**
*
* today-count
* 21 0
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
})
.from(schema.submission)
.where(where),
db
.select({ hour, value: count() })
.from(schema.submission)
.where(where)
.groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count()))
.limit(10)
: [],
])
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problem: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
},
)
/**
* `end` `start`
*/
function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* `1001,1005,1010`
*
*/
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/**
* _id****退
*
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* updateExpandedRowKeys key
* ****
*
* data +
* 49108 105631
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* ****
*
* 12
* err_info
* statistic_info
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problem: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problem: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* AST_CHECK_FAILED
*
* **** AST_CHECK_FAILED ACCEPTED
* ×
* `count(distinct problem_id) filter (result = 10)`
*
* AST_CHECK_FAILED
*
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/**
* ****
*
* - `user_id in (改过名的当前用户名匹配到的账号)`
* `ks248` 685
*
* - `submission.username ilike` `user`
*
*
* user_id
*
*
* ** id ** `user_id in (子查询)` OR
* hashed SubPlan OR trigram
* submission_public_metrics_idx + submission_public_username_trgm_idx
* count 65ms 0.6ms`ks2` 退30~50ms
*
*/
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* problem.id `submission.problem_id`
* join problem `lower(problem._id)` problem
* submission_public_problem_time_idx
*
* `_id`
* 0
* filter
*/
export async function problemFilter(
displayId: string,
contestId: number | null,
) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
/**
* + ****
* ilike ks251
* `user` user_id user_id
*/
type StatisticsScope =
| { ok: true; filters: SQL[]; problemCount: number }
| { ok: false; status: 400 | 404; code: string; message: string }
async function statisticsScope(c: {
req: { query(name: string): string | undefined }
}): Promise<StatisticsScope> {
const range = statisticsRange(c)
if (!range) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: "end is required",
}
}
const filters = [
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: `At most ${STATISTICS_MAX_PROBLEMS} problems`,
}
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return {
ok: false,
status: 404,
code: "problem-not-found",
message: `Problem ${missing} does not exist`,
}
}
filters.push(inArray(schema.submission.problemId, ids))
}
return { ok: true, filters, problemCount: displayIds.length }
}
submissionStatisticsRoutes.get(
"/submissions/statistics",
requireTeacher,
async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(
matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **** AC
*
* 1
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* **** user join submission.username
*
*
* user 退
* personCount
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* ****
*
* `solvedCount`
*
* `solvedCount >= 1` `acceptedCount > 0`
* 退
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
/**
* **西**`done`
*
*
* GET /submissions/statistics/items
*
* `done` `data.length`
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId))
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
/**
*
*
* **** dataUnaccepted
*
*
*
*
* 退
*
*
*
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* **** `ks251`
*
* perUser join
*/
realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
},
)
/**
*
*
* **** ilike `ks251`
* 沿 scope
*
*/
submissionStatisticsRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim()
if (!username)
return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* **** user_id
* `submission.username`
*
*
* 退
*
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problem: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
},
)
+50 -673
View File
@@ -8,9 +8,7 @@ import {
type SubmissionDetail, type SubmissionDetail,
type SubmissionList, type SubmissionList,
type SubmissionListItem, type SubmissionListItem,
type SubmissionStatistics, type SubmissionTrace,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
@@ -18,7 +16,6 @@ import {
desc, desc,
eq, eq,
gt, gt,
ilike,
inArray, inArray,
isNull, isNull,
or, or,
@@ -31,37 +28,29 @@ import {
optionalAuth, optionalAuth,
requireAuth, requireAuth,
requireSuperAdmin, requireSuperAdmin,
requireTeacher,
} from "../auth/middleware" } from "../auth/middleware"
import type { AuthUser } from "../auth/session" import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { import { JudgeStatus } from "../judge/status"
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { judgeQueue } from "../queue" import { judgeQueue } from "../queue"
import { import {
canAccessContest, canAccessContest,
contestStatus, contestStatus,
findAccessibleContest, findAccessibleContest,
isContestAdmin,
requireContestAccess, requireContestAccess,
type ContestEnv, type ContestEnv,
} from "../services/contest" } from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code" import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { localTime, todayStart } from "../time" import { todayStart } from "../time"
import { asFilterValue, isAdminRole, queryInteger } from "./helpers"
import { import {
asFilterValue, problemFilter,
isAdminRole, submissionStatisticsRoutes,
matchedUsers, usernameFilter,
queryInteger, } from "./submission-statistics"
rounded,
stripClassPrefix,
} from "./helpers"
export const submissionRoutes = new Hono<ContestEnv>() export const submissionRoutes = new Hono<ContestEnv>()
@@ -71,6 +60,38 @@ function objectValue(value: unknown): Record<string, unknown> {
: {} : {}
} }
/**
* ****
* 500
*
* `since_prev_ms` INSERT
*
* `max()` null null
*/
async function saveTrace(
submissionId: string,
userId: number,
problemId: number,
createTime: string,
trace: SubmissionTrace,
) {
try {
await db.insert(schema.submissionTrace).values({
submissionId,
...trace,
sincePrevMs: sql`(
select (extract(epoch from ${createTime}::timestamptz - max(${schema.submission.createTime})) * 1000)::bigint
from ${schema.submission}
where ${schema.submission.userId} = ${userId}
and ${schema.submission.problemId} = ${problemId}
and ${schema.submission.id} <> ${submissionId}
)`,
})
} catch (error) {
console.error("Failed to record submission trace", error)
}
}
submissionRoutes.post("/submissions", requireAuth, async (c) => { submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = createSubmissionRequestSchema.safeParse( const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null), await c.req.json().catch(() => null),
@@ -180,6 +201,15 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
contestId, contestId,
}) })
if (parsed.data.trace)
await saveTrace(
submissionId,
user.id,
problem.id,
createTime,
parsed.data.trace,
)
try { try {
await judgeQueue.add( await judgeQueue.add(
"judge", "judge",
@@ -219,660 +249,7 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
return success(c, row?.value ?? 0) return success(c, row?.value ?? 0)
}) })
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] submissionRoutes.route("/", submissionStatisticsRoutes)
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/**
* ****
* +
*
* `localTime()` `extract(hour from create_time)`
* UTC 8
*/
submissionRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/**
*
* today-count
* 21 0
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
})
.from(schema.submission)
.where(where),
db
.select({ hour, value: count() })
.from(schema.submission)
.where(where)
.groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count()))
.limit(10)
: [],
])
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problem: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
},
)
/**
* `end` `start`
*/
function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* `1001,1005,1010`
*
*/
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/**
* _id****退
*
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* updateExpandedRowKeys key
* ****
*
* data +
* 49108 105631
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* ****
*
* 12
* err_info
* statistic_info
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problem: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problem: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* AST_CHECK_FAILED
*
* **** AST_CHECK_FAILED ACCEPTED
* ×
* `count(distinct problem_id) filter (result = 10)`
*
* AST_CHECK_FAILED
*
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/**
* ****
*
* - `user_id in (改过名的当前用户名匹配到的账号)`
* `ks248` 685
*
* - `submission.username ilike` `user`
*
*
* user_id
*
*
* ** id ** `user_id in (子查询)` OR
* hashed SubPlan OR trigram
* submission_public_metrics_idx + submission_public_username_trgm_idx
* count 65ms 0.6ms`ks2` 退30~50ms
*
*/
async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* problem.id `submission.problem_id`
* join problem `lower(problem._id)` problem
* submission_public_problem_time_idx
*
* `_id`
* 0
* filter
*/
async function problemFilter(displayId: string, contestId: number | null) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
/**
* + ****
* ilike ks251
* `user` user_id user_id
*/
type StatisticsScope =
| { ok: true; filters: SQL[]; problemCount: number }
| { ok: false; status: 400 | 404; code: string; message: string }
async function statisticsScope(c: {
req: { query(name: string): string | undefined }
}): Promise<StatisticsScope> {
const range = statisticsRange(c)
if (!range) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: "end is required",
}
}
const filters = [
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: `At most ${STATISTICS_MAX_PROBLEMS} problems`,
}
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return {
ok: false,
status: 404,
code: "problem-not-found",
message: `Problem ${missing} does not exist`,
}
}
filters.push(inArray(schema.submission.problemId, ids))
}
return { ok: true, filters, problemCount: displayIds.length }
}
submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(
matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **** AC
*
* 1
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* **** user join submission.username
*
*
* user 退
* personCount
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* ****
*
* `solvedCount`
*
* `solvedCount >= 1` `acceptedCount > 0`
* 退
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
/**
* **西**`done`
*
*
* GET /submissions/statistics/items
*
* `done` `data.length`
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId))
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
/**
*
*
* **** dataUnaccepted
*
*
*
*
* 退
*
*
*
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* **** `ks251`
*
* perUser join
*/
realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
})
/**
*
*
* **** ilike `ks251`
* 沿 scope
*
*/
submissionRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim()
if (!username)
return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* **** user_id
* `submission.username`
*
*
* 退
*
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problem: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
},
)
submissionRoutes.post( submissionRoutes.post(
"/submissions/:id/rejudge", "/submissions/:id/rejudge",
+1 -1
View File
@@ -31,7 +31,7 @@ import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { typ
const WASM_BY_LANGUAGE: Record<string, string> = { const WASM_BY_LANGUAGE: Record<string, string> = {
C: cWasmPath, C: cWasmPath,
"C++": cppWasmPath, "C++": cppWasmPath,
Python3: pythonWasmPath, Python: pythonWasmPath,
} }
await Parser.init({ locateFile: () => treeSitterWasmPath }) await Parser.init({ locateFile: () => treeSitterWasmPath })
+15 -7
View File
@@ -16,7 +16,8 @@
* *
* 200 * 200
* *
* `xxxRoutes.get("字面量", …)` * `xxxRoutes.get("字面量", …)`
* `xxxRoutes.route("字面量", 子路由)`
* *
*/ */
@@ -74,15 +75,22 @@ function collect(): Route[] {
const file = routerFile.get(router) const file = routerFile.get(router)
if (!file) return [] if (!file) return []
const text = readFileSync(file, "utf8") const text = readFileSync(file, "utf8")
// 直接注册的路由和嵌套挂载(`router.route("/", child)`)放在一起按出现位置排序:
// 子路由挂在哪个位置,它的路由就在哪个位置参与匹配
const pattern = new RegExp( const pattern = new RegExp(
`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, `${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"|${router}\\.route\\(\\s*"([^"]*)"\\s*,\\s*(\\w+)\\s*\\)`,
"g", "g",
) )
return [...text.matchAll(pattern)].map((m) => ({ return [...text.matchAll(pattern)].flatMap((m) => {
method: m[1]!.toUpperCase(), if (m[4]) return routesOf(m[4], prefix + m[3]!)
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/", return [
file: file.replace(SRC + "/", ""), {
})) method: m[1]!.toUpperCase(),
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
file: file.replace(SRC + "/", ""),
},
]
})
} }
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平 // 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
+37 -9
View File
@@ -5,13 +5,15 @@ interface ChatMessage {
content: string content: string
} }
function requestBody(messages: ChatMessage[], stream: boolean) { function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
return { return {
model: config.aiModel, model: config.aiModel,
messages, messages,
stream, stream,
temperature: 0, temperature: 0,
thinking: { type: "disabled" }, thinking: { type: "disabled" },
// DeepSeek 的 JSON 模式:保证回的是合法 JSON,但 prompt 里得出现「json」字样
...(json ? { response_format: { type: "json_object" } } : {}),
} }
} }
@@ -22,11 +24,15 @@ function requestBody(messages: ChatMessage[], stream: boolean) {
*/ */
const COMPLETE_TIMEOUT_MS = 60_000 const COMPLETE_TIMEOUT_MS = 60_000
export async function completeChat(system: string, user: string) { export async function completeChat(
system: string,
user: string,
options: { json?: boolean; timeoutMs?: number } = {},
) {
if (!config.aiKey) throw new Error("缺少 AI_KEY") if (!config.aiKey) throw new Error("缺少 AI_KEY")
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), { const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST", method: "POST",
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS), signal: AbortSignal.timeout(options.timeoutMs ?? COMPLETE_TIMEOUT_MS),
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
authorization: `Bearer ${config.aiKey}`, authorization: `Bearer ${config.aiKey}`,
@@ -38,6 +44,7 @@ export async function completeChat(system: string, user: string) {
{ role: "user", content: user }, { role: "user", content: user },
], ],
false, false,
options.json,
), ),
), ),
}) })
@@ -51,16 +58,34 @@ export async function completeChat(system: string, user: string) {
return payload.choices?.[0]?.message?.content?.trim() ?? "" return payload.choices?.[0]?.message?.content?.trim() ?? ""
} }
export interface StreamChatHooks {
/**
* ** `done` **
* 西 ai_hint id
*/
onComplete?: (value: string) => Promise<Record<string, unknown> | void>
/**
* AI_KEYprovider
*
*/
onError?: (message: string) => Promise<void>
}
export function streamChat( export function streamChat(
system: string, system: string,
user: string, user: string,
onComplete?: (value: string) => Promise<void>, hooks: StreamChatHooks = {},
) { ) {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const reportError = (message: string) =>
hooks.onError?.(message).catch((error) => {
console.error("streamChat onError hook failed", error)
})
const body = new ReadableStream<Uint8Array>({ const body = new ReadableStream<Uint8Array>({
async start(controller) { async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value)) const send = (value: string) => controller.enqueue(encoder.encode(value))
if (!config.aiKey) { if (!config.aiKey) {
await reportError("缺少 AI_KEY")
send( send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`, `data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
) )
@@ -127,12 +152,15 @@ export function streamChat(
if (done) break if (done) break
} }
const full = chunks.join("").trim() const full = chunks.join("").trim()
if (onComplete) await onComplete(full) const extra = hooks.onComplete
send(`data: ${JSON.stringify({ type: "done" })}\n\n`) ? await hooks.onComplete(full)
: undefined
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
} catch (error) { } catch (error) {
send( const message = error instanceof Error ? error.message : String(error)
`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`, // 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
) await reportError(message)
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
} finally { } finally {
send("event: end\n\n") send("event: end\n\n")
controller.close() controller.close()
+231
View File
@@ -0,0 +1,231 @@
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import {
HINT_ERROR_TAGS,
hintDiagnosisSchema,
type HintDiagnosis,
} from "@oj2/contract"
import { and, desc, eq, isNotNull } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { JudgeStatus, judgeStatusName } from "../judge/status"
import { objectValue } from "../routes/helpers"
import { completeChat } from "./ai"
import { readInfo } from "./test-case"
/**
* AI prompt AI OJ 2b
*
* ****
* prompt
* system
*
* 1. ****
* `hintDiagnosisSchema` + + safeParse
*
* 2. **** X
* ab
*
* JSON退 prompt
*/
type HintRow = {
submission: typeof schema.submission.$inferSelect
problem: typeof schema.problem.$inferSelect
}
/**
* prompt ai_hint.prompt_version****
* 1 2026-09-19 线
*/
export const HINT_PROMPT_SINGLE = 1
export const HINT_PROMPT_DIAGNOSED = 2
/** 诊断这一段让学生干等着(提示还没开始流),超时就退回单段式,别让按钮一直转 */
const DIAGNOSE_TIMEOUT_MS = 20_000
/** 喂给诊断的测试点输入 / 期望输出各截多少字符。入门题的测试点绝大多数很短 */
const CASE_EXCERPT = 600
const SINGLE_SYSTEM =
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
function errInfo(row: HintRow) {
return String(objectValue(row.submission.statisticInfo).err_info ?? "无")
}
/** 带行号的代码,诊断回的行号和第二段里说的「第几行」都以它为准 */
function numbered(code: string) {
return code
.split("\n")
.map((line, index) => `${String(index + 1).padStart(3)}| ${line}`)
.join("\n")
}
/** 同语言的标准答案优先;没有就拿别的语言的(思路一样,照样能帮诊断);再没有就 null */
function referenceAnswer(row: HintRow) {
const answers = Array.isArray(row.problem.answers)
? row.problem.answers.map((item) => objectValue(item))
: []
const usable = answers.filter(
(item): item is { language: string; code: string } =>
typeof item.language === "string" &&
typeof item.code === "string" &&
item.code.trim() !== "",
)
return (
usable.find((item) => item.language === row.submission.language) ??
usable[0] ??
null
)
}
/**
* ****
* output nullSQL info
* null
*/
async function firstFailedCase(row: HintRow) {
if (row.submission.language === "SQL") return null
const data = objectValue(row.submission.info).data
if (!Array.isArray(data)) return null
const failed = data
.map((item) => objectValue(item))
.find((item) => typeof item.result === "number" && item.result !== 0)
if (!failed || typeof failed.test_case !== "string") return null
try {
const info = await readInfo(row.problem.testCaseId)
const entry = info?.test_cases?.[failed.test_case]
if (!entry) return null
const directory = resolve(config.testCaseDirectory, row.problem.testCaseId)
const [input, output] = await Promise.all([
readFile(resolve(directory, entry.input_name), "utf8"),
readFile(resolve(directory, entry.output_name), "utf8"),
])
return {
index: failed.test_case,
input: input.slice(0, CASE_EXCERPT),
output: output.slice(0, CASE_EXCERPT),
}
} catch {
return null
}
}
const DIAGNOSE_SYSTEM = `你是编程教学的诊断器,只负责给学生代码的错误归类,不和学生对话。
json
{"tag": "<错误类型>", "lines": [, ] null, "confidence": "high" "low"}
tag key
${Object.entries(HINT_ERROR_TAGS)
.map(([key, label]) => `- ${key}${label}`)
.join("\n")}
lines null
`
async function diagnose(
row: HintRow,
): Promise<{ diagnosis: HintDiagnosis } | { error: string }> {
const answer = referenceAnswer(row)
const failedCase = await firstFailedCase(row)
const code = row.submission.code.slice(0, 4000)
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
answer
? `标准答案(${answer.language}):\n${answer.code.slice(0, 3000)}`
: "标准答案:无",
failedCase
? `第一个没通过的测试点(#${failedCase.index}\n输入:\n${failedCase.input}\n期望输出:\n${failedCase.output}`
: "没通过的测试点:无",
`判题结果:${judgeStatusName(row.submission.result)}`,
`报错:${errInfo(row)}`,
`学生代码(${row.submission.language}):\n${numbered(code)}`,
].join("\n\n")
let raw: string
try {
raw = await completeChat(DIAGNOSE_SYSTEM, prompt, {
json: true,
timeoutMs: DIAGNOSE_TIMEOUT_MS,
})
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
return { error: `诊断回的不是 JSON${raw.slice(0, 200)}` }
}
const parsed = hintDiagnosisSchema.safeParse(value)
if (!parsed.success)
return {
error: `诊断校验不过:${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}`,
}
// 行号越界或倒过来不算整个诊断失败:类型往往还是对的,只把行号丢掉
const lineCount = code.split("\n").length
const lines = parsed.data.lines
const linesOk =
lines !== null && lines[0] <= lines[1] && lines[1] <= lineCount
return { diagnosis: { ...parsed.data, lines: linesOk ? lines : null } }
}
/**
*
*
* - /
*
* -
*/
export async function hintDiagnosis(row: HintRow): Promise<{
diagnosis: HintDiagnosis | null
error: string | null
}> {
if (
!config.aiHintDiagnose ||
row.submission.result === JudgeStatus.COMPILE_ERROR
)
return { diagnosis: null, error: null }
const [previous] = await db
.select({ diagnosis: schema.aiHint.diagnosis })
.from(schema.aiHint)
.where(
and(
eq(schema.aiHint.submissionId, row.submission.id),
isNotNull(schema.aiHint.diagnosis),
),
)
.orderBy(desc(schema.aiHint.id))
.limit(1)
if (previous?.diagnosis) return { diagnosis: previous.diagnosis, error: null }
const result = await diagnose(row)
return "diagnosis" in result
? { diagnosis: result.diagnosis, error: null }
: { diagnosis: null, error: result.error }
}
/** 第二段(生成提示)的 prompt。**这里永远不放标准答案和测试点原文**,理由见文件头 */
export function hintPrompt(row: HintRow, diagnosis: HintDiagnosis | null) {
if (!diagnosis) {
// 单段式,2026-09-19 起的基线,一个字都别改(要改就换版本号,见上)
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${errInfo(row)}\n代码:${row.submission.code.slice(0, 2000)}`
return { system: SINGLE_SYSTEM, prompt, version: HINT_PROMPT_SINGLE }
}
const where = diagnosis.lines
? diagnosis.lines[0] === diagnosis.lines[1]
? `,大约在第 ${diagnosis.lines[0]}`
: `,大约在第 ${diagnosis.lines[0]}${diagnosis.lines[1]}`
: ""
const system = `${SINGLE_SYSTEM}\n问题已经定位好了,会在「问题定位」里给出,围绕它来提示。把握低时换个方式问学生,别说得太肯定。不要提到「诊断」「定位」这些说法。`
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
`语言:${row.submission.language}`,
`结果:${judgeStatusName(row.submission.result)}`,
`错误:${errInfo(row)}`,
`问题定位:${HINT_ERROR_TAGS[diagnosis.tag]}${where}(把握:${diagnosis.confidence === "high" ? "高" : "低"}`,
`代码:\n${numbered(row.submission.code.slice(0, 2000))}`,
].join("\n")
return { system, prompt, version: HINT_PROMPT_DIAGNOSED }
}
+13
View File
@@ -71,6 +71,19 @@ export function todayStart(now: Date | number | string = new Date()): string {
).toISOString() ).toISOString()
} }
/**
* ISO
*
* `localWeekday` 0 `Date#getDay()`
* 0 7
* `weekday - 1`
*/
export function weekStart(now: Date | number | string = new Date()): string {
const today = dayNumber(calendarDay(now))
const weekday = localWeekday(today) || 7
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */ /** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date { export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant) const wall = toWallClock(instant)
+201 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract" import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import { NProgress, NText } from "naive-ui" import { NProgress, NTag, NText } from "naive-ui"
import { import {
getLearnStudents, getLearnStudents,
getLearnTutorials, getLearnTutorials,
@@ -28,6 +28,8 @@ const type = ref<"python" | "c">("python")
// 3-4 1-2 classFilter // 3-4 1-2 classFilter
const className = ref("") const className = ref("")
const tab = ref("students") const tab = ref("students")
// /
const keyword = ref("")
const loading = ref(false) const loading = ref(false)
const students = ref<LearnStudentProgress[]>([]) const students = ref<LearnStudentProgress[]>([])
@@ -44,10 +46,92 @@ const typeOptions = [
{ label: "C 语言", value: "c" }, { label: "C 语言", value: "c" },
] ]
type StudentStatus = "idle" | "stalled" | "noPractice" | "going" | "done"
const STALL_DAYS = 7
const STATUS_META: Record<
StudentStatus,
{ label: string; type: "default" | "error" | "warning" | "info" | "success" }
> = {
idle: { label: "未开始", type: "error" },
stalled: { label: `${STALL_DAYS} 天没学`, type: "warning" },
noPractice: { label: "只读不练", type: "info" },
going: { label: "进行中", type: "default" },
done: { label: "已学完", type: "success" },
}
//
// > > > >
function statusOf(row: LearnStudentProgress): StudentStatus {
if (row.readCount === 0 && row.totalSeconds === 0 && !row.exerciseTried) {
return "idle"
}
if (tutorialCount.value && row.readCount >= tutorialCount.value) return "done"
if (row.lastViewedAt) {
// time.ts
const days = (Date.now() - Date.parse(row.lastViewedAt)) / 86_400_000
if (days > STALL_DAYS) return "stalled"
}
if (exerciseCount.value && row.readCount > 0 && row.exerciseTried === 0) {
return "noPractice"
}
return "going"
}
const statusFilter = ref<StudentStatus | "all">("all")
const statusCounts = computed(() => {
const counts: Record<StudentStatus, number> = {
idle: 0,
stalled: 0,
noPractice: 0,
going: 0,
done: 0,
}
for (const row of students.value) counts[statusOf(row)]++
return counts
})
const startedCount = computed( const startedCount = computed(
() => students.value.filter((row) => row.readCount > 0).length, () => students.value.length - statusCounts.value.idle,
) )
const avgRead = computed(() =>
students.value.length
? (
students.value.reduce((n, row) => n + row.readCount, 0) /
students.value.length
).toFixed(1)
: "0",
)
// /
const solveRate = computed(() => {
const tried = students.value.reduce((n, row) => n + row.exerciseTried, 0)
const solved = students.value.reduce((n, row) => n + row.exerciseSolved, 0)
return tried ? Math.round((solved / tried) * 100) : null
})
function lastSeen(value: string | null) {
if (!value) return "-"
const days = Math.floor((Date.now() - Date.parse(value)) / 86_400_000)
const absolute = parseTime(value, "M月D日 HH:mm")
return days >= 1 ? `${absolute}${days} 天前)` : absolute
}
//
//
const filteredStudents = computed(() => {
const value = keyword.value.trim().toLowerCase()
return students.value.filter(
(row) =>
(statusFilter.value === "all" || statusOf(row) === statusFilter.value) &&
(!value ||
row.username.toLowerCase().includes(value) ||
(row.realName ?? "").toLowerCase().includes(value)),
)
})
const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{ title: "班级", key: "className", width: 90, sorter: "default" }, { title: "班级", key: "className", width: 90, sorter: "default" },
{ title: "学号", key: "username", width: 140 }, { title: "学号", key: "username", width: 140 },
@@ -57,6 +141,19 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
width: 110, width: 110,
render: (row) => row.realName || "-", render: (row) => row.realName || "-",
}, },
{
title: "状态",
key: "status",
width: 110,
render: (row) => {
const meta = STATUS_META[statusOf(row)]
return h(
NTag,
{ size: "small", type: meta.type, bordered: false },
() => meta.label,
)
},
},
{ {
title: `已读(共 ${tutorialCount.value} 课)`, title: `已读(共 ${tutorialCount.value} 课)`,
key: "readCount", key: "readCount",
@@ -114,10 +211,9 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{ {
title: "最后学习", title: "最后学习",
key: "lastViewedAt", key: "lastViewedAt",
width: 170, width: 210,
sorter: "default", sorter: "default",
render: (row) => render: (row) => lastSeen(row.lastViewedAt),
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-",
}, },
]) ])
@@ -196,6 +292,31 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
ellipsis: { tooltip: true }, ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)", render: (row) => row.question || "(无题干)",
}, },
{
//
//
title: "提示",
key: "flag",
width: 100,
render: (row) => {
if (row.triedUsers < 3) return null
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
return h(
NTag,
{ size: "small", type: "warning", bordered: false },
() => "没人一次对",
)
}
if (row.solvedUsers / row.triedUsers < 0.5) {
return h(
NTag,
{ size: "small", type: "error", bordered: false },
() => "多数人卡住",
)
}
return null
},
},
{ {
title: "做对 / 做过", title: "做对 / 做过",
key: "solvedUsers", key: "solvedUsers",
@@ -244,6 +365,7 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
async function load() { async function load() {
loading.value = true loading.value = true
expanded.value = [] expanded.value = []
statusFilter.value = "all"
const params = { type: type.value, className: className.value.trim() } const params = { type: type.value, className: className.value.trim() }
try { try {
// tab // tab
@@ -298,12 +420,85 @@ onMounted(load)
</n-text> </n-text>
</n-flex> </n-flex>
<n-grid
cols="2 s:3 m:5"
:x-gap="12"
:y-gap="12"
responsive="screen"
style="margin-bottom: 16px"
>
<n-gi>
<n-card size="small" :bordered="true">
<n-statistic label="学生" :value="studentCount" />
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="已开始" :value="startedCount">
<template #suffix>/ {{ students.length }}</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="人均已读课数" :value="avgRead">
<template #suffix>/ {{ tutorialCount }}</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic
label="练一练做对率"
:value="solveRate === null ? '-' : `${solveRate}%`"
/>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="停滞(7 天没学)" :value="statusCounts.stalled">
<template #suffix></template>
</n-statistic>
</n-card>
</n-gi>
</n-grid>
<n-tabs v-model:value="tab" type="line" animated> <n-tabs v-model:value="tab" type="line" animated>
<n-tab-pane name="students" tab="按学生"> <n-tab-pane name="students" tab="按学生">
<n-flex align="center" style="margin-bottom: 12px">
<n-input
v-model:value="keyword"
placeholder="搜索姓名或学号"
clearable
style="width: 200px"
/>
<n-text v-if="keyword.trim()" depth="3">
找到 {{ filteredStudents.length }}
</n-text>
</n-flex>
<n-flex :size="8" style="margin-bottom: 12px">
<n-tag
checkable
:checked="statusFilter === 'all'"
@update:checked="statusFilter = 'all'"
>
全部 {{ students.length }}
</n-tag>
<n-tag
v-for="(meta, key) in STATUS_META"
:key="key"
checkable
:type="meta.type"
:checked="statusFilter === key"
@update:checked="statusFilter = statusFilter === key ? 'all' : key"
>
{{ meta.label }} {{ statusCounts[key] }}
</n-tag>
</n-flex>
<n-data-table <n-data-table
:loading="loading" :loading="loading"
:columns="studentColumns" :columns="studentColumns"
:data="students" :data="filteredStudents"
:row-key="(row: LearnStudentProgress) => row.userId" :row-key="(row: LearnStudentProgress) => row.userId"
striped striped
:pagination="{ pageSize: 20 }" :pagination="{ pageSize: 20 }"
@@ -16,7 +16,7 @@ const emit = defineEmits<{
(e: "update:modelValue", value: AstRules | null): void (e: "update:modelValue", value: AstRules | null): void
}>() }>()
// C / Python3judge/ast.ts // C / Pythonjudge/ast.ts
// loadLanguage null tab // loadLanguage null tab
// C++ // C++
const supportedLanguages = computed(() => const supportedLanguages = computed(() =>
@@ -26,7 +26,7 @@ const unsupportedLanguages = computed(() =>
props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)), props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)),
) )
const activeTab = ref(supportedLanguages.value[0] || "Python3") const activeTab = ref(supportedLanguages.value[0] || "Python")
const ENGINE_OPTIONS: SelectOption[] = [ const ENGINE_OPTIONS: SelectOption[] = [
{ {
@@ -47,7 +47,7 @@ function makeInitialFiles(): FileEntry[] {
const files = ref<FileEntry[]>(makeInitialFiles()) const files = ref<FileEntry[]>(makeInitialFiles())
const selectedLanguage = ref<LANGUAGE>("Python3") const selectedLanguage = ref<LANGUAGE>("Python")
// //
const availableLanguages = computed(() => const availableLanguages = computed(() =>
+8 -8
View File
@@ -63,7 +63,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
difficulty: "Low", difficulty: "Low",
visible: false, visible: false,
tags: [], tags: [],
languages: ["Python3", "C"] as LANGUAGE[], languages: ["Python", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string }, template: {} as { [key in LANGUAGE]?: string },
samples: [ samples: [
{ input: "", output: "" }, { input: "", output: "" },
@@ -153,8 +153,8 @@ function validateNewTags(v: string[]) {
// //
const [needTemplate, toggleNeedTemplate] = useToggle(false) const [needTemplate, toggleNeedTemplate] = useToggle(false)
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES))) const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
const currentActiveTemplate = ref<LANGUAGE>("Python3") const currentActiveTemplate = ref<LANGUAGE>("Python")
const currentActiveAnswer = ref<LANGUAGE>("Python3") const currentActiveAnswer = ref<LANGUAGE>("Python")
// TextEditor // TextEditor
const [ready, toggleReady] = useToggle(false) const [ready, toggleReady] = useToggle(false)
@@ -169,7 +169,7 @@ const difficultyOptions: SelectOption[] = [
] ]
const languageOptions = [ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" }, { label: LANGUAGE_SHOW_VALUE["Python"], value: "Python" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" }, { label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" }, { label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" }, { label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
@@ -511,7 +511,7 @@ async function generateMermaid() {
isAIGenerating.value = true isAIGenerating.value = true
try { try {
const res = await generateFlowchartFromPythonCode( const res = await generateFlowchartFromPythonCode(
problem.value.answers.filter((a) => a.language === "Python3")[0].code, problem.value.answers.filter((a) => a.language === "Python")[0].code,
) )
problem.value.mermaidCode = res.flowchart problem.value.mermaidCode = res.flowchart
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法") message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
@@ -736,7 +736,7 @@ watch(
> >
<n-tabs <n-tabs
type="segment" type="segment"
default-value="Python3" default-value="Python"
v-model:value="currentActiveAnswer" v-model:value="currentActiveAnswer"
> >
<n-tab-pane <n-tab-pane
@@ -760,7 +760,7 @@ watch(
<n-form-item label="编写预制代码"> <n-form-item label="编写预制代码">
<n-tabs <n-tabs
type="segment" type="segment"
default-value="Python3" default-value="Python"
v-model:value="currentActiveTemplate" v-model:value="currentActiveTemplate"
> >
<n-tab-pane <n-tab-pane
@@ -878,7 +878,7 @@ watch(
type="primary" type="primary"
size="small" size="small"
:disabled=" :disabled="
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code !problem.answers.filter((a) => a.language === 'Python')[0]?.code
.length .length
" "
:loading="isAIGenerating" :loading="isAIGenerating"
@@ -391,7 +391,7 @@ function typeTagType(type: ExerciseType) {
{{ typeName(ex.type) }} {{ typeName(ex.type) }}
</n-tag> </n-tag>
<n-text style="margin-left: 10px"> <n-text style="margin-left: 10px">
{{ (ex.data as any).question }} {{ (ex.data as { question?: string }).question }}
</n-text> </n-text>
</div> </div>
<n-space :size="8"> <n-space :size="8">
+1 -1
View File
@@ -107,7 +107,7 @@ onMounted(init)
<n-tab-pane name="code" tab="示例代码"> <n-tab-pane name="code" tab="示例代码">
<CodeEditor <CodeEditor
v-model:value="tutorial.code" v-model:value="tutorial.code"
:language="tutorial.type === 'python' ? 'Python3' : 'C'" :language="tutorial.type === 'python' ? 'Python' : 'C'"
height="400px" height="400px"
/> />
</n-tab-pane> </n-tab-pane>
+18 -1
View File
@@ -1,5 +1,6 @@
import { import {
type AiAnalysisRecord, type AiAnalysisRecord,
type AiHintFeedbackRequest,
type Contest as OjContest, type Contest as OjContest,
type ContestAccess, type ContestAccess,
type ContestList, type ContestList,
@@ -11,6 +12,7 @@ import {
type ClassRankItem, type ClassRankItem,
type ClassUserRank, type ClassUserRank,
type UserRank, type UserRank,
type WeeklyRank,
type ProblemRank, type ProblemRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemAuthor, type ProblemAuthor,
@@ -129,7 +131,7 @@ export function submitCode(data: SubmitCodePayload) {
export function formatCode(data: { code: string; language: string }) { export function formatCode(data: { code: string; language: string }) {
const languages: Record<string, string> = { const languages: Record<string, string> = {
Python3: "python", Python: "python",
C: "c", C: "c",
"C++": "cpp", "C++": "cpp",
SQL: "sql", SQL: "sql",
@@ -209,6 +211,14 @@ export function getActivityRank(start: string) {
}) })
} }
/**
* `scope` global
* / 400
*/
export function getWeeklyRank(scope: "global" | "class") {
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
}
export function getClassRank(grade?: number | null) { export function getClassRank(grade?: number | null) {
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } }) return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
} }
@@ -371,6 +381,13 @@ export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned") return api.get<AiAnalysisRecord | null>("ai/pinned")
} }
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
export function submitHintFeedback(hintId: number, helpful: boolean) {
return api.post<null>(`ai/hint/${hintId}/feedback`, {
helpful,
} satisfies AiHintFeedbackRequest)
}
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
+7 -3
View File
@@ -26,6 +26,7 @@ import {
Legend, Legend,
Colors, Colors,
Filler, Filler,
type TooltipItem,
} from "chart.js" } from "chart.js"
// Chart.js // Chart.js
@@ -673,9 +674,12 @@ const radarChartOptions = {
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: function (context: any) { label: function (context: TooltipItem<"radar">) {
const dataset = context.dataset as any // rawData dataset chart.js
const rawValue = dataset?.rawData?.[context.dataIndex] const dataset = context.dataset as typeof context.dataset & {
rawData?: (number | null)[]
}
const rawValue = dataset.rawData?.[context.dataIndex]
const metric = context.label || "" const metric = context.label || ""
const isRate = context.dataIndex >= 3 const isRate = context.dataIndex >= 3
if (rawValue === undefined || rawValue === null) { if (rawValue === undefined || rawValue === null) {
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types"
const props = defineProps<{
titles: { id: number; title: string }[]
progress: Record<number, TutorialProgress>
traced: boolean
}>()
const stats = computed(() => {
const rows = props.titles.map((t) => props.progress[t.id])
const read = rows.filter(
(p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS,
).length
const solved = rows.reduce((n, p) => n + (p?.exerciseSolved ?? 0), 0)
const total = rows.reduce((n, p) => n + (p?.exerciseTotal ?? 0), 0)
return { read, solved, total }
})
const percent = computed(() =>
props.titles.length
? Math.round((stats.value.read / props.titles.length) * 100)
: 0,
)
</script>
<template>
<div v-if="traced && titles.length" class="summary">
<n-progress
type="line"
:percentage="percent"
:height="8"
:show-indicator="false"
status="success"
/>
<n-text depth="3" class="numbers">
已读 {{ stats.read }}/{{ titles.length }}
<template v-if="stats.total">
· 练一练 {{ stats.solved }}/{{ stats.total }}
</template>
</n-text>
</div>
</template>
<style scoped>
.summary {
padding: 4px 10px 12px;
}
.numbers {
display: block;
margin-top: 6px;
font-size: 12px;
}
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Segment } from "../composables/useExerciseParse"
defineProps<{ segments: Segment[]; lang?: string }>()
const isDark = useDark()
const ExerciseWidget = defineAsyncComponent(
() => import("./ExerciseWidget.vue"),
)
</script>
<template>
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget v-else :exercise="seg.exercise" :lang="lang" />
</template>
</template>
+101 -58
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract" import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types" import type { TutorialProgress } from "utils/types"
import { readableDuration } from "utils/functions"
defineProps<{ const props = defineProps<{
titles: { id: number; title: string }[] titles: { id: number; title: string }[]
step: number step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */ /** 按教程 id 索引的自学留痕,未登录时是空的 */
@@ -14,70 +13,114 @@ defineProps<{
const emit = defineEmits<{ select: [lesson: number] }>() const emit = defineEmits<{ select: [lesson: number] }>()
// readableDuration "-" - type Status = "todo" | "reading" | "done"
// 15 0
function readSoFar(seconds: number) { /**
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟" * 三态没打开过 / 读过但没读满或练习没做完 / 读满且练习全对
* 没有练习的课只看阅读已读的门槛沿用契约的 TUTORIAL_READ_SECONDS
*/
function statusOf(id: number): Status {
const p = props.progress[id]
if (!p?.viewCount) return "todo"
const read = p.totalSeconds >= TUTORIAL_READ_SECONDS
const practiced = !p.exerciseTotal || p.exerciseSolved >= p.exerciseTotal
return read && practiced ? "done" : "reading"
}
function hint(id: number) {
const p = props.progress[id]
if (!p?.exerciseTotal) return ""
return `练一练 ${p.exerciseSolved}/${p.exerciseTotal}`
} }
</script> </script>
<template> <template>
<n-list hoverable clickable> <ol class="lessons">
<n-list-item <li
v-for="(item, index) in titles" v-for="(item, index) in titles"
:key="item.id" :key="item.id"
class="lesson"
:class="{ active: step === index + 1 }"
@click="emit('select', index + 1)" @click="emit('select', index + 1)"
> >
<!-- 标题独占一行目录栏只有屏幕的五分之一宽已读摆在同一行会把 <span class="dot" :class="traced ? statusOf(item.id) : 'todo'">
中文标题挤成两截 --> <template v-if="traced && statusOf(item.id) === 'done'"></template>
<n-flex vertical :size="2"> <template v-else>{{ index + 1 }}</template>
<n-text </span>
:type="step === index + 1 ? 'primary' : undefined" <span class="text">
:strong="step === index + 1" <span class="title">{{ item.title }}</span>
> <span v-if="traced && hint(item.id)" class="hint">
{{ index + 1 }}. {{ item.title }} {{ hint(item.id) }}
</n-text> </span>
<!-- 每篇教程都有一条进度没读过的是一行零所以这里判的是读没读过 </span>
不是有没有这条记录 </li>
TUTORIAL_READ_SECONDS 才打 打开过但没读满的仍然显示时长 </ol>
只是不带勾也不是成功色 记是记下了还没到已读 --> <n-text v-if="!traced" depth="3" class="login-tip">
<n-text
v-if="progress[item.id]?.totalSeconds >= TUTORIAL_READ_SECONDS"
type="success"
style="font-size: 12px"
>
已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-else-if="progress[item.id]?.viewCount"
depth="3"
style="font-size: 12px"
>
读了 {{ readSoFar(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-if="progress[item.id]?.exerciseTotal"
:type="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? 'success'
: undefined
"
:depth="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? undefined
: 3
"
style="font-size: 12px"
>
练一练 {{ progress[item.id].exerciseSolved }} /
{{ progress[item.id].exerciseTotal }}
</n-text>
</n-flex>
</n-list-item>
</n-list>
<!-- 只在没登录时提一句登录了却还没读的人不需要被提醒你还没读 -->
<n-text v-if="!traced" depth="3" style="display: block; padding: 8px 4px">
登录后可以记录学习进度 登录后可以记录学习进度
</n-text> </n-text>
</template> </template>
<style scoped>
.lessons {
list-style: none;
margin: 0;
padding: 0;
}
.lesson {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s;
}
.lesson:hover {
background: rgba(128, 128, 128, 0.12);
}
.lesson.active {
background: rgba(24, 160, 88, 0.14);
}
.dot {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
border: 1.5px solid rgba(128, 128, 128, 0.5);
}
.dot.reading {
border-color: #f0a020;
color: #f0a020;
}
.dot.done {
border-color: #18a058;
background: #18a058;
color: #fff;
}
.active .dot.todo {
border-color: #18a058;
color: #18a058;
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
line-height: 1.4;
}
.active .title {
font-weight: 600;
}
.hint {
font-size: 12px;
opacity: 0.6;
}
.login-tip {
display: block;
padding: 8px 10px;
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
defineProps<{ step: number; total: number }>()
const theme = useThemeVars()
const emit = defineEmits<{ go: [lesson: number] }>()
</script>
<template>
<nav class="pager" :style="{ background: theme.bodyColor }">
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)">
上一课
</n-button>
<n-text depth="3">{{ step }} / {{ total }}</n-text>
<n-button
type="primary"
:secondary="step >= total"
:disabled="step >= total"
@click="emit('go', step + 1)"
>
下一课
</n-button>
</nav>
</template>
<style scoped>
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
margin-top: 16px;
position: sticky;
bottom: 0;
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
</style>
@@ -1,6 +1,6 @@
import type { Exercise } from "utils/types" import type { Exercise } from "utils/types"
type Segment = export type Segment =
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise } { type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
export function parseExercises( export function parseExercises(
+102 -122
View File
@@ -1,14 +1,18 @@
<template> <template>
<div class="learn-container"> <div class="learn-container">
<!-- 桌面端布局 --> <template v-if="tutorial.id">
<n-grid <!-- 桌面端目录 | 正文居中限宽 | 可收起的示例代码 -->
:cols="5" <div
:x-gap="16" v-if="isDesktop"
v-if="tutorial.id && isDesktop" class="learn-layout"
class="learn-grid" :class="{ 'with-code': codeOpen }"
> >
<n-gi :span="1" class="learn-col"> <aside class="rail">
<n-card title="教程目录" :bordered="false" size="small"> <LearnSummary
:titles="titles"
:progress="progress"
:traced="traced"
/>
<LessonList <LessonList
:titles="titles" :titles="titles"
:step="step" :step="step"
@@ -16,103 +20,60 @@
:traced="traced" :traced="traced"
@select="goToLesson" @select="goToLesson"
/> />
</n-card> </aside>
</n-gi>
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col"> <main class="reader">
<n-card <article class="reader-body">
:title="`第 ${step} 课:${titles[step - 1]?.title}`" <header class="lesson-head">
:bordered="false" <n-text depth="3"> {{ step }} / {{ titles.length }} </n-text>
size="small" <n-flex align="center" justify="space-between" :wrap="false">
> <span />
<template v-for="(seg, i) in segments" :key="i"> <n-button
<MdPreview v-if="tutorial.code"
v-if="seg.type === 'md'" size="small"
preview-theme="vuepress" secondary
:theme="isDark ? 'dark' : 'light'" @click="codeOpen = !codeOpen"
:model-value="seg.content" >
/> {{ codeOpen ? "收起示例代码" : "展开示例代码" }}
<ExerciseWidget </n-button>
v-else </n-flex>
:exercise="seg.exercise" </header>
:lang="tutorial.type" <LessonBody :segments="segments" :lang="tutorial.type" />
/> </article>
</template> <PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</n-card> </main>
</n-gi>
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code"> <aside v-if="tutorial.code && codeOpen" class="code-panel">
<n-card
title="示例代码"
:bordered="false"
size="small"
class="code-card"
content-style="height: calc(100% - 44px); padding: 0;"
>
<CodeEditor <CodeEditor
:language="editorLanguage" :language="editorLanguage"
v-model="tutorial.code" v-model="tutorial.code"
height="100%" height="100%"
/> />
</n-card> </aside>
</n-gi> </div>
</n-grid>
<!-- 手机端布局 --> <!-- 手机端 -->
<template v-if="tutorial.id && !isDesktop"> <template v-else>
<n-tabs type="line" animated v-model:value="activeTab"> <LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<n-tab-pane name="catalog" tab="目录"> <n-tabs type="line" animated v-model:value="activeTab">
<LessonList <n-tab-pane name="catalog" tab="目录">
:titles="titles" <LessonList
:step="step" :titles="titles"
:progress="progress" :step="step"
:traced="traced" :progress="progress"
@select="goToLesson" :traced="traced"
/> @select="goToLesson"
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/> />
<ExerciseWidget </n-tab-pane>
v-else <n-tab-pane name="content" :tab="`第 ${step} 课`">
:exercise="seg.exercise" <LessonBody :segments="segments" :lang="tutorial.type" />
:lang="tutorial.type" </n-tab-pane>
/> <n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
</template> <CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane> </n-tab-pane>
</n-tabs>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code"> <PagerBar :step="step" :total="titles.length" @go="goToLesson" />
<CodeEditor :language="editorLanguage" v-model="tutorial.code" /> </template>
</n-tab-pane>
</n-tabs>
<n-divider style="margin: 12px 0" />
<n-flex align="center" justify="space-between">
<n-button
secondary
type="primary"
:disabled="isFirstLesson"
@click="goToPrevLesson"
>
上一课
</n-button>
<n-text>{{ step }} / {{ titles.length }}</n-text>
<n-button
secondary
type="primary"
:disabled="isLastLesson"
@click="goToNextLesson"
>
下一课
</n-button>
</n-flex>
</template> </template>
<n-empty <n-empty
@@ -124,8 +85,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { import type {
Tutorial, Tutorial,
Exercise, Exercise,
@@ -144,15 +103,13 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress" import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue" import LessonList from "./components/LessonList.vue"
import LearnSummary from "./components/LearnSummary.vue"
const ExerciseWidget = defineAsyncComponent( import LessonBody from "./components/LessonBody.vue"
() => import("./components/ExerciseWidget.vue"), import PagerBar from "./components/PagerBar.vue"
)
const CodeEditor = defineAsyncComponent( const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"), () => import("shared/components/CodeEditor.vue"),
) )
const isDark = useDark()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -180,12 +137,14 @@ const tutorial = ref<Partial<Tutorial>>({
}) })
const editorLanguage = computed<LANGUAGE>(() => const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3", tutorial.value.type === "c" ? "C" : "Python",
) )
const titles = ref<{ id: number; title: string }[]>([]) const titles = ref<{ id: number; title: string }[]>([])
const progress = ref<Record<number, TutorialProgress>>({}) const progress = ref<Record<number, TutorialProgress>>({})
const exercises = ref<Exercise[]>([]) const exercises = ref<Exercise[]>([])
const activeTab = ref("content") const activeTab = ref("content")
//
const codeOpen = useStorage("oj2:learn-code-open", true)
const isEmpty = ref(false) const isEmpty = ref(false)
const segments = computed(() => const segments = computed(() =>
@@ -198,22 +157,12 @@ useLearnTrace(
traced, traced,
) )
const isFirstLesson = computed(() => step.value === 1)
const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) { function goToLesson(lessonNumber: number) {
activeTab.value = "content" activeTab.value = "content"
router.push( router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`, `/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
) )
} }
function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1)
}
function goToNextLesson() {
if (step.value < titles.value.length) goToLesson(step.value + 1)
}
/** /**
* 拉自己的自学留痕给目录打勾失败就当没有 目录少几个勾不影响上课 * 拉自己的自学留痕给目录打勾失败就当没有 目录少几个勾不影响上课
* 但弹个错会把我是不是没学的焦虑塞给学生 * 但弹个错会把我是不是没学的焦虑塞给学生
@@ -263,27 +212,58 @@ watch(traced, loadProgress)
</script> </script>
<style scoped> <style scoped>
/* 桌面端固定高度,目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */ /* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
@media (min-width: 769px) { @media (min-width: 769px) {
.learn-container { .learn-container {
height: calc(100vh - 138px); height: calc(100vh - 138px);
} }
} }
.learn-grid { .learn-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 24px;
height: 100%; height: 100%;
} }
.learn-layout.with-code {
grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 40%);
}
.learn-col { .rail,
.reader {
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
} }
.reader {
.learn-col--code { display: flex;
overflow-y: hidden; flex-direction: column;
}
.reader-body {
flex: 1;
width: 100%;
max-width: 820px;
margin: 0 auto;
}
.reader :deep(.pager) {
max-width: 820px;
width: 100%;
margin-left: auto;
margin-right: auto;
} }
.code-card { .lesson-head h1,
.mobile-title {
margin: 4px 0 12px;
font-size: 26px;
line-height: 1.3;
}
.mobile-title {
font-size: 20px;
}
.code-panel {
height: 100%; height: 100%;
overflow: hidden;
border-radius: 8px;
} }
</style> </style>
@@ -7,6 +7,7 @@ import CodeEditor from "shared/components/CodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue" import Form from "./Form.vue"
const route = useRoute() const route = useRoute()
@@ -34,6 +35,10 @@ onMounted(() => {
problem.value!.template[codeStore.code.language] || problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language], SOURCES[codeStore.code.language],
) )
beginEditTrace(
`problem_${problem.value!._id}_contest_${contestID}`,
codeStore.code.value.length,
)
}) })
const changeCode = (v: string) => { const changeCode = (v: string) => {
@@ -58,6 +63,7 @@ const changeLanguage = (v: LANGUAGE) => {
v-model:value="codeStore.code.value" v-model:value="codeStore.code.value"
:language="codeStore.code.language" :language="codeStore.code.language"
:height="editorHeight" :height="editorHeight"
:extra-extensions="editTraceExtensions"
@update:model-value="changeCode" @update:model-value="changeCode"
/> />
</n-flex> </n-flex>
+51 -7
View File
@@ -64,6 +64,20 @@ const showHelpButton = computed(
!isContestMode.value, !isContestMode.value,
) )
/**
* 教师端协作就开在这道题上接单后直接跳到题目页协作原来是 CollabModal 弹框
* 所以状态和结束协作得摆在题目页的工具栏上 也只有这一个按钮能结束
* 不像弹框那样按一下 Esc 就把协作关掉了
*/
const collabHere = computed(
() =>
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id,
)
const showCollabBar = computed(
() => collabHere.value && userStore.isTeacherOrAbove,
)
/** /**
* 状态全塞进按钮本身原来旁边还挂一个 n-tag 说明排队情况一行工具栏 * 状态全塞进按钮本身原来旁边还挂一个 n-tag 说明排队情况一行工具栏
* 语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助 1280 的机房屏上放不下 * 语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助 1280 的机房屏上放不下
@@ -134,10 +148,14 @@ const menuOptions = computed<DropdownOption[]>(() => {
label: "复制代码", label: "复制代码",
key: "copy", key: "copy",
}) })
options.push({ // **** v-model
label: "重置代码", // Yjs
key: "reset", if (!showCollabBar.value) {
}) options.push({
label: "重置代码",
key: "reset",
})
}
} }
if (isDesktop.value && userStore.isSuperAdmin) { if (isDesktop.value && userStore.isSuperAdmin) {
options.push({ options.push({
@@ -216,7 +234,15 @@ const goTestCat = () => {
const goSubmissions = () => { const goSubmissions = () => {
const name = route.params.contestID ? "contest submissions" : "submissions" const name = route.params.contestID ? "contest submissions" : "submissions"
router.push({ name, query: { problem: problem.value!._id } }) const target = { name, query: { problem: problem.value!._id } }
//
// 退
//
if (showCollabBar.value) {
window.open(router.resolve(target).href, "_blank")
return
}
router.push(target)
} }
const goEdit = () => { const goEdit = () => {
@@ -228,19 +254,21 @@ const goEdit = () => {
onMounted(() => { onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) { if (!languages.value.includes(codeStore.code.language)) {
// 退 SQL "SQL" Python3 // 退 SQL "SQL" Python
codeStore.code.language = languages.value[0] ?? "Python3" codeStore.code.language = languages.value[0] ?? "Python"
} }
}) })
</script> </script>
<template> <template>
<n-flex align="center"> <n-flex align="center">
<!-- 协作中编辑器的语言跟着学生走这个选择器改了也不会生效索性禁掉 -->
<n-select <n-select
v-model:value="codeStore.code.language" v-model:value="codeStore.code.language"
style="width: 120px" style="width: 120px"
:size="buttonSize" :size="buttonSize"
:options="languageOptions" :options="languageOptions"
:disabled="showCollabBar"
@update:value="changeLanguage" @update:value="changeLanguage"
/> />
@@ -274,6 +302,22 @@ onMounted(() => {
<n-button :size="buttonSize">更多操作</n-button> <n-button :size="buttonSize">更多操作</n-button>
</n-dropdown> </n-dropdown>
<template v-if="showCollabBar">
<n-tag type="success" :size="buttonSize">
正在帮 {{ collabStore.room!.peerName }} ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room!.language] }}
</n-tag>
<!-- 显式的结束求助记录一并清掉跳走页面发的是 leave("left")
那边只是退回排队 store leave 的注释 -->
<n-button
:size="buttonSize"
type="primary"
@click="collabStore.leave('done')"
>
结束协作
</n-button>
</template>
<n-button <n-button
v-if="showHelpButton" v-if="showHelpButton"
:size="buttonSize" :size="buttonSize"
@@ -2,11 +2,13 @@
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { SOURCES } from "utils/constants" import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue" import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue" import Form from "./Form.vue"
const FlowchartEditor = defineAsyncComponent( const FlowchartEditor = defineAsyncComponent(
@@ -18,8 +20,66 @@ const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore() const codeStore = useCodeStore()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
/**
* 课堂求助的协作就开在这道题上
*
* 教师端原来是一个独立的弹框CollabModal里面第二个 CodeMirror现在接单
* 直接跳到题目页就在页面这一个编辑器里协作 顺带治好了按一下 Esc 弹框就关
* 协作跟着结束页面上没有弹框可关结束协作只有工具栏那个按钮和离开这一页两条路
*/
const collabHere = computed(
() =>
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id,
)
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
const teacherCollab = computed(() => collabHere.value && collabStore.isTeacher)
/** 教师接单前自己选的语言,协作结束后连同草稿一起还原 */
let teacherLanguageBefore: LANGUAGE | null = null
/**
* 协作中**教师的语言选择跟着学生走**选择器同时禁用 Form.vue学生中途切了
* 还会再同步一次
*
* 写的是 codeStore 而不是只改编辑器的高亮提交语法检查去自测猫读的都是
* `codeStore.code.language` 只改高亮的话老师会拿着自己那档语言提交学生的代码
* 学生写 C老师选的是 Python当场 CE工具栏还可能显示提交流程图
*
* 直接写 store 不会连带重载模板代码那是 Form 里选择器的 `update:value` 才做的事
*
* **学生端一个字都不动**早退出他的语言本来就是权威那一份 服务端的
* `room_language` 只发给教师学生本地那份 `room.language` 停在建房那一刻
* 拿它当依据的话学生一切语言就卡在旧的那套上
*
* `immediate` 是必须的老师从别的题跳过来时房间早就开着了非立即的 watch
* 在挂载这一轮压根不会触发
*/
watch(
() => [collabHere.value, collabStore.room?.language] as const,
([here, language], previous) => {
if (!collabStore.isTeacher) return
const wasHere = previous?.[0] ?? false
if (here && language) {
if (!wasHere) teacherLanguageBefore = codeStore.code.language
if (codeStore.code.language !== language)
codeStore.code.language = language
return
}
if (!here && wasHere) {
if (teacherLanguageBefore) codeStore.code.language = teacherLanguageBefore
teacherLanguageBefore = null
// 稿 persistCode storage
loadCode()
}
},
{ immediate: true },
)
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const contestID = route.params.contestID || null const contestID = route.params.contestID || null
@@ -39,23 +99,30 @@ function loadCode() {
problem.value!.template[codeStore.code.language] || problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language], SOURCES[codeStore.code.language],
) )
// loadCode稿
beginEditTrace(
`problem_${problem.value!._id}_contest_${contestID}`,
codeStore.code.value.length,
)
} }
onMounted(loadCode) onMounted(loadCode)
watch(() => problem.value?._id, loadCode) watch(() => problem.value?._id, loadCode)
watch( /**
() => codeStore.code.value, * 存本地草稿**协作中的教师不存** 那会儿编辑器里是学生的代码存下去就把
(v) => { * 老师自己在这道题上的草稿盖掉了他可能正开着自己的解法学生照存那是他本人的
storage.set(storageKey.value, v) */
}, const persistCode = (v: string) => {
) if (teacherCollab.value) return
const changeCode = (v: string) => {
storage.set(storageKey.value, v) storage.set(storageKey.value, v)
} }
watch(() => codeStore.code.value, persistCode)
const changeCode = persistCode
const changeLanguage = (v: LANGUAGE) => { const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value) const savedCode = storage.get(storageKey.value)
codeStore.setCode( codeStore.setCode(
@@ -73,6 +140,13 @@ provide("flowchartEditorRef", flowchartEditorRef)
<template> <template>
<n-flex vertical> <n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" /> <Form :storage-key="storageKey" @change-language="changeLanguage" />
<!--
协作中教师这边不会落到流程图分支上面那个 watch 已经把他的语言换成了学生的
而求助入口本身就排掉了流程图Form.vue showHelpButton服务端的
COLLAB_LANGUAGES所以 room.language 不可能是 Flowchart
学生自己切到流程图就是不写代码了编辑器卸载协作正常结束SyncCodeEditor
detach这是原来就有的语义
-->
<FlowchartEditor <FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'" v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef" ref="flowchartEditorRef"
@@ -81,7 +155,9 @@ provide("flowchartEditorRef", flowchartEditorRef)
v-else v-else
v-model:value="codeStore.code.value" v-model:value="codeStore.code.value"
:language="codeStore.code.language" :language="codeStore.code.language"
:problem-id="problem!._id"
:height="editorHeight" :height="editorHeight"
:extra-extensions="editTraceExtensions"
@update:model-value="changeCode" @update:model-value="changeCode"
/> />
</n-flex> </n-flex>
@@ -298,7 +298,7 @@ watch(query, listSubmissions)
<n-tag <n-tag
v-for="item in statusDistribution" v-for="item in statusDistribution"
:key="item.result" :key="item.result"
:type="item.type as any" :type="item.type"
size="small" size="small"
round round
> >
@@ -15,6 +15,7 @@ import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { aiStreamError, consumeJSONEventStream } from "utils/stream" import { aiStreamError, consumeJSONEventStream } from "utils/stream"
import { submitHintFeedback } from "oj/api"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
@@ -31,6 +32,10 @@ const theme = useThemeVars()
const hintContent = ref("") const hintContent = ref("")
const hintLoading = ref(false) const hintLoading = ref(false)
const hintError = ref("") const hintError = ref("")
// ai_hint id done
const hintId = ref<number | null>(null)
const hintHelpful = ref<boolean | null>(null)
const hintFeedbackSending = ref(false)
// //
const msg = computed(() => { const msg = computed(() => {
@@ -95,6 +100,8 @@ watch(
hintContent.value = "" hintContent.value = ""
hintError.value = "" hintError.value = ""
hintLoading.value = false hintLoading.value = false
hintId.value = null
hintHelpful.value = null
}, },
) )
@@ -102,6 +109,8 @@ async function fetchHint(submissionId: string) {
hintLoading.value = true hintLoading.value = true
hintContent.value = "" hintContent.value = ""
hintError.value = "" hintError.value = ""
hintId.value = null
hintHelpful.value = null
try { try {
const response = await fetch("/api/ai/hint", { const response = await fetch("/api/ai/hint", {
@@ -117,9 +126,12 @@ async function fetchHint(submissionId: string) {
type: string type: string
content?: string content?: string
message?: string message?: string
hintId?: number
}) => { }) => {
if (data.type === "delta" && data.content) { if (data.type === "delta" && data.content) {
hintContent.value += data.content hintContent.value += data.content
} else if (data.type === "done") {
hintId.value = data.hintId ?? null
} else if (data.type === "error") { } else if (data.type === "error") {
hintError.value = data.message || "AI 提示生成失败" hintError.value = data.message || "AI 提示生成失败"
} }
@@ -132,6 +144,22 @@ async function fetchHint(submissionId: string) {
} }
} }
//
//
async function sendHintFeedback(helpful: boolean) {
if (hintId.value === null || hintFeedbackSending.value) return
if (hintHelpful.value === helpful) return
hintFeedbackSending.value = true
try {
await submitHintFeedback(hintId.value, helpful)
hintHelpful.value = helpful
} catch {
//
} finally {
hintFeedbackSending.value = false
}
}
// //
const infoTable = computed(() => { const infoTable = computed(() => {
const submission = props.submission const submission = props.submission
@@ -254,6 +282,30 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
preview-theme="vuepress" preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
<n-flex
v-if="hintId !== null && !hintLoading"
align="center"
size="small"
style="margin-top: 8px"
>
<n-text depth="3">这条提示对你有帮助吗</n-text>
<n-button
size="tiny"
:type="hintHelpful === true ? 'primary' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(true)"
>
有帮助
</n-button>
<n-button
size="tiny"
:type="hintHelpful === false ? 'warning' : 'default'"
:disabled="hintFeedbackSending"
@click="sendHintFeedback(false)"
>
没帮助
</n-button>
</n-flex>
</n-card> </n-card>
</template> </template>
</div> </div>
@@ -12,6 +12,8 @@ import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState" import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { useCollabStore } from "shared/store/collab"
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
import { import {
checkPythonSyntax, checkPythonSyntax,
prefetchPythonSyntaxChecker, prefetchPythonSyntaxChecker,
@@ -24,6 +26,7 @@ const ProblemReaction = defineAsyncComponent(
// ==================== ==================== // ==================== ====================
const userStore = useUserStore() const userStore = useUserStore()
const collabStore = useCollabStore()
const codeStore = useCodeStore() const codeStore = useCodeStore()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
@@ -53,11 +56,11 @@ const isFormatting = ref(false)
const isSubmittingRequest = ref(false) const isSubmittingRequest = ref(false)
// ==================== Python ==================== // ==================== Python ====================
// Python3 Skulpt // Python Skulpt
watch( watch(
() => codeStore.code.language, () => codeStore.code.language,
(language) => { (language) => {
if (language === "Python3") prefetchPythonSyntaxChecker() if (language === "Python") prefetchPythonSyntaxChecker()
}, },
{ immediate: true }, { immediate: true },
) )
@@ -111,8 +114,8 @@ const buttonState = computed(() =>
async function submit() { async function submit() {
if (buttonState.value.disabled) return if (buttonState.value.disabled) return
// 0. Python3 // 0. Python
if (codeStore.code.language === "Python3") { if (codeStore.code.language === "Python") {
const syntaxError = await checkPythonSyntax(codeStore.code.value) const syntaxError = await checkPythonSyntax(codeStore.code.value)
if (syntaxError) { if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`) message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
@@ -120,7 +123,7 @@ async function submit() {
} }
} }
// 0.5 Python3 ruffC/C++ clang-formatSQL sqlparse // 0.5 Python ruffC/C++ clang-formatSQL sqlparse
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language] const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
if (["python", "c", "cpp", "sql"].includes(formatLang)) { if (["python", "c", "cpp", "sql"].includes(formatLang)) {
isFormatting.value = true isFormatting.value = true
@@ -132,7 +135,7 @@ async function submit() {
codeStore.setCode(res.code) codeStore.setCode(res.code)
} catch (e: any) { } catch (e: any) {
if (e?.error === "format-error") { if (e?.error === "format-error") {
// Python3 // Python
message.warning(`代码格式化失败:${e.data},请检查代码后重试`) message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
return return
} }
@@ -147,6 +150,11 @@ async function submit() {
problemId: problem.value!.id, problemId: problem.value!.id,
language: codeStore.code.language, language: codeStore.code.language,
code: codeStore.code.value, code: codeStore.code.value,
// utils/editTrace.ts ProblemEditor collabHere
trace: snapshotEditTrace(
collabStore.room !== null &&
collabStore.room.problemId === problem.value!._id,
),
} }
if (contestID) { if (contestID) {
data.contestId = parseInt(contestID) data.contestId = parseInt(contestID)
@@ -161,6 +169,8 @@ async function submit() {
try { try {
const res = await submitCode(data) const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`) console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
// /
restartEditTrace(codeStore.code.value.length)
// 3. + // 3. +
startCooldown() startCooldown()
+139
View File
@@ -0,0 +1,139 @@
import { EditorView } from "@codemirror/view"
import type { SubmissionTrace } from "@oj2/contract"
/**
* `submission_trace`
* `submissionTraceSchema`****
*
* Pinia storeProblemEditor / ContestEditor
* Form SubmitCode CodeMirror
* store facet
*
*
* ** userEvent **
* - `codeStore.setCode()` vue-codemirror setDoc dispatch changes
* 稿 /
* - y-codemirror.next ySyncAnnotation
*
* / `move.drop`
*/
/** 两次编辑间隔超过这个就算走开了,中间这段不计入活跃时长 */
const IDLE_MS = 60_000
let key: string | null = null
let startedAt = 0
let lastEditAt: number | null = null
let activeMs = 0
let typedChars = 0
let pastedChars = 0
let pasteCount = 0
let maxPaste = 0
let deletedChars = 0
let blurCount = 0
let initialLen = 0
function reset(len: number) {
startedAt = performance.now()
lastEditAt = null
activeMs = 0
typedChars = 0
pastedChars = 0
pasteCount = 0
maxPaste = 0
deletedChars = 0
blurCount = 0
initialLen = len
}
/**
*
*
* `traceKey` ****
*
* key
*/
export function beginEditTrace(traceKey: string, len: number) {
if (traceKey === key) return
key = traceKey
reset(len)
}
/** 这一段的快照,附在提交请求上 */
export function snapshotEditTrace(collab: boolean): SubmissionTrace {
return {
activeMs: Math.round(activeMs),
sinceOpenMs: Math.round(performance.now() - startedAt),
typedChars,
pastedChars,
pasteCount,
maxPaste,
deletedChars,
blurCount,
initialLen,
collab,
}
}
/** 提交成功之后调:下一条提交只记从这里往后的那一段 */
export function restartEditTrace(len: number) {
reset(len)
}
function touch() {
const now = performance.now()
if (lastEditAt !== null && now - lastEditAt <= IDLE_MS)
activeMs += now - lastEditAt
lastEditAt = now
}
// 只数 hidden 这一个事件:切标签页时 window 的 blur 和 visibilitychange 会一起触发,
// 两个都数就是一次记两下。代价是同屏切到别的窗口(页面仍可见)不计,这本来就只是辅助信号。
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") return
blurCount++
// 切走的这段不算活跃,回来之后的第一下编辑重新起算
lastEditAt = null
})
/** 挂到题目页的代码编辑器上。同一个实例,别每次渲染新建 —— 那会让编辑器反复重配扩展 */
export const editTraceExtensions = [
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
for (const tr of update.transactions) {
if (!tr.docChanged) continue
// 顺序要紧:isUserEvent("input") 也会匹配 "input.paste"
const pasted =
tr.isUserEvent("input.paste") || tr.isUserEvent("input.drop")
const typed = !pasted && tr.isUserEvent("input")
const deleted = tr.isUserEvent("delete")
if (!pasted && !typed && !deleted) continue
let inserted = 0
let removed = 0
tr.changes.iterChanges((fromA, toA, _fromB, _toB, text) => {
// 原样替换不算:closeBrackets 越过已有的右括号 / 引号时,是把 `)` 替换成 `)`
// 而不是只挪光标(@codemirror/autocomplete 的 handleClose),不排掉的话
// 每敲一个右括号就多记一个键入加一个删除
if (
toA - fromA === text.length &&
tr.startState.sliceDoc(fromA, toA) === text.toString()
)
return
removed += toA - fromA
inserted += text.length
})
// 选中一段再打字 / 粘贴,被替换掉的那部分也算删除
deletedChars += removed
if (pasted) {
pastedChars += inserted
pasteCount++
if (inserted > maxPaste) maxPaste = inserted
} else if (typed) {
typedChars += inserted
}
touch()
}
}),
]
@@ -5,7 +5,7 @@ export interface PythonSyntaxError {
let skulptPromise: Promise<any> | null = null let skulptPromise: Promise<any> | null = null
/** /**
* Skulpt 233KB gzip Python3 * Skulpt 233KB gzip Python
* *
*/ */
function loadSkulpt(): Promise<any> { function loadSkulpt(): Promise<any> {
+199 -36
View File
@@ -5,6 +5,7 @@ import type {
ClassUserRank, ClassUserRank,
MyRank, MyRank,
Rank, Rank,
WeeklyRankItem,
} from "utils/types" } from "utils/types"
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { NButton, NFlex } from "naive-ui" import { NButton, NFlex } from "naive-ui"
@@ -15,9 +16,10 @@ import {
getRank, getRank,
getUserClassRank, getUserClassRank,
getClassPK, getClassPK,
getWeeklyRank,
} from "oj/api" } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { durationFromValue, getACRate } from "utils/functions" import { durationFromValue, getACRate, parseTime } from "utils/functions"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants" import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
@@ -72,6 +74,30 @@ const myClassQuery = reactive({
limit: 10, limit: 10,
}) })
/**
* 本周进步榜默认落在**本班** 全服榜上中位学生仍然看不到自己班内 30 个人的
* 周榜才是我这周排第几有答案的那张没有班级教师超管没入班的账号才退回全服
*/
const weeklyScope = ref<"global" | "class">("global")
const weeklyData = ref<WeeklyRankItem[]>([])
const weeklyMe = ref<WeeklyRankItem | null>(null)
const weeklyTotal = ref(0)
const weeklyStart = ref("")
/**
* 我入不入这张榜教师/超管本来就不参与排名服务端 me 恒为 null未登录同理
* 这两种情况下 footer 那句做出 1 题就能上榜是说给不相干的人听的不该出现
*/
const weeklyMeEligible = computed(
() => userStore.isAuthed && !userStore.isTeacherOrAbove,
)
/** 我在榜面之外(或本周还没做出题)—— 榜上高亮不到我,footer 另起一行 */
const weeklyMeOffBoard = computed(
() =>
weeklyMeEligible.value &&
(!weeklyMe.value ||
!weeklyData.value.some((row) => row.rank === weeklyMe.value!.rank)),
)
const showClassDetailModal = ref(false) const showClassDetailModal = ref(false)
const classDetailData = ref<ClassComparison | null>(null) const classDetailData = ref<ClassComparison | null>(null)
const classDetailLoading = ref(false) const classDetailLoading = ref(false)
@@ -291,6 +317,58 @@ const subOptions = computed<Duration>(
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!, durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
) )
//
// 1280
const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
{
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
key: "rank",
width: 100,
align: "center",
// rank Index 0
render: (row) => h(Index, { index: row.rank - 1, page: 1, limit: 10 }),
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
minWidth: 120,
ellipsis: { tooltip: true },
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{
title: renderTableTitle("新解决", "fluent-emoji:party-popper"),
key: "solvedCount",
width: 100,
align: "center",
},
{
title: renderTableTitle("提交", "streamline-emojis:rocket"),
key: "submissionCount",
width: 90,
align: "center",
},
]
async function initWeeklyRank() {
if (!userStore.user) await userStore.getMyProfile()
// watch
// listWeeklyRank
if (userStore.user?.className) weeklyScope.value = "class"
else await listWeeklyRank()
}
onMounted(() => { onMounted(() => {
// Top10 10 init() offset=0&limit=10 // Top10 10 init() offset=0&limit=10
// /rankings/users // /rankings/users
@@ -300,6 +378,7 @@ onMounted(() => {
listActivity() listActivity()
listClassRank() listClassRank()
listMyClassRank() listMyClassRank()
initWeeklyRank()
}) })
const classColumns: DataTableColumn<ClassRank>[] = [ const classColumns: DataTableColumn<ClassRank>[] = [
@@ -469,6 +548,20 @@ async function listMyClassRank() {
} }
} }
async function listWeeklyRank() {
try {
const res = await getWeeklyRank(weeklyScope.value)
weeklyData.value = res.results
weeklyMe.value = res.me
weeklyTotal.value = res.total
weeklyStart.value = res.start
} catch (err: any) {
console.error(err)
}
}
watch(weeklyScope, listWeeklyRank)
watch( watch(
() => classQuery.grade, () => classQuery.grade,
() => { () => {
@@ -501,7 +594,7 @@ watch(
<n-flex vertical size="large"> <n-flex vertical size="large">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20"> <n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1"> <n-gi :span="1">
<n-card> <n-card :bordered="false">
<template #header> <template #header>
<div style="height: 34px">全服 Top10</div> <div style="height: 34px">全服 Top10</div>
</template> </template>
@@ -514,7 +607,7 @@ watch(
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi :span="1"> <n-gi :span="1">
<n-card> <n-card :bordered="false">
<template #header>日活 Top10</template> <template #header>日活 Top10</template>
<template #header-extra> <template #header-extra>
<n-select <n-select
@@ -532,41 +625,111 @@ watch(
</n-card> </n-card>
</n-gi> </n-gi>
</n-grid> </n-grid>
<n-card> <!--
<template #header>全服 Top100</template> 两张榜并排左边全服总榜分母是历史全部 AC名次几乎不动右边本周进步榜
<template #header-extra> 分母只有这一周同一屏里对照着看追不上这周还能进前十是一眼的事
<n-tag v-if="onlineCount > 0" round :bordered="false" type="success"> -->
当前在线 {{ onlineCount }} <n-grid :cols="isDesktop ? 3 : 1" :x-gap="20" :y-gap="20">
</n-tag> <n-gi :span="isDesktop ? 2 : 1">
</template> <n-card :bordered="false">
<n-data-table <template #header>全服 Top100</template>
:data="data" <template #header-extra>
:columns="columns" <n-tag
:row-class-name="rowClassName" v-if="onlineCount > 0"
/> round
<template #footer> :bordered="false"
<n-flex align="center" justify="space-between" :wrap="false"> type="success"
<!-- 100 名之外的学生榜上找不到自己这里单独给一行 --> >
<n-tag v-if="meOffBoard" type="info" round :bordered="false"> 当前在线 {{ onlineCount }}
<template #icon> </n-tag>
<Icon width="18" icon="fluent-emoji:person-raising-hand" /> </template>
</template> <n-data-table
我的排名 {{ me!.rank }} · 已解决 {{ me!.acceptedNumber }} · :data="data"
提交 {{ me!.submissionNumber }} · 正确率 :columns="columns"
{{ getACRate(me!.acceptedNumber, me!.submissionNumber) }} :row-class-name="rowClassName"
</n-tag>
<span v-else />
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/> />
</n-flex> <template #footer>
</template> <n-flex align="center" justify="space-between" :wrap="false">
</n-card> <!-- 100 名之外的学生榜上找不到自己这里单独给一行 -->
<n-tag v-if="meOffBoard" type="info" round :bordered="false">
<template #icon>
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
</template>
我的排名 {{ me!.rank }} · 已解决
{{ me!.acceptedNumber }} · 提交 {{ me!.submissionNumber }} ·
正确率
{{ getACRate(me!.acceptedNumber, me!.submissionNumber) }}
</n-tag>
<span v-else />
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</n-flex>
</template>
</n-card>
</n-gi>
<n-gi :span="1">
<n-card :bordered="false">
<template #header>
<n-flex align="center" :size="8">
<span>本周进步榜</span>
<n-text depth="3" style="font-size: 13px">
{{ weeklyStart ? parseTime(weeklyStart, "M月D日") + "起" : "" }}
· 每周一清零
</n-text>
</n-flex>
</template>
<template #header-extra>
<n-select
v-if="userStore.user?.className"
style="width: 140px"
:options="[
{ label: '本班', value: 'class' },
{ label: '全服', value: 'global' },
]"
v-model:value="weeklyScope"
/>
</template>
<n-data-table
v-if="weeklyData.length"
:data="weeklyData"
:columns="weeklyColumns"
:row-class-name="
(row: WeeklyRankItem) =>
weeklyMe && row.rank === weeklyMe.rank ? 'me-row' : ''
"
/>
<n-empty
v-else
style="padding: 20px 0"
description="这周还没有人解决新题目 —— 现在做出一题就是第一名"
/>
<!--
本周一题没做出来时 weeklyMe null这一行照样要出现它是这张榜对
还没上榜的人说的话而那恰好是最需要被推一把的那批学生
-->
<template #footer v-if="weeklyMeOffBoard">
<n-tag type="info" round :bordered="false">
<template #icon>
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
</template>
<template v-if="weeklyMe">
我这周第 {{ weeklyMe.rank }} {{ weeklyTotal }} 人上榜·
新解决 {{ weeklyMe.solvedCount }}
</template>
<template v-else>
我这周还没有解决新题目做出 1 题就能上榜
</template>
</n-tag>
</template>
</n-card>
</n-gi>
</n-grid>
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20"> <n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1"> <n-gi :span="1">
<n-card> <n-card :bordered="false">
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
<span>班级排名</span> <span>班级排名</span>
@@ -593,7 +756,7 @@ watch(
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi :span="1"> <n-gi :span="1">
<n-card> <n-card :bordered="false">
<template #header>我在班级的排名</template> <template #header>我在班级的排名</template>
<template #header-extra> <template #header-extra>
<n-select <n-select
+4 -1
View File
@@ -1,3 +1,4 @@
import { normalizeLanguage } from "@oj2/contract"
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
@@ -11,7 +12,9 @@ export const useCodeStore = defineStore("code", () => {
// ==================== 状态 ==================== // ==================== 状态 ====================
const code = reactive<Code>({ const code = reactive<Code>({
value: "", value: "",
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3", // 过一道 normalizeLanguage:上线那一刻学生浏览器的 localStorage 里存的还是
// 旧值 Python3,直接拿来用会被后端的契约挡掉(而且报错看不出是这个原因)
language: normalizeLanguage(storage.get(STORAGE_KEY.LANGUAGE)) ?? "Python",
}) })
const input = ref("") const input = ref("")
+90 -2
View File
@@ -147,7 +147,7 @@ const gradeOptions: SelectOption[] = [
const languageOptions: SelectOption[] = [ const languageOptions: SelectOption[] = [
{ label: "流程图", value: "Flowchart" }, { label: "流程图", value: "Flowchart" },
{ label: "全部语言", value: "" }, { label: "全部语言", value: "" },
{ label: "Python", value: "Python3" }, { label: "Python", value: "Python" },
{ label: "C语言", value: "C" }, { label: "C语言", value: "C" },
{ label: "C++", value: "C++" }, { label: "C++", value: "C++" },
] ]
@@ -183,6 +183,7 @@ async function listSubmissions() {
} finally { } finally {
loading.value = false loading.value = false
} }
consumePendingJump()
} }
async function getTodayCount() { async function getTodayCount() {
@@ -258,6 +259,73 @@ function showCodePanel(id: string, problem: string) {
problemDisplayID.value = problem problemDisplayID.value = problem
} }
/**
* 代码详情的键盘翻阅老师看一个班的提交时原来是点开 关掉 再点下一行
* 这里让弹框开着就能上下切换走到本页头尾自动翻页续上
*
* 只在**能看代码**的行之间走 showLink 是后端按题单规则算出来的
* 跳到一条看不了的上面只会得到一个空弹框
*/
const viewableSubmissions = computed(() =>
submissions.value.filter((row) => row.showLink),
)
const currentCodeIndex = computed(() =>
viewableSubmissions.value.findIndex((row) => row.id === submissionID.value),
)
const maxPage = computed(() => Math.ceil(total.value / query.limit))
//
let pendingJump: "first" | "last" | null = null
function consumePendingJump() {
const jump = pendingJump
pendingJump = null
if (!jump || !codePanel.value) return
const list = viewableSubmissions.value
const row = jump === "first" ? list[0] : list[list.length - 1]
//
if (!row) {
message.info("这一页没有可以查看的代码")
return
}
showCodePanel(row.id, row.problem)
}
function moveCodePanel(step: 1 | -1) {
const index = currentCodeIndex.value
if (index === -1) return
const next = viewableSubmissions.value[index + step]
if (next) {
showCodePanel(next.id, next.problem)
return
}
const page = query.page + step
if (page < 1 || page > maxPage.value) return
pendingJump = step > 0 ? "first" : "last"
query.page = page // listSubmissions consumePendingJump
}
// Esc n-modal
//
onKeyStroke(
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"],
(e: KeyboardEvent) => {
if (!codePanel.value) return
//
const target = e.target as HTMLElement | null
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return
}
e.preventDefault()
moveCodePanel(e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1)
},
)
function showScoreDetail(id: string) { function showScoreDetail(id: string) {
selectedFlowchartId.value = id selectedFlowchartId.value = id
toggleScoreDetailPanel(true) toggleScoreDetailPanel(true)
@@ -675,9 +743,24 @@ const flowchartColumns = computed(() => {
preset="card" preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }" :content-style="{ overflow: 'auto' }"
title="代码详情"
> >
<template #header>
<n-flex align="center" :size="12">
<n-text>代码详情</n-text>
<n-text v-if="isDesktop && viewableSubmissions.length > 1" depth="3">
<span class="shortcut-hint">
本页第 {{ currentCodeIndex + 1 }} / {{ viewableSubmissions.length }}
· 切换上下一条 · Esc 关闭
</span>
</n-text>
</n-flex>
</template>
<!--
key 换掉才会重新拉代码 detail.vue 只在 onMounted 里取一次
键盘切换时不换 key 的话弹框里还是上一条的代码
-->
<SubmissionDetail <SubmissionDetail
:key="submissionID"
:problemID="problemDisplayID" :problemID="problemDisplayID"
:submissionID="submissionID" :submissionID="submissionID"
hideList hideList
@@ -714,6 +797,11 @@ const flowchartColumns = computed(() => {
overflow: auto; overflow: auto;
} }
.shortcut-hint {
font-size: 13px;
font-weight: normal;
}
.flowchart-iframe { .flowchart-iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
+1 -1
View File
@@ -81,7 +81,7 @@ async function init() {
firstSubmissionAt.value = parseTime(metricsRes.first) firstSubmissionAt.value = parseTime(metricsRes.first)
latestSubmissionAt.value = parseTime(metricsRes.latest) latestSubmissionAt.value = parseTime(metricsRes.latest)
toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now) toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
learnDuration.value = durationToDays(metricsRes.first, metricsRes.latest) learnDuration.value = `${metricsRes.activeDays}`
} }
} finally { } finally {
toggle(false) toggle(false)
@@ -4,6 +4,7 @@ import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql" import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language" import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror" import { Codemirror } from "vue-codemirror"
import type { Extension } from "@codemirror/state"
import { import {
autocompletion, autocompletion,
closeBrackets, closeBrackets,
@@ -21,14 +22,17 @@ interface Props {
height?: string height?: string
readonly?: boolean readonly?: boolean
placeholder?: string placeholder?: string
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
extraExtensions?: Extension[]
} }
const { const {
language = "Python3", language = "Python",
fontSize = 20, fontSize = 20,
height = "100%", height = "100%",
readonly = false, readonly = false,
placeholder = "", placeholder = "",
extraExtensions = [],
} = defineProps<Props>() } = defineProps<Props>()
const code = defineModel<string>("value") const code = defineModel<string>("value")
@@ -37,7 +41,7 @@ const isDark = useDark()
const langExtension = computed(() => { const langExtension = computed(() => {
if (language === "SQL") if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true }) return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp() return language === "Python" ? python() : cpp()
}) })
const extensions = computed(() => [ const extensions = computed(() => [
@@ -49,6 +53,7 @@ const extensions = computed(() => [
override: [enhanceCompletion(language), completeAnyWord], override: [enhanceCompletion(language), completeAnyWord],
}), }),
isDark.value ? oneDark : smoothy, isDark.value ? oneDark : smoothy,
...extraExtensions,
]) ])
</script> </script>
+6 -16
View File
@@ -4,29 +4,20 @@ import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue" import HelpRequestList from "./HelpRequestList.vue"
/** /**
* 课堂求助的全局界面一次性提示新求助 toast求助列表教师端协作弹框 * 课堂求助的全局界面一次性提示新求助 toast求助列表
*
* 教师端的协作**没有弹框**接单会跳到那道题的页面在页面自带的编辑器里协作
* HelpRequestList handleAcceptProblemEditor collabHere
* 原来这里还异步挂一个 CollabModal那个弹框按一下 Esc 就关协作跟着结束
* *
* 挂在 App.vue 而不是顶栏或 default.vue 布局里这些东西跟着**连接** * 挂在 App.vue 而不是顶栏或 default.vue 布局里这些东西跟着**连接**
* 而连接是全局常驻的App.vue 按登录态开关 挂在顶栏里的时候老师一进 * 而连接是全局常驻的App.vue 按登录态开关 挂在顶栏里的时候老师一进
* /admin admin.vue * /admin admin.vue
* 提示角标协作弹框全都不出现正好错过 collab.ts 里写的那句老师可能 * 提示角标协作界面全都不出现正好错过 collab.ts 里写的那句老师可能
* 正在后台改题时收到求助放在这里才真的全局 * 正在后台改题时收到求助放在这里才真的全局
* *
* 位置要求n-message-provider 的后代useMessage 需要 * 位置要求n-message-provider 的后代useMessage 需要
*/ */
/**
* 协作弹框异步加载
*
* 这个组件静态 import 进来的话整套 CodeMirrorview / state / language /
* autocomplete / lang-*就跟着 App.vue 进了入口 chunk 首屏白白多下 640 KB
* gzip 210 KB而下面那个 v-if 决定了学生根本不渲染它 机房那批老机器
* 解析这些字节是实打实的开销
*
* 拆成异步之后首页的 JS 1.9 MB 降到 1.3 MBgzip 642 KB 428 KB
* 老师那边只是在第一次接单时多一次 chunk 请求
*/
const CollabModal = defineAsyncComponent(() => import("./CollabModal.vue"))
const collabStore = useCollabStore() const collabStore = useCollabStore()
const message = useMessage() const message = useMessage()
@@ -90,5 +81,4 @@ watch(
v-if="collabStore.isTeacher" v-if="collabStore.isTeacher"
v-model:show="collabStore.helpPanelOpen" v-model:show="collabStore.helpPanelOpen"
/> />
<CollabModal v-if="collabStore.isTeacher" />
</template> </template>
@@ -1,306 +0,0 @@
<script setup lang="ts">
import { bracketMatching } from "@codemirror/language"
import {
autocompletion,
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { enhanceCompletion } from "shared/extensions/autocompletion"
import { languageExtension } from "shared/extensions/language"
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { getProblem } from "oj/api"
import type { Problem } from "utils/types"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
const isDark = useDark()
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
// shallowRef SyncCodeEditor.vueEditorView ref()
// UnwrapRef vue-tsc
const editorView = shallowRef<EditorView | null>(null)
// SyncCodeEditor
const show = computed({
get: () => collabStore.isTeacher && collabStore.room !== null,
set: (value: boolean) => {
if (!value) collabStore.leave()
},
})
/**
* 语言跟着学生走room_open 带过来学生协作期间切语言会再来一条 room_language
* 取不到时按 C 和这里原来写死 cpp() 的表现一致
*/
const language = computed<LANGUAGE>(() => collabStore.room?.language ?? "C")
const extensions = computed(() => [
styleTheme,
languageExtension(language.value),
bracketMatching(),
closeBrackets(),
isDark.value ? oneDark : smoothy,
//
// vue-codemirror reconfigure collabDoc compartment
// CM6 沿@codemirror/state flatten `compartments.get() ||
// ext.inner` yCollab
autocompletion({
override: [enhanceCompletion(language.value), completeAnyWord],
}),
getInitialExtension(),
])
/**
* 题面直接放在弹框左侧原来只有一个打开题面链接老师得在两个标签页之间
* 来回切着对照学生的代码
*
* 不复用 ProblemContent它读写全局的 problemStore老师接单时可能正开着另一道题
* 的详情页一写就把那边的题面换掉了它的测试按钮跑的也是 codeStore 里老师
* 自己的代码这里只要只读的题面
*
* 求助入口在比赛里是关掉的Form.vue showHelpButtonproblemId 一定是公开
* 题目的展示 ID按非比赛的接口取就行
*/
const problem = ref<Problem | null>(null)
const problemLoading = ref(false)
watch(
() => collabStore.room?.problemId,
async (problemId) => {
if (!problemId) {
problem.value = null
problemLoading.value = false
return
}
if (problem.value?._id === problemId) return
problem.value = null
problemLoading.value = true
try {
const res = await getProblem(problemId, "")
//
if (collabStore.room?.problemId === problemId) problem.value = res
} catch {
//
} finally {
if (collabStore.room?.problemId === problemId)
problemLoading.value = false
}
},
{ immediate: true },
)
const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
})
const bind = (view: EditorView) => {
if (!collabStore.isTeacher || !collabStore.room) return
// seedContent null
start({ editorView: view, seedContent: null })
}
/**
* 起点是编辑器就绪不是房间打开
*
* n-modal 默认 display-directive="if"每次打开都会重挂一个全新的 CodeMirror
* 原来在 watch `await nextTick()` 之后去取 editorView赶上 teleport + 离场
* 过渡没走完取到的是上一轮那个已经 destroy viewstart() 静默绑到死编辑器上
* 老师对着空白框干等服务端那边房间却是活的
*/
const handleEditorReady = (payload: { view: EditorView }) => {
editorView.value = payload.view
bind(payload.view)
}
watch(
() => collabStore.room,
(room) => {
// null @ready
// display-directive show
if (room && collabStore.isTeacher) {
if (editorView.value) bind(editorView.value)
} else {
stop()
// view
editorView.value = null
}
},
)
onUnmounted(() => {
stop()
editorView.value = null
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:style="{ width: '94vw', maxWidth: '1600px' }"
:title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''} · ${language}`"
>
<template #header-extra>
<n-button
text
tag="a"
target="_blank"
:href="`/problem/${collabStore.room?.problemId}`"
>
打开题面
</n-button>
</template>
<n-split
direction="horizontal"
:default-size="0.4"
:min="0.2"
:max="0.7"
style="height: 70vh"
>
<template #1>
<n-scrollbar style="height: 100%">
<div class="statement">
<n-spin v-if="problemLoading" size="small" />
<n-empty v-else-if="!problem" description="题面加载失败" />
<template v-else>
<h3 class="problemTitle">{{ problem.title }}</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.description"
:theme="isDark ? 'dark' : 'light'"
/>
<template v-if="!sqlDisplay">
<template v-if="problem.inputDescription">
<p class="section">输入</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<template v-if="problem.outputDescription">
<p class="section">输出</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<template
v-for="(sample, index) of problem.samples"
:key="index"
>
<p class="section">例子 {{ index + 1 }}</p>
<n-descriptions bordered :column="2" size="small">
<n-descriptions-item label="输入">
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item label="输出">
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
</n-descriptions>
</template>
</template>
<template v-else>
<p class="section">数据表</p>
<div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<template v-if="sqlExpectedQuery">
<p class="section">期望结果</p>
<SQLDataTable
:columns="sqlExpectedQuery.columns"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
</template>
</template>
<template v-if="problem.hint">
<p class="section">提示</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.hint"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
</template>
</div>
</n-scrollbar>
</template>
<template #2>
<!--
不绑 v-model这个编辑器的内容完全由 Yjs 文档接管
原来绑了一个跨会话不清的 code ref 模态框重挂时 CodeMirror 拿它当初始
文档 yCollab 只观察 ytext从不反过来用 ytext 覆盖编辑器于是上一个
学生的代码留在文档里新学生的内容作为 delta 插到位置 0两边的偏移从此
对不上教师和学生显示的是两份不同的文档
-->
<Codemirror
indentWithTab
:extensions="extensions"
:tab-size="4"
style="height: 100%; font-size: 18px"
@ready="handleEditorReady"
/>
</template>
</n-split>
<template #footer>
<n-flex justify="end">
<n-button type="primary" @click="collabStore.leave()">
结束协作
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.statement {
padding-right: 16px;
}
.problemTitle {
margin: 0 0 8px;
}
.section {
font-size: 16px;
font-weight: 600;
margin: 12px 0 6px;
}
.testcase {
font-size: 14px;
white-space: pre;
font-family: Monaco, Consolas, monospace;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: Monaco, Consolas, monospace;
}
</style>
@@ -2,14 +2,19 @@
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
/** 由顶栏的姓名下拉菜单打开 */ /** 由顶栏的姓名下拉菜单打开 */
const show = defineModel<boolean>("show", { default: false }) const show = defineModel<boolean>("show", { default: false })
const collabStore = useCollabStore() const collabStore = useCollabStore()
const screenModeStore = useScreenModeStore()
const router = useRouter()
const route = useRoute()
const message = useMessage()
// //
// toast // toast
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
// now // now
@@ -43,12 +48,37 @@ const waited = (createdAt: number) => {
return `${m}:${String(s).padStart(2, "0")}` return `${m}:${String(s).padStart(2, "0")}`
} }
const handleAccept = (studentId: number, status: string) => { /**
* 接单 = 跳到那道题的页面在页面自带的编辑器里协作
*
* 原来是接单后弹一个 CollabModal弹框里再挂一个 CodeMirror + 一份只读题面
* 那个弹框按一下 Esc 就关协作跟着结束上课时太容易误触题面也只能看不能用
* 直接跳题目页之后老师看到的就是学生看到的那一页
*
* 求助入口在比赛里是关掉的Form.vue showHelpButtonproblemId 一定是公开
* 题目的展示 ID /problem/:id 就行
*/
const handleAccept = (studentId: number, problemId: string, status: string) => {
// //
if (status === "active" || !isDesktop.value) return if (status === "active" || !isDesktop.value) return
// handler 退
//
//
if (collabStore.room) {
message.warning("请先结束当前协作")
return
}
collabStore.accept(studentId) collabStore.accept(studentId)
// CollabModal //
//
// detail.vue init() **
// **
screenModeStore.resetScreenMode()
show.value = false show.value = false
//
// vue-router rejected promise
const target = `/problem/${problemId}`
if (route.path !== target) router.push(target)
} }
</script> </script>
@@ -97,7 +127,7 @@ const handleAccept = (studentId: number, status: string) => {
cursor: cursor:
item.status === 'active' || !isDesktop ? 'default' : 'pointer', item.status === 'active' || !isDesktop ? 'default' : 'pointer',
}" }"
@click="handleAccept(item.studentId, item.status)" @click="handleAccept(item.studentId, group.problemId, item.status)"
> >
<n-flex vertical :size="2"> <n-flex vertical :size="2">
<n-text> <n-text>
+118 -23
View File
@@ -7,6 +7,7 @@ import {
completeAnyWord, completeAnyWord,
} from "@codemirror/autocomplete" } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view" import type { EditorView } from "@codemirror/view"
import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark" import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy" import { smoothy } from "../themes/smoothy"
@@ -26,14 +27,27 @@ interface Props {
height?: string height?: string
readonly?: boolean readonly?: boolean
placeholder?: string placeholder?: string
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
extraExtensions?: Extension[]
/**
* 当前这个编辑器属于哪道题题目的展示 ID
*
* **协作只在题号对得上时才建立** 教师端现在也在题目页里协作原来是单独的
* CollabModal 弹框他接单时人可能停在别的题上 不比对题号的话那个编辑器
* 会绑上学生的文档老师看着自己的题面改着别人的代码学生那边同理排队期间
* 切到别的题接通了也不该把这道题的代码交出去
*/
problemId?: string
} }
const { const {
language = "Python3", language = "Python",
fontSize = 20, fontSize = 20,
height = "100%", height = "100%",
readonly = false, readonly = false,
placeholder = "", placeholder = "",
extraExtensions = [],
problemId = "",
} = defineProps<Props>() } = defineProps<Props>()
const code = defineModel<string>("value") const code = defineModel<string>("value")
@@ -49,6 +63,7 @@ const extensions = computed(() => [
override: [enhanceCompletion(language), completeAnyWord], override: [enhanceCompletion(language), completeAnyWord],
}), }),
getInitialExtension(), getInitialExtension(),
...extraExtensions,
]) ])
interface EditorReadyPayload { interface EditorReadyPayload {
@@ -61,10 +76,50 @@ interface EditorReadyPayload {
// sync.ts shallowRef // sync.ts shallowRef
const editorView = shallowRef<EditorView | null>(null) const editorView = shallowRef<EditorView | null>(null)
/** 房间开着,而且开的就是这道题 */
const roomIsHere = computed(
() =>
collabStore.room !== null &&
(!problemId || collabStore.room.problemId === problemId),
)
/**
* 这个编辑器**接进过**当前这个房间
*
* 用来区分两件看起来一样的事房间从我这儿挪走了我得明确结束协作
* 房间在别处开起来了跟我无关不能替别人把协作掐掉老师在别的题上
* 接单时后者天天发生 room open再导航过来那一瞬间旧页面的编辑器
* 看到的就是房间非空但不是我这道题
*/
const bound = ref(false)
const bind = (view: EditorView) => { const bind = (view: EditorView) => {
if (collabStore.isTeacher || !collabStore.room) return if (!roomIsHere.value) return
// bound.value = true
start({ editorView: view, seedContent: view.state.doc.toString() }) // null
// collabDoc.ts StartOptions
start({
editorView: view,
seedContent: collabStore.isTeacher ? null : view.state.doc.toString(),
})
}
/**
* 拆掉本地绑定**房间还在只是不在这道题上了**人走了不是协作结束了
* 补一条 leave
*
* - 教师从学生这道题切去别的题 不发的话学生那边一直挂着老师正在帮你
* 而老师的编辑器早就不在这个房间里字一个也过不去
* - 学生在协作中切去别的题 同理
*
* `/problem/1001` `/problem/1003` 是同一条路由换 params**组件被复用不卸载**
* 所以这件事不能只靠 onUnmounted实测就是这么漏的
*/
const detach = () => {
const wasBound = bound.value
bound.value = false
stop()
if (wasBound && collabStore.room) collabStore.leave("left")
} }
const handleEditorReady = (payload: EditorReadyPayload) => { const handleEditorReady = (payload: EditorReadyPayload) => {
@@ -74,24 +129,40 @@ const handleEditorReady = (payload: EditorReadyPayload) => {
bind(payload.view) bind(payload.view)
} }
// /**
// * 房间开了才建文档学生点求助时什么都不做 老师没来之前不该动他的编辑器
// ProblemEditor.vue Flowchart *
// collabStore.room * **现在两边都走这里** 教师端原来是一个独立的 CollabModal弹框里再挂一个
// **** * CodeMirror接单改成跳到题目页之后两边协作的都是页面上这一个编辑器
// CollabModal setBinaryHandler * setBinaryHandler 那个单例槽位也只剩一个使用者不会再有两个编辑器谁后调用
// CollabModal * 谁把对方顶掉的问题
*
* 所以收窄的条件从不是教师换成了房间开的是这道题roomIsHere
* 教师停在别的题上时照样不绑
*/
watch( watch(
() => collabStore.room, //
(room) => { //
if (room && !collabStore.isTeacher && editorView.value) // - `room`
bind(editorView.value) // roomIsHere
else stop() // Y.Doc
// - `roomIsHere` ****
// `/problem/1002` `/problem/1001` params****
// `@ready` room open room
//
//
// room.language
[() => collabStore.room, roomIsHere],
() => {
if (roomIsHere.value) {
if (editorView.value) bind(editorView.value)
} else detach()
}, },
) )
// //
// //
// ProblemEditor editorLanguage
watch( watch(
() => language, () => language,
(lang) => { (lang) => {
@@ -99,13 +170,37 @@ watch(
}, },
) )
/**
* 学生**排队期间离开了这道题**撤掉求助
*
* 那条求助说的是我卡在这道题人不在这道题上了就不成立了 原来它会一直挂在
* 队列里老师接进来时学生的编辑器不在这道题上根本不会绑老师对着一个空编辑器
* 敲字两边都没有提示把语言切成流程图同理那一档连求助按钮都没有留着他自己
* 也取消不掉
*
* **只认离开这件事不跟房间状态挂钩**老师走开时服务端会把求助退回排队
* 那条刚补上的 pending 不能被这里顺手取消掉
*/
const cancelPendingIfLeaving = () => {
if (collabStore.isTeacher) return
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
}
// params onUnmounted
watch(
() => problemId,
(next, previous) => {
if (previous && next !== previous) cancelPendingIfLeaving()
},
)
onUnmounted(() => { onUnmounted(() => {
stop() cancelPendingIfLeaving()
// // CRDT
// CRDT Y.Doc // Y.Doc
// "" // detach
// //
if (collabStore.room && !collabStore.isTeacher) collabStore.leave() detach()
}) })
</script> </script>
@@ -19,7 +19,8 @@ export function useConfigUpdate() {
const handleConfigUpdate = (data: ConfigUpdate) => { const handleConfigUpdate = (data: ConfigUpdate) => {
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段 // 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return if (!(data.key in configStore.config)) return
;(configStore.config as any)[data.key] = data.value ;(configStore.config as unknown as Record<string, unknown>)[data.key] =
data.value
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份 // getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
if (data.key === "websiteName") document.title = data.value if (data.key === "websiteName") document.title = data.value
} }
+12 -10
View File
@@ -42,11 +42,13 @@ export function usePagination<T extends Record<string, any>>(
limit: parseInt(<string>route.query.limit) || defaultLimit, limit: parseInt(<string>route.query.limit) || defaultLimit,
...initialQuery, ...initialQuery,
}) as unknown as T & PaginationQuery }) as unknown as T & PaginationQuery
// 键是运行时按 initialQuery 枚举出来的,静态类型写不出来;写入统一走这一个口子
const writable = query as Record<string, unknown>
// 同步 URL 查询参数到本地状态 // 同步 URL 查询参数到本地状态
function syncFromRoute() { function syncFromRoute() {
;(query as any).page = parseInt(<string>route.query.page) || defaultPage writable.page = parseInt(<string>route.query.page) || defaultPage
;(query as any).limit = parseInt(<string>route.query.limit) || defaultLimit writable.limit = parseInt(<string>route.query.limit) || defaultLimit
// 同步其他查询参数 // 同步其他查询参数
Object.keys(initialQuery).forEach((key) => { Object.keys(initialQuery).forEach((key) => {
@@ -54,11 +56,11 @@ export function usePagination<T extends Record<string, any>>(
if (value !== undefined) { if (value !== undefined) {
// 处理不同类型的参数 // 处理不同类型的参数
if (typeof initialQuery[key] === "boolean") { if (typeof initialQuery[key] === "boolean") {
;(query as any)[key] = value === "1" || value === "true" writable[key] = value === "1" || value === "true"
} else if (typeof initialQuery[key] === "number") { } else if (typeof initialQuery[key] === "number") {
;(query as any)[key] = parseInt(<string>value) || initialQuery[key] writable[key] = parseInt(<string>value) || initialQuery[key]
} else { } else {
;(query as any)[key] = <string>value || initialQuery[key] writable[key] = <string>value || initialQuery[key]
} }
} }
}) })
@@ -75,7 +77,7 @@ export function usePagination<T extends Record<string, any>>(
// 重置页码到第一页 // 重置页码到第一页
function resetPage() { function resetPage() {
;(query as any).page = defaultPage writable.page = defaultPage
} }
// 清空所有查询条件(除了分页参数) // 清空所有查询条件(除了分页参数)
@@ -83,13 +85,13 @@ export function usePagination<T extends Record<string, any>>(
Object.keys(initialQuery).forEach((key) => { Object.keys(initialQuery).forEach((key) => {
const initialValue = initialQuery[key] const initialValue = initialQuery[key]
if (typeof initialValue === "string") { if (typeof initialValue === "string") {
;(query as any)[key] = "" writable[key] = ""
} else if (typeof initialValue === "boolean") { } else if (typeof initialValue === "boolean") {
;(query as any)[key] = false writable[key] = false
} else if (typeof initialValue === "number") { } else if (typeof initialValue === "number") {
;(query as any)[key] = 0 writable[key] = 0
} else { } else {
;(query as any)[key] = initialValue writable[key] = initialValue
} }
}) })
resetPage() resetPage()
+2 -2
View File
@@ -5,7 +5,7 @@ import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
/** /**
* SyncCodeEditorCollabModal * SyncCodeEditor
* *
* *
* Java / Golang / JavaScript cpp() * Java / Golang / JavaScript cpp()
@@ -13,5 +13,5 @@ import type { LANGUAGE } from "utils/types"
export function languageExtension(language: LANGUAGE): Extension { export function languageExtension(language: LANGUAGE): Extension {
if (language === "SQL") if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true }) return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp() return language === "Python" ? python() : cpp()
} }
+42 -5
View File
@@ -84,6 +84,37 @@ export const useCollabStore = defineStore("collab", () => {
) )
}) })
/**
* room_closed reason
*
*
*
* ** reason **teardownRoom initiator
* `done` / `self_left` `peer_left` / `peer_offline`
* reason
*/
function closedNotice(reason: unknown) {
const teacher = userStore.isTeacherOrAbove
switch (reason) {
case "peer_offline":
return "对方已断开连接"
// 对面离开了这道题:学生那侧要说明求助的去向 —— 服务端紧接着会补一条
// help_status:pending 把他放回队列
case "peer_left":
return teacher
? "学生离开了这道题,协作已结束"
: "老师暂时离开,你的求助已重新排队"
// 自己离开了这道题(跳走页面、把语言切成流程图)
case "self_left":
return teacher
? "你已离开这道题,求助退回队列了"
: "你已离开这道题,协作结束"
// done:有人点了「结束协作」
default:
return teacher ? "已结束这次协作" : "老师已结束这次帮忙"
}
}
const handleMessage = (data: CollabMessage) => { const handleMessage = (data: CollabMessage) => {
switch (data.type) { switch (data.type) {
case "requests": case "requests":
@@ -126,9 +157,7 @@ export const useCollabStore = defineStore("collab", () => {
// 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉 // 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉
room.value = null room.value = null
helpStatus.value = "idle" helpStatus.value = "idle"
setNotice( setNotice(closedNotice(data.reason))
data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束",
)
return return
case "error": case "error":
setNotice(String(data.message ?? "")) setNotice(String(data.message ?? ""))
@@ -192,8 +221,16 @@ export const useCollabStore = defineStore("collab", () => {
ws.send({ type: "reject", studentId }) ws.send({ type: "reject", studentId })
} }
function leave() { /**
ws.send({ type: "leave" }) * 退**** reason collab/handler.ts handleLeave
*
* - `"done"`
* - `"left"`
* **退**
*
*/
function leave(reason: "done" | "left" = "done") {
ws.send({ type: "leave", reason })
} }
function sendBinary(data: Uint8Array) { function sendBinary(data: Uint8Array) {
+18 -10
View File
@@ -1,3 +1,4 @@
import type { JudgeStatusValue } from "@oj2/contract"
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types" import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。 // 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。
@@ -21,6 +22,18 @@ export enum SubmissionStatus {
ast_check_failed = 10, ast_check_failed = 10,
} }
// 编译期对齐契约:契约加/改一个码而这里没跟,下面两行会当场编译不过。
type SyncedWithContract =
Exclude<
`${SubmissionStatus}`,
`${SubmissionStatus.submitting}`
> extends `${JudgeStatusValue}`
? `${JudgeStatusValue}` extends `${Exclude<SubmissionStatus, SubmissionStatus.submitting>}`
? true
: never
: never
export const _submissionStatusSynced: SyncedWithContract = true
export enum ContestStatus { export enum ContestStatus {
initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位 initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
not_started = "1", not_started = "1",
@@ -164,8 +177,7 @@ export const SOURCES = {
C: cSource, C: cSource,
"C++": cppSource, "C++": cppSource,
Java: javaSource, Java: javaSource,
Python3: pythonSource, Python: pythonSource,
Python2: "",
JavaScript: "", JavaScript: "",
Golang: "", Golang: "",
Flowchart: "", Flowchart: "",
@@ -176,8 +188,7 @@ export const LANGUAGE_FORMAT_VALUE = {
C: "c", C: "c",
"C++": "cpp", "C++": "cpp",
Java: "java", Java: "java",
Python2: "python", Python: "python",
Python3: "python",
JavaScript: "javascript", JavaScript: "javascript",
Golang: "go", Golang: "go",
Flowchart: "flowchart", Flowchart: "flowchart",
@@ -189,8 +200,7 @@ export const LANGUAGE_SHOW_VALUE = {
C: "C语言", C: "C语言",
"C++": "C++", "C++": "C++",
Java: "Java", Java: "Java",
Python2: "Python", Python: "Python",
Python3: "Python",
JavaScript: "JS", JavaScript: "JS",
Golang: "Go", Golang: "Go",
SQL: "SQL", SQL: "SQL",
@@ -198,8 +208,7 @@ export const LANGUAGE_SHOW_VALUE = {
export const ICON_SET = { export const ICON_SET = {
Flowchart: "vscode-icons:file-type-drawio", Flowchart: "vscode-icons:file-type-drawio",
Python2: "devicon:python", Python: "devicon:python",
Python3: "devicon:python",
C: "devicon:c", C: "devicon:c",
"C++": "devicon:cplusplus", "C++": "devicon:cplusplus",
Java: "devicon:java", Java: "devicon:java",
@@ -237,8 +246,7 @@ const blankTemplate = `//PREPEND BEGIN
export const CODE_TEMPLATES = { export const CODE_TEMPLATES = {
C: cTemplate, C: cTemplate,
"C++": cppTemplate, "C++": cppTemplate,
Python2: blankTemplate, Python: blankTemplate,
Python3: blankTemplate,
Java: blankTemplate, Java: blankTemplate,
JavaScript: blankTemplate, JavaScript: blankTemplate,
Golang: blankTemplate, Golang: blankTemplate,
+1 -2
View File
@@ -12,8 +12,7 @@ const JUDGE0_LANGUAGE_ID: Partial<Record<LANGUAGE, number>> = {
Java: 62, Java: 62,
Golang: 60, Golang: 60,
JavaScript: 63, JavaScript: 63,
Python2: 70, Python: 71,
Python3: 71,
} }
export async function createTestSubmission(code: Code, input: string) { export async function createTestSubmission(code: Code, input: string) {
+3
View File
@@ -305,6 +305,9 @@ export type Rank = RankProfile
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */ /** 榜单里「我」的位置:比 Rank 多一个全服名次 */
export type { MyRank } from "@oj2/contract" export type { MyRank } from "@oj2/contract"
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { export type {
ClassComparison, ClassComparison,
ClassRankItem, ClassRankItem,
+4
View File
@@ -19,6 +19,10 @@ JUDGE_CONCURRENCY=2
# DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。 # DeepSeek key,用于题解 AI 分析。留空则 AI 功能不可用(其余功能不受影响)。
AI_KEY= AI_KEY=
# AI 提示走两段式(先诊断、再生成)。填 1 打开,留空为关。
# 服务器和机房共用一个库、各有各的 .env —— 两边要一起开关,不然 ai_hint 里两种口径的数据混在一起。
AI_HINT_DIAGNOSE=
# --- 数据在哪 --- # --- 数据在哪 ---
# #
# 这三个变量决定新栈是「自带 postgres/redis」还是「接着用旧栈的」。 # 这三个变量决定新栈是「自带 postgres/redis」还是「接着用旧栈的」。
+6 -1
View File
@@ -72,7 +72,11 @@ services:
retries: 10 retries: 10
oj-judge: oj-judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 # 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
# 上游停更在 2024-04registry 上的 1.6.1 == latest,没有新版可拉。
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
image: oj2-judge-2
container_name: oj-judge container_name: oj-judge
restart: always restart: always
read_only: true read_only: true
@@ -135,6 +139,7 @@ services:
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2} JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
AI_KEY: ${AI_KEY:-} AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 走 NPM 终止 TLS,浏览器侧是 httpsCookie 必须带 Secure # 走 NPM 终止 TLS,浏览器侧是 httpsCookie 必须带 Secure
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
healthcheck: healthcheck:
+5 -1
View File
@@ -57,7 +57,11 @@ services:
retries: 10 retries: 10
judge: judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 # 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
# 上游停更在 2024-04registry 上的 1.6.1 == latest,没有新版可拉。
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
image: oj2-judge-2
container_name: oj2-judge container_name: oj2-judge
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
+6 -1
View File
@@ -29,7 +29,11 @@ services:
retries: 10 retries: 10
oj-judge: oj-judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1 # 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
# 上游停更在 2024-04registry 上的 1.6.1 == latest,没有新版可拉。
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
image: oj2-judge-2
container_name: oj-judge container_name: oj-judge
restart: always restart: always
read_only: true read_only: true
@@ -79,6 +83,7 @@ services:
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4} JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4}
AI_KEY: ${AI_KEY:-} AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传, # 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传,
# 学生会「登录成功但立刻又是未登录」。这里必须是 false。 # 学生会「登录成功但立刻又是未登录」。这里必须是 false。
COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SECURE: ${COOKIE_SECURE:-false}
+16 -1
View File
@@ -152,7 +152,22 @@ judge_dir=$(grep -E '^JUDGE_STATE_DIR=' docker/.env | tail -1 | cut -d= -f2- ||
[ -n "$judge_dir" ] || die "JUDGE_STATE_DIR 没设 —— 试跑期间新旧两个判题机会共用运行目录" [ -n "$judge_dir" ] || die "JUDGE_STATE_DIR 没设 —— 试跑期间新旧两个判题机会共用运行目录"
ok "判题机运行目录 $judge_dir" ok "判题机运行目录 $judge_dir"
# ④ 外接形态才需要预检:库不归本栈管,得确认它已经活着。 # ④ 判题镜像是自建的(上游 JudgeServer 停更,官方镜像的编译器停在 gcc-13),
# registry 上没有这个 tag。忘了 docker load 的话,要到「起栈」那步 compose 去 pull
# 才失败 —— 不如在这里就把该跑的三条命令说清楚。
# 回滚到官方镜像时这段自动跳过:那个 tag 是 pull 得到的。
judge_image=$(grep -m1 -E '^[[:space:]]*image: oj2-judge' <<<"$cfg" | awk '{print $2}' || true)
if [ -n "$judge_image" ]; then
docker image inspect "$judge_image" >/dev/null 2>&1 \
|| die "判题镜像 $judge_image 不在这台机器上,而且 registry 上也没有(它是自建的)。
本机:docker/judge/build.sh --save
scp dist/${judge_image/:/-}.tar root@这台机器:/root/OJDeploy/
这里:docker load -i /root/OJDeploy/${judge_image/:/-}.tar
构建和回滚见 docker/judge/README.md。"
ok "判题镜像 $judge_image 在本机"
fi
# ⑤ 外接形态才需要预检:库不归本栈管,得确认它已经活着。
# 自带形态下这两个容器就是本栈自己起的,起栈那步会拉起来,这里没什么可查。 # 自带形态下这两个容器就是本栈自己起的,起栈那步会拉起来,这里没什么可查。
if [ "$LOCAL_DATA" -eq 0 ]; then if [ "$LOCAL_DATA" -eq 0 ]; then
for c in oj-postgres oj-redis; do for c in oj-postgres oj-redis; do
+148
View File
@@ -0,0 +1,148 @@
# 判题沙箱镜像。**这是 QingdaoU/JudgeServer 官方 Dockerfile 的分叉**
# 只改工具链版本,server/ 和 Judger/ 的代码一行都没动(构建时从上游仓库
# 的固定 commit 拉,见 build.sh)。
#
# 为什么要分叉:上游停更在 2024-04-05b28aa56,也就是 1.6.1 这个 tag),
# registry 上的 `latest` 和 `1.6.1` 是同一份镜像,没有新版可升。想要新编译器
# 只能自己构建。
#
# 相对上游的全部改动:
# gcc/g++ 13 → 14 (trixie 默认)
# Python 3.12 → 3.13 (trixie 默认)
# Go / JDK / Node → **整套删掉**(见下)
#
# 只留 C / C++ / Python3 的工具链:前端的语言复选框从来只给这三种加 SQL,
# 生产库 12 万条提交里 Java/Golang/JavaScript 一共 62 条、全是很早以前的。
# 删掉 golang-1.24-go、temurin-25-jdk、nodejs 之后镜像从 1.16GB 掉到 ~500MB
# 构建也少了 NodeSource 那个 39MB 的 deb(它没有可用的国内镜像,最慢的一块)。
# 想恢复某种语言:这里加回包 + alternatives,同时改 apps/api/src/judge/languages.ts。
#
# ⚠️ 语言的编译/运行命令**不在这个文件里**,在 apps/api/src/judge/languages.ts。
# 升 gcc 大版本要同步看那边的开关(gcc-14 把 implicit-function-declaration
# 等提成了 errorlanguages.ts 里有三个 -Wno-error 把它压回去)。
# 镜像源。默认走国内镜像 —— 官方源在这边实测 197 KB/s,清华 3.9 MB/s
# 整个构建从 12 分钟掉到 2 分钟出头。出国内网络环境用 build.sh --no-mirror 关掉。
#
# ⚠️ debian 这两个只能用 **http**:改 sources 这一步发生在装 ca-certificates 之前,
# base 镜像里没有 CA 根证书,https 一律 `certificate verify failed`。
# PyPI 那条是 pip 自己请求的,pip 内置 certifi,不依赖系统 CA。
ARG APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian
ARG APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
FROM debian:trixie-slim AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ARG APT_MIRROR
ARG APT_SECURITY_MIRROR
ARG PIP_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,id=apt-cahce-1-$TARGETARCH$TARGETVARIANT-builder,sharing=locked \
--mount=type=cache,target=/var/lib/apt,id=apt-cahce-2-$TARGETARCH$TARGETVARIANT-builder,sharing=locked \
<<EOS
set -ex
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/no-recommends
echo 'APT::AutoRemove::RecommendsImportant "0";' >> /etc/apt/apt.conf.d/no-recommends
# 换镜像源。debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
# 反过来的话第二条 sed 会把它改成 <镜像>/debian-security,路径不存在。
sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|; s|http://deb.debian.org/debian|$APT_MIRROR|" /etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y libtool make cmake libseccomp-dev gcc python3 python3-venv
EOS
COPY Judger/ /app/
RUN <<EOS
set -ex
mkdir /app/build
cmake -S . -B build
cmake --build build --parallel $(nproc)
EOS
RUN <<EOS
set -ex
cd bindings/Python
python3 -m venv .venv
.venv/bin/pip3 install -i "$PIP_INDEX_URL" build
.venv/bin/python3 -m build -w
EOS
FROM debian:trixie-slim
ARG TARGETARCH
ARG TARGETVARIANT
ARG APT_MIRROR
ARG APT_SECURITY_MIRROR
ARG PIP_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,id=apt-cahce-1-$TARGETARCH$TARGETVARIANT-final,sharing=locked \
--mount=type=cache,target=/var/lib/apt,id=apt-cahce-2-$TARGETARCH$TARGETVARIANT-final,sharing=locked \
<<EOS
set -ex
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/no-recommends
echo 'APT::AutoRemove::RecommendsImportant "0";' >> /etc/apt/apt.conf.d/no-recommends
needed="python3.13-minimal \
python3.13-venv \
libpython3.13-stdlib \
libpython3.13-dev \
gcc-14 \
g++-14 \
strace"
savedAptMark="$(apt-mark showmanual) $needed"
# 换镜像源。debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
# 反过来的话第二条 sed 会把它改成 <镜像>/debian-security,路径不存在。
sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|; s|http://deb.debian.org/debian|$APT_MIRROR|" /etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y $needed
# languages.ts 里的编译/运行命令写的是绝对路径(/usr/bin/gcc、/usr/bin/python3 …),
# 全靠下面这几条 alternatives 挂出来。换包名必须同步改,漏一条的表现是 CE 而不是报错。
update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 14
update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-14 14
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.13 13
apt-mark auto '.*' > /dev/null
apt-mark manual $savedAptMark
apt-get purge -y --auto-remove
EOS
COPY --from=builder --chmod=755 --link /app/output/libjudger.so /usr/lib/judger/libjudger.so
COPY --from=builder /app/bindings/Python/dist/ /app/
RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cahce-$TARGETARCH$TARGETVARIANT-final \
<<EOS
set -ex
python3 -m venv .venv
CC=gcc .venv/bin/pip3 install -i "$PIP_INDEX_URL" --compile --no-cache-dir flask gunicorn idna psutil requests
.venv/bin/pip3 install *.whl
EOS
COPY server/ /app/
RUN <<EOS
set -ex
chmod -R u=rwX,go=rX /app/
chmod +x /app/entrypoint.sh
gcc -shared -fPIC -o unbuffer.so unbuffer.c
useradd -u 901 -r -s /sbin/nologin -M compiler
useradd -u 902 -r -s /sbin/nologin -M code
useradd -u 903 -r -s /sbin/nologin -M -G code spj
mkdir -p /usr/lib/judger
EOS
# 构建期自检:这五行任何一个不是预期版本,说明上面的包名或 alternatives 配错了。
RUN <<EOS
set -ex
gcc --version
g++ --version
python3 --version
EOS
HEALTHCHECK --interval=5s CMD [ "/app/.venv/bin/python3", "/app/service.py" ]
EXPOSE 8080
ENTRYPOINT [ "/app/entrypoint.sh" ]
+104
View File
@@ -0,0 +1,104 @@
# 判题沙箱镜像
判题机跑的是 [QingdaoU/JudgeServer](https://github.com/QingdaoU/JudgeServer)。
**上游已经停更**:master 最后一次提交是 2024-04-05`b28aa56`,即 tag `v1.6.1`),
registry 上的 `oj-image/judge:latest``:1.6.1` 是同一份镜像(config digest 都是
`221bf4c0e730`)。所以想换新编译器,只能自己构建。
这里放的是**只改工具链的分叉**`server/`Flask + `_judger` 那套判题逻辑)和
`Judger/`libjudger.so 沙箱内核)一行都没动,构建时从上游那个固定 commit 拉。
## 工具链
2026-09 从上游镜像升级,同时把用不上的三种语言整套砍掉:
| | 上游 1.6.1 | 现在 |
|---|---|---|
| gcc / g++ | 13 | **14.2**trixie 默认) |
| Python | 3.12 | **3.13.5**trixie 默认) |
| Go | 1.22 | **删掉** |
| Node | 20.x | **删掉** |
| JDK | temurin-21 | **删掉** |
| base | debian:trixie-slim2024 年的) | debian:trixie-slim(当前) |
| 镜像体积 | 1.1 GB | **433 MB** |
砍语言的依据:前端的题目语言复选框从来只给 Python / C / C++ / SQL
生产库 12 万条提交里 Java 44 条、Golang 15 条、JavaScript 3 条,全是很早以前的。
契约 `judgeLanguageSchema` 里那几个键留着(渲染历史提交要用),只是判题机不再认。
要恢复某种语言:Dockerfile 里加回包和 `update-alternatives`,同时改
`apps/api/src/judge/languages.ts`,两边缺一个都是静默失败。
## 镜像源
默认走清华源(`Dockerfile` 顶部三个 ARG)。官方源在这边实测 **197 KB/s**,清华
**3.9 MB/s**,整个构建从 12 分钟掉到 1 分钟以内。
`build.sh --no-mirror` 换回官方源。debian 那两个只能用 http —— 改 sources 发生在
装 ca-certificates 之前,base 镜像里没有 CA 根证书,https 一律
`certificate verify failed`(这个坑踩过)。
## 构建与分发
```bash
docker/judge/build.sh --save # 本机构建 + 导出 dist/oj2-judge-2.tar
scp dist/oj2-judge-2.tar root@服务器:/root/OJDeploy/
ssh root@服务器 'docker load -i /root/OJDeploy/oj2-judge-2.tar'
# 机房那台同样来一遍 —— 两个站点各有各的判题沙箱
```
之后正常 `docker/deploy.sh` 即可。
⚠️ **这个名字不在任何 registry 上。** 服务器上忘了 `docker load`compose 会去
pull 然后报找不到镜像(好在是响亮地失败,不是静默降级)。
⚠️ **改工具链就把末尾的序号 +1**(下一版叫 `oj2-judge-3``build.sh` 里的 `IMAGE`
compose 里三处,一起改)。`docker compose up -d` 不带 `--pull`,名字没变会静默用机器上
的旧镜像。官方镜像算第 1 版,所以我们自己重编的从 `-2` 起。
回滚:把三个 compose 的 image 改回
`registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1`,重新 `up -d`。别在服务器上
`docker image prune` 把那份旧镜像清掉。
## 和 `languages.ts` 的关系
**编译和运行命令不在镜像里**,在 `apps/api/src/judge/languages.ts`。镜像只负责把
`/usr/bin/gcc``/usr/bin/python3``/usr/bin/go``/usr/bin/node``/usr/bin/java`
这些绝对路径挂到正确的版本上(Dockerfile 末尾的 `update-alternatives`)。
gcc-14 把隐式函数声明、int↔指针互赋、不兼容指针类型从 warning 提成了 error`-w`
压不住。`languages.ts` 里的 `cLooseErrors` 三个 `-Wno-error=` 就是为此加的 ——
实测 1951 份历史 C 提交和 20 篇 C 教程的 93 个代码块,加了之后与 gcc-13 逐个文件
结果完全一致;不加的话有一批会从能过变成 CE。
## 这次升级是怎么验的
不写测试,全是实跑。除了 `smoke.ts` 的 13 条,还拿**生产库备份里的真实代码**逐个
文件对比了新旧镜像的编译结果(脚本是一次性的,结论记在这里):
| 语料 | 份数 | 老镜像 (gcc-13 / py3.12) | 新镜像 (gcc-14 / py3.13) | 差异 |
|---|---|---|---|---|
| 历史 C 提交(共 19262,随机抽样) | 1951 | 1725 过 / 226 CE | 一模一样 | **0** |
| 历史 C++ 提交(全量) | 882 | 603 过 / 279 CE | 一模一样 | **0** |
| 历史 Python 提交(共 104527,随机抽样) | 2000 | 1833 过 / 167 CE | 一模一样 | **0** |
| 20 篇 C 教程里含 `int main` 的代码块 | 93 | 93 全过 | 一模一样 | **0** |
**不加 `cLooseErrors` 那三个开关的话,1951 份 C 提交里有 26 份会从「能过」变成 CE**
(按比例算全库约 260 条),全是忘了 `#include <string.h>` 之类的隐式函数声明。
教程那 93 块本身写得规范,加不加都全过。
## 换镜像后怎么验
```bash
bun docker/judge/smoke.ts # 六种语言 + 六种状态码 + gcc 宽松度
```
它直接打判题机的 `/judge`,不需要起后端、不需要库里有题。用的 `languageConfigs`
就是线上那份,所以配置和镜像对不上会当场暴露。
**Go 那条坑记一下**(升级时发现的,升级之前就有):`GOCACHE` 指向容器的 tmpfs
`/tmp`,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒的编译
预算 —— 重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。Go 现在整个
删掉了,但**以后加回任何需要编译缓存的语言,记得把编译预算放宽**。
判题机装好之后,后台「判题机列表」应该能看到它上线(心跳走
`POST /api/judge-server/heartbeat`5 秒一次)。
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# 构建判题沙箱镜像(本机构建,产物用 docker save 传到服务器和机房)。
#
# docker/judge/build.sh # 构建并打 tag
# docker/judge/build.sh --save # 顺便导出 tar(给 scp 用)
# docker/judge/build.sh --no-cache # 不吃构建缓存
# docker/judge/build.sh --no-mirror # 不走国内镜像源(默认走)
#
# 上游 JudgeServer 停更在 2024-04-05registry 上的 1.6.1 == latest,没有新版
# 可拉。这个脚本从上游那个固定 commit 拉源码(server/ 和 Judger/ 一行不改),
# 只把 Dockerfile 换成 docker/judge/Dockerfile —— 新工具链的全部改动都在那里。
#
# 上传和切换见 docker/judge/README.md。
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# 上游 master HEAD= tag v1.6.1+judgeserver.1.6.1)。Judger 子模块的版本由这个
# commit 自己钉住(d19a6dc),不用在这里再写一遍。
UPSTREAM_REPO=https://github.com/QingdaoU/JudgeServer.git
UPSTREAM_COMMIT=b28aa56d60fed7358a29d9bdeb9d86fcc06e41a7
# 我们自己重编的第几版判题镜像(官方那个算第 1 版,这是第 2 版)。
# **改工具链就把末尾的序号 +1**(下一版叫 oj2-judge-3),别在同一个名字上重建 ——
# compose 的 `up -d` 不带 --pull,名字没变就会静默用机器上的旧镜像。
IMAGE=oj2-judge-2
SAVE=0
BUILD_ARGS=()
for arg in "$@"; do
case "$arg" in
--save) SAVE=1 ;;
--no-cache) BUILD_ARGS+=(--no-cache) ;;
# 默认用国内镜像源(Dockerfile 顶部的四个 ARG)。在能直连的网络里用这个关掉,
# 换回 deb.debian.org / pypi.org。
--no-mirror)
BUILD_ARGS+=(
--build-arg APT_MIRROR=http://deb.debian.org/debian
--build-arg APT_SECURITY_MIRROR=http://deb.debian.org/debian-security
--build-arg PIP_INDEX_URL=https://pypi.org/simple
) ;;
*) echo "未知参数:$arg(可用:--save、--no-cache、--no-mirror" >&2; exit 2 ;;
esac
done
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
command -v docker >/dev/null || die "没装 docker"
[ -f docker/judge/Dockerfile ] || die "docker/judge/Dockerfile 不见了,当前目录:$PWD"
src=$(mktemp -d)
trap 'rm -rf "$src"' EXIT
say "拉上游源码 $UPSTREAM_COMMIT"
git -c advice.detachedHead=false clone --quiet "$UPSTREAM_REPO" "$src"
git -C "$src" -c advice.detachedHead=false checkout --quiet "$UPSTREAM_COMMIT"
git -C "$src" submodule update --quiet --init --recursive
ok "server/ 和 Judger/ 就位"
say "构建 $IMAGE"
# 上下文是上游源码,Dockerfile 用我们自己的那份。
docker build "${BUILD_ARGS[@]}" -f docker/judge/Dockerfile -t "$IMAGE" "$src"
ok "$IMAGE"
say "镜像里的工具链"
docker run --rm --entrypoint sh "$IMAGE" -c '
printf "gcc %s\n" "$(gcc -dumpfullversion)"
printf "g++ %s\n" "$(g++ -dumpfullversion)"
printf "python3 %s\n" "$(python3 -V | cut -d" " -f2)"
'
if [ "$SAVE" = 1 ]; then
out="dist/${IMAGE/:/-}.tar"
mkdir -p dist
say "导出 $out"
docker save "$IMAGE" -o "$out"
ok "$(du -h "$out" | cut -f1) → scp 到服务器后 docker load -i"
fi
+266
View File
@@ -0,0 +1,266 @@
/**
* ****
*
* bun docker/judge/smoke.ts # .env JUDGE_SERVER_URL
* JUDGE_SERVER_URL=http://localhost:8082 bun docker/judge/smoke.ts
*
* /judge api / worker /
* `test_case`
*
*
* 1. languageConfigs 线
* 2. **** packages/contract/src/judge-status.ts
* 3. gcc #include gcc-14 CE
*/
import { createHash } from "node:crypto"
import { JudgeStatus } from "@oj2/contract"
import { languageConfigs } from "../../apps/api/src/judge/languages"
const url = process.env.JUDGE_SERVER_URL ?? "http://localhost:8081"
const rawToken = process.env.JUDGE_SERVER_TOKEN
if (!rawToken) {
console.error("JUDGE_SERVER_TOKEN 没设 —— 在 OJ2 根目录跑,bun 会自己读 .env")
process.exit(2)
}
const token = createHash("sha256").update(rawToken).digest("hex")
interface Case {
language: string
name: string
code: string
expect: number
/** 默认 3000ms / 128MB,跑得慢或要撑爆内存的用例自己改 */
cpu?: number
memory?: number
}
const sumTestCase = [{ input: "1 2\n", output: "3\n" }]
const cases: Case[] = [
// ---------------------------------------------------------------- C
{
language: "C",
name: "C 正常通过",
expect: JudgeStatus.ACCEPTED,
code: `#include <stdio.h>
int main(void) {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\\n", a + b);
return 0;
}`,
},
{
// 这条是升 gcc 的主要风险点:gcc-14 起 implicit-function-declaration 是
// errorlanguages.ts 里的三个 -Wno-error 就是为它加的。这条挂了说明那些
// 开关没生效 —— 后果是一批历史题解和 20 篇 C 教程的示例突然全 CE。
language: "C",
name: "C 忘了 #include 仍能过(gcc 宽松度)",
expect: JudgeStatus.ACCEPTED,
code: `int main(void) {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\\n", a + b);
return 0;
}`,
},
{
language: "C",
name: "C 答案错误",
expect: JudgeStatus.WRONG_ANSWER,
code: `#include <stdio.h>
int main(void) {
int a, b;
scanf("%d %d", &a, &b);
printf("%d\\n", a + b + 1);
return 0;
}`,
},
{
language: "C",
name: "C 编译错误",
expect: JudgeStatus.COMPILE_ERROR,
code: `int main(void) { return }`,
},
{
language: "C",
name: "C 运行超时",
expect: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
cpu: 1000,
code: `int main(void) {
volatile long x = 0;
while (1) x++;
return 0;
}`,
},
{
// RLIMIT_AS 是 max_memory 的两倍,所以 malloc 会先成功一阵子再失败,
// 退出时 ru_maxrss 已经超过 max_memory → judger 判 MLE 而不是 RE。
language: "C",
name: "C 内存超限",
expect: JudgeStatus.MEMORY_LIMIT_EXCEEDED,
memory: 64 * 1024 * 1024,
code: `#include <stdlib.h>
#include <string.h>
int main(void) {
for (;;) {
char *p = malloc(8 * 1024 * 1024);
if (!p) return 1;
memset(p, 1, 8 * 1024 * 1024);
}
}`,
},
{
language: "C",
name: "C 运行时错误",
expect: JudgeStatus.RUNTIME_ERROR,
code: `int main(void) {
int *p = 0;
*p = 1;
return 0;
}`,
},
// ---------------------------------------------------------------- Python
{
language: "Python",
name: "Python 正常通过",
expect: JudgeStatus.ACCEPTED,
code: `a, b = map(int, input().split())
print(a + b)`,
},
{
language: "Python",
name: "Python 编译错误",
expect: JudgeStatus.COMPILE_ERROR,
code: `def (:`,
},
{
language: "Python",
name: "Python 运行超时",
expect: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
cpu: 1000,
code: `while True:
pass`,
},
{
language: "Python",
name: "Python 运行时错误",
expect: JudgeStatus.RUNTIME_ERROR,
code: `print(1 / 0)`,
},
// ---------------------------------------------------------------- C++
{
language: "C++",
name: "C++ 正常通过",
expect: JudgeStatus.ACCEPTED,
code: `#include <iostream>
int main() {
int a, b;
std::cin >> a >> b;
std::cout << a + b << std::endl;
return 0;
}`,
},
{
language: "C++",
name: "C++ 编译错误",
expect: JudgeStatus.COMPILE_ERROR,
code: `int main() { return }`,
},
]
const names: Record<number, string> = {
[JudgeStatus.COMPILE_ERROR]: "CE",
[JudgeStatus.WRONG_ANSWER]: "WA",
[JudgeStatus.ACCEPTED]: "AC",
[JudgeStatus.CPU_TIME_LIMIT_EXCEEDED]: "TLE(cpu)",
[JudgeStatus.REAL_TIME_LIMIT_EXCEEDED]: "TLE(real)",
[JudgeStatus.MEMORY_LIMIT_EXCEEDED]: "MLE",
[JudgeStatus.RUNTIME_ERROR]: "RE",
[JudgeStatus.SYSTEM_ERROR]: "SE",
}
const label = (code: number) => `${names[code] ?? "?"}(${code})`
async function runCase(item: Case) {
const response = await fetch(new URL("/judge", url), {
method: "POST",
headers: {
"content-type": "application/json",
"X-Judge-Server-Token": token,
},
body: JSON.stringify({
language_config: languageConfigs[item.language],
src: item.code,
max_cpu_time: item.cpu ?? 3000,
max_memory: item.memory ?? 128 * 1024 * 1024,
test_case: sumTestCase,
output: false,
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
}),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = (await response.json()) as {
err: string | null
data: unknown
}
// 编译失败走 err 通道,不会有逐测试点的结果
if (body.err === "CompileError") return { result: JudgeStatus.COMPILE_ERROR }
if (body.err) throw new Error(`${body.err}: ${JSON.stringify(body.data)}`)
const results = body.data as { result: number; cpu_time: number }[]
// 多个测试点取最坏的那个,和 run.ts 的口径一致
const failed = results.find((r) => r.result !== JudgeStatus.ACCEPTED)
return failed ?? results[0]!
}
/** 判题机刚重建时 gunicorn 还没起来,先等它 —— 否则整屏都是连接被关。 */
async function waitReady() {
for (let i = 0; i < 60; i++) {
try {
const response = await fetch(new URL("/ping", url), {
method: "POST",
headers: { "X-Judge-Server-Token": token },
})
if (response.ok) return
} catch {
// 还没起来,接着等
}
await Bun.sleep(500)
}
console.error(`连不上判题机 ${url}(等了 30 秒)`)
process.exit(2)
}
await waitReady()
let failures = 0
console.log(`判题机 ${url}\n`)
for (const item of cases) {
try {
const got = await runCase(item)
const pass = got.result === item.expect
if (!pass) failures++
const time = "cpu_time" in got ? ` ${got.cpu_time}ms` : ""
console.log(
`${pass ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"} ${item.name.padEnd(32)}` +
` 期望 ${label(item.expect).padEnd(10)} 实得 ${label(got.result)}${time}`,
)
} catch (error) {
failures++
console.log(`\x1b[31m✗\x1b[0m ${item.name.padEnd(32)} ${error}`)
}
}
console.log(
failures === 0
? `\n\x1b[32m全部 ${cases.length} 条通过\x1b[0m`
: `\n\x1b[31m${failures} / ${cases.length} 条不对\x1b[0m`,
)
process.exit(failures === 0 ? 0 : 1)
+1 -1
View File
@@ -27,7 +27,7 @@ bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里
## 只有三种语言真的会跑 ## 只有三种语言真的会跑
判题机只认 `AST_SUPPORTED_LANGUAGES`C / C++ / Python3)。别的语言配了规则一条都不会跑, 判题机只认 `AST_SUPPORTED_LANGUAGES`C / C++ / Python)。别的语言配了规则一条都不会跑,
所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」—— 所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」——
**看得见却不检查**比没有更糟。 **看得见却不检查**比没有更糟。
+17
View File
@@ -64,6 +64,23 @@ docker/deploy.sh --no-build # 只改了 env / compose 时跳过构建
有没有生效、库指向和形态是否自洽、判题机运行目录有没有和旧栈分开、外接的 有没有生效、库指向和形态是否自洽、判题机运行目录有没有和旧栈分开、外接的
postgres / redis 是否活着。起完再跑四条冒烟,**题目数是 0 也中止** —— 那意味着连错库了。 postgres / redis 是否活着。起完再跑四条冒烟,**题目数是 0 也中止** —— 那意味着连错库了。
### 判题镜像是自建的,不在 registry 上
`compose.*.yml` 里的 `oj2-judge-2` 是本机构建的(上游 JudgeServer 停更在
2024-04,官方镜像的编译器停在 gcc-13)。**新机器或换镜像之后,先把镜像 load 进去
再部署**
```bash
# 本机
docker/judge/build.sh --save
scp dist/oj2-judge-2.tar root@服务器:/root/OJDeploy/
# 服务器 / 机房各来一次(两个站点各有各的判题沙箱)
docker load -i /root/OJDeploy/oj2-judge-2.tar
```
忘了这一步,`deploy.sh` 起栈时会去 pull 一个不存在的镜像并失败(响亮地失败,
不会静默降级)。构建、回滚和工具链版本表见 `docker/judge/README.md`
### 迁移在起栈之前跑 ### 迁移在起栈之前跑
`deploy.sh` 在「构建镜像」之后、「起栈」之前跑 `oj2-api migrate`,失败就中止部署 `deploy.sh` 在「构建镜像」之后、「起栈」之前跑 `oj2-api migrate`,失败就中止部署
@@ -216,7 +216,7 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
|---|---| |---|---|
| `shared/store/collab.ts` | pinia store:持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间 | | `shared/store/collab.ts` | pinia store:持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间 |
| `shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 | | `shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 |
| `shared/components/CollabModal.vue` | 老师端协作模态框 | | ~~`shared/components/CollabModal.vue`~~ | 老师端协作模态框。**2026-09-16 删除**,见文末的变更 |
| `shared/composables/collab.ts` | Y.Doc 与 yCollab 绑定,取代 `sync.ts` | | `shared/composables/collab.ts` | Y.Doc 与 yCollab 绑定,取代 `sync.ts` |
### 5.2 改动 ### 5.2 改动
@@ -298,3 +298,89 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
的 Django 后端),其中「Yjs + y-webrtc for collaborative editing **in the flowchart 的 Django 后端),其中「Yjs + y-webrtc for collaborative editing **in the flowchart
editor**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把 editor**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把
实时特性与环境变量两节改对,整份文档的翻新另计。 实时特性与环境变量两节改对,整份文档的翻新另计。
## 变更记录
### 2026-09-16:教师端不再用弹框,接单直接跳题目页
老师点求助列表里的一条 → 跳到 `/problem/:id`,就在**页面自带的那个编辑器**里
和学生同步。`CollabModal.vue` 删除。
为什么改:
- **弹框按一下 Esc 就关,协作跟着结束。** 上课时太容易误触 —— `show` 的 setter
`leave()` 是唯一的关闭语义,n-modal 的 Esc / 点遮罩都会走到它。
现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。
- 弹框里那份题面是只读复制品(不能跑测试、不能提交),老师还是得另开一个标签页。
跳到真页面之后,老师看到的就是学生看到的那一页。
- 少一个 CodeMirror 实例:`setBinaryHandler` 那个单例槽位现在只有
`SyncCodeEditor` 一个使用者,教师端也不用再为了首屏体积把整套编辑器拆成
异步 chunk(页面本来就要加载它)。
连带的口径:
- `SyncCodeEditor` 多一个 `problemId` prop,**房间的题号对不上就不绑**(老师接单时
可能停在别的题上);种子内容仍然只来自学生端(教师端传 `null`)。
- 协作期间教师那个编辑器的语言跟着 `room.language` 走,语言选择器禁用;
草稿不写 localStorage(那会儿编辑器里是学生的代码),协作结束后读回教师自己的草稿。
- 协作中的题目页强制走代码编辑器分支,不受教师自己选的「流程图」影响。
### 2026-09-16(同日,第二轮):`leave` 分两种语义
「老师走开」和「老师处理完了」原来都走 `leave``teardownRoom("done")` → 删掉求助
记录,于是老师点一下「提交信息」,学生就得重新举手,而且他看到的是「协作已结束」,
会以为自己被处理完了。
协议加一个 reason
| 前端 | 服务端 | 求助记录 | 对面看到 |
|---|---|---|---|
| `leave("done")`(点「结束协作」) | `teardownRoom("done")` | 删除 | 协作已结束 |
| `leave("left")`(离开这道题的页面) | `teardownRoom("peer_left", side, ws)` | 教师走→**退回排队**;学生走→删除 | 老师暂时离开,已重新排队 |
`"left"` 复用的是老师掉线那条路(`requeueAfterTeacherGone`)—— 两件事语义相同。
**发起方收到的 reason 是 `done`**`teardownRoom``initiator` 参数),他不该看到
一句「对方离开了」。
配套:
- 协作中的「提交信息」改成新标签打开 —— 那是教师工具栏上唯一会跳路由的按钮,
而「看看这学生都交了什么」恰好是协作时最常点的一个;
- **学生排队期间离开那道题 → 自动取消求助**`SyncCodeEditor`
`cancelPendingIfLeaving`)。那条求助说的是「我卡在这道题」,人走了就不成立;
原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本不会绑,
老师对着空编辑器敲字,两边都没有提示。只认「离开」这件事、不跟房间状态挂钩:
老师走开时服务端刚补上的那条 pending 不能被它顺手取消掉;
- **协作中教师的语言选择跟着 `room.language` 走**(写 codeStore,不只是改编辑器高亮),
协作结束后还原成他自己那档。只改高亮的话,老师会拿着自己那档语言提交学生的代码
(学生写 C、老师选的是 Python,当场 CE),工具栏还可能显示「提交流程图」。
- 「重置代码」协作中不出现在菜单里:`v-model` 一写回就顺着 Yjs 同步过去,
等于一键清空学生的作业。
- 接单时 `resetScreenMode()`:分屏模式是「题目」或「自测」时右侧编辑器根本没挂出来,
停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 `detail.vue`
`init()` 会重置,同页不会)。
- 老师已经在一个房间里时,前端也拦住接下一单(服务端本来就拦)—— 不拦的话前端
已经跳到新题目上,等于把手上那场协作断掉。
### 2026-09-16(同日,第三轮):文案
`type: "error"` 的 message **会被前端原样弹成 toast**store 的 `case "error"`
`setNotice``message.info`),所以服务端那几条协议层错误从英文改成中文
`Invalid problemId` → 「题号不对,请刷新页面重试」等四条)。**以后往这个通道加
错误消息一律写中文、写成学生看得懂的话。**
「请先**退出**当前协作」改成「请先**结束**当前协作」,和按钮「结束协作」、前端那道
拦截统一成一个词。
`room_closed` 的提示语按 reason × 角色展开,原来四种情形共用一句「协作已结束」:
| reason | 谁收到 | 老师看到 | 学生看到 |
|---|---|---|---|
| `done` | 对面(有人点了「结束协作」) | 已结束这次协作 | 老师已结束这次帮忙 |
| `self_left` | **发起方**(自己离开了这道题) | 你已离开这道题,求助退回队列了 | 你已离开这道题,协作结束 |
| `peer_left` | 对面(对方离开了这道题) | 学生离开了这道题,协作已结束 | 老师暂时离开,你的求助已重新排队 |
| `peer_offline` | 对面(掉线 / 转发失败) | 对方已断开连接 | 对方已断开连接 |
`self_left` 是这一轮新加的:发起方原来跟对面收同一条 `done`,于是学生自己切走了
却看到「老师已结束这次帮忙」—— 一句假话。发起方收到的 reason 由 `teardownRoom`
`initiator` 决定。
+1 -1
View File
@@ -13,7 +13,7 @@
"db:generate": "bun run --filter '@oj2/api' db:generate", "db:generate": "bun run --filter '@oj2/api' db:generate",
"db:migrate": "bun run --filter '@oj2/api' db:migrate", "db:migrate": "bun run --filter '@oj2/api' db:migrate",
"db:down": "docker compose -f docker/compose.dev.yml down", "db:down": "docker compose -f docker/compose.dev.yml down",
"fmt": "prettier --write apps/api/src apps/web/src apps/web/tests packages/contract/src apps/api/drizzle.config.ts apps/web/vite.config.ts" "fmt": "prettier --write apps/api/src apps/web/src apps/web/tests packages/contract/src apps/api/drizzle.config.ts apps/web/vite.config.ts docker/judge/smoke.ts"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.9.6", "prettier": "^3.9.6",
+32
View File
@@ -19,6 +19,8 @@ export const metricsSchema = z.object({
now: z.string(), now: z.string(),
latest: z.string(), latest: z.string(),
first: z.string(), first: z.string(),
/** 有提交的日历天数(东八区),不是首末提交之间跨了多少天 */
activeDays: z.number().int(),
}) })
export const rankProfileSchema = z.object({ export const rankProfileSchema = z.object({
@@ -56,6 +58,34 @@ export const activityRankItemSchema = z.object({
count: z.number().int().nonnegative(), count: z.number().int().nonnegative(),
}) })
/**
* `solvedCount` ** AC ** AC
*
*
*/
export const weeklyRankItemSchema = z.object({
user: sampleUserSchema,
solvedCount: z.number().int().positive(),
submissionCount: z.number().int().nonnegative(),
rank: z.number().int().positive(),
})
export const weeklyRankSchema = z.object({
/** 本周一 0:00(东八区)对应的 UTC 时刻,前端拿它显示统计区间 */
start: z.string(),
scope: z.enum(["global", "class"]),
/** `scope = "class"` 时是我的班号,全服榜为 null */
className: z.string().nullable(),
/** 本周有新增 AC 的总人数。榜面只回前几名,这个数是全量 */
total: z.number().int().nonnegative(),
results: z.array(weeklyRankItemSchema),
/**
* ** null**
* 1
*/
me: weeklyRankItemSchema.nullable(),
})
export const problemRankSchema = z.object({ export const problemRankSchema = z.object({
className: z.string(), className: z.string(),
rank: z.number().int(), rank: z.number().int(),
@@ -72,6 +102,8 @@ export type RankProfile = z.infer<typeof rankProfileSchema>
export type UserRank = z.infer<typeof userRankSchema> export type UserRank = z.infer<typeof userRankSchema>
export type MyRank = z.infer<typeof myRankSchema> export type MyRank = z.infer<typeof myRankSchema>
export type ActivityRankItem = z.infer<typeof activityRankItemSchema> export type ActivityRankItem = z.infer<typeof activityRankItemSchema>
export type WeeklyRankItem = z.infer<typeof weeklyRankItemSchema>
export type WeeklyRank = z.infer<typeof weeklyRankSchema>
export type Metrics = z.infer<typeof metricsSchema> export type Metrics = z.infer<typeof metricsSchema>
export type PublicProfile = z.infer<typeof publicProfileSchema> export type PublicProfile = z.infer<typeof publicProfileSchema>
+50
View File
@@ -123,6 +123,55 @@ export const HINT_MIN_FAILURES = 3
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) }) export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
/**
* AI **key `ai_hint.diagnosis.tag`
* key **
* `label`
*
* C / Python `output_format`
* WA
*/
export const HINT_ERROR_TAGS = {
syntax: "语法错误",
input_format: "输入读取方式不对(格式、分隔、个数)",
output_format:
"输出格式不对(多余的输入提示语、全角/半角符号、多余空格或换行、小数位数)",
condition: "条件判断写错(比较符、漏了分支)",
loop_bound: "循环次数或边界不对(差一)",
integer_division: "整数除法或取余用错",
type_overflow: "数据类型不对或溢出(int 不够、浮点精度)",
uninitialized: "变量没初始化,或累加器没清零",
missing_case: "漏了特殊情况(0、负数、边界值)",
runtime_error: "运行时错误(下标越界、除以零)",
timeout: "超时(算法太慢或死循环)",
wrong_approach: "思路整体不对",
other: "其他,或者看不出来",
} as const
export type HintErrorTag = keyof typeof HINT_ERROR_TAGS
/**
* ****
*
* zod
*/
export const hintDiagnosisSchema = z.object({
tag: z.enum(
Object.keys(HINT_ERROR_TAGS) as [HintErrorTag, ...HintErrorTag[]],
),
/** 问题所在的行号区间(从 1 起,含两端);说不准就是 null */
lines: z.tuple([z.number().int().min(1), z.number().int().min(1)]).nullable(),
confidence: z.enum(["high", "low"]),
})
export type HintDiagnosis = z.infer<typeof hintDiagnosisSchema>
/**
* AI POST /ai/hint/:id/feedback id /ai/hint
* `done`
*/
export const aiHintFeedbackRequestSchema = z.object({ helpful: z.boolean() })
export const classAnalysisRequestSchema = z.object({ export const classAnalysisRequestSchema = z.object({
comparison: z.record(z.string(), z.unknown()), comparison: z.record(z.string(), z.unknown()),
}) })
@@ -181,6 +230,7 @@ export type LoginSummary = z.infer<typeof loginSummarySchema>
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema> export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
export type AiHintRequest = z.infer<typeof aiHintRequestSchema> export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
export type AiHintFeedbackRequest = z.infer<typeof aiHintFeedbackRequestSchema>
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema> export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
export type ClassPkAnalysisRequest = z.infer< export type ClassPkAnalysisRequest = z.infer<
typeof classPkAnalysisRequestSchema typeof classPkAnalysisRequestSchema
+1
View File
@@ -8,6 +8,7 @@ export * from "./common"
export * from "./content" export * from "./content"
export * from "./contest" export * from "./contest"
export * from "./flowchart" export * from "./flowchart"
export * from "./judge-status"
export * from "./language" export * from "./language"
export * from "./problem" export * from "./problem"
export * from "./problemset" export * from "./problemset"
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod"
/**
* ****
*
* 12 `submission.result`
* `judge/status.ts`
* `utils/constants.ts` `SubmissionStatus`
*/
export const JudgeStatus = {
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
// 同名的类型:原来契约里就有 `type JudgeStatus`(各处按类型引用),值与类型同名合并
export type JudgeStatus = JudgeStatusValue
export const judgeStatusSchema = z.literal(
Object.values(JudgeStatus) as [JudgeStatusValue, ...JudgeStatusValue[]],
)
+37 -4
View File
@@ -8,14 +8,24 @@ import { z } from "zod"
* `Record<string, …>` * `Record<string, …>`
* **** languages.ts * **** languages.ts
* *
* 6 **** `Python2` * ****
* 3 Python2 * `C` / `C++` / `Python``languages.ts`
* SQL`Java`(44 ) / `Golang`(15) / `JavaScript`(3)
*
*
*
* Java / JavaScript / Golang 2026-09
* `apps/api/src/judge/languages.ts`
*
* `Python2` / `Python3` **** 0019
* 104530 937 1235 `Python`
* localStorage
* `normalizeLanguage()` parse
*/ */
export const judgeLanguageSchema = z.enum([ export const judgeLanguageSchema = z.enum([
"Python2",
"Python3",
"C", "C",
"C++", "C++",
"Python",
"Java", "Java",
"JavaScript", "JavaScript",
"Golang", "Golang",
@@ -40,3 +50,26 @@ export const problemLanguageSchema = z.enum([
export type JudgeLanguage = z.infer<typeof judgeLanguageSchema> export type JudgeLanguage = z.infer<typeof judgeLanguageSchema>
export type ProblemLanguage = z.infer<typeof problemLanguageSchema> export type ProblemLanguage = z.infer<typeof problemLanguageSchema>
/**
* ****0019
*
*
* 1. localStorage `Python3`线
* 2. BullMQ
* 3. `Python` ****
*
*/
const LANGUAGE_ALIASES: Record<string, ProblemLanguage> = {
Python2: "Python",
Python3: "Python",
}
/** 把可能是旧值的语言名归一化;认不出来返回 null,由调用方决定怎么兜底。 */
export function normalizeLanguage(value: unknown): ProblemLanguage | null {
if (typeof value !== "string") return null
const parsed = problemLanguageSchema.safeParse(
LANGUAGE_ALIASES[value] ?? value,
)
return parsed.success ? parsed.data : null
}
+3 -3
View File
@@ -106,7 +106,7 @@ export const astRuleSchema = z.object({
max: z.number().int().optional(), max: z.number().int().optional(),
}) })
/** 按语言分组:`{ Python3: [...], C: [...] }`,键是 languages 里的语言名 */ /** 按语言分组:`{ Python: [...], C: [...] }`,键是 languages 里的语言名 */
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema)) export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/** /**
@@ -183,7 +183,7 @@ export const AST_NODE_TARGETS_BY_LANGUAGE: Record<
lambda: { label: "lambda 表达式", node: "lambda_expression" }, lambda: { label: "lambda 表达式", node: "lambda_expression" },
using: { label: "using 声明", node: "using_declaration" }, using: { label: "using 声明", node: "using_declaration" },
}, },
Python3: { Python: {
for_loop: { label: "for 循环", node: "for_statement" }, for_loop: { label: "for 循环", node: "for_statement" },
while_loop: { label: "while 循环", node: "while_statement" }, while_loop: { label: "while 循环", node: "while_statement" },
if_statement: { label: "if 条件", node: "if_statement" }, if_statement: { label: "if 条件", node: "if_statement" },
@@ -260,7 +260,7 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<
C: C_OPERATOR_TARGETS, C: C_OPERATOR_TARGETS,
// `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token) // `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token)
"C++": { ...C_OPERATOR_TARGETS, "<<": "<<", ">>": ">>" }, "C++": { ...C_OPERATOR_TARGETS, "<<": "<<", ">>": ">>" },
Python3: { Python: {
"+": "+", "+": "+",
"-": "-", "-": "-",
"*": "*", "*": "*",
+39 -16
View File
@@ -1,23 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema } from "./common" import { paginatedSchema } from "./common"
import { judgeStatusSchema, type JudgeStatus } from "./judge-status"
import { problemLanguageSchema } from "./language" import { problemLanguageSchema } from "./language"
export const judgeStatusSchema = z.union([
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
z.literal(10),
])
/** /**
* `submission.info` JSONB **** * `submission.info` JSONB ****
* *
@@ -93,6 +79,36 @@ export const statisticInfoSchema = z.looseObject({
.optional(), .optional(),
}) })
/**
* `submission_trace`****
*
*
*
*
* **** AC
* `since_prev_ms`
*/
export const submissionTraceSchema = z.object({
/** 活跃编辑时长:相邻两次编辑间隔不超过 60 秒才累加,页面不可见时不计 */
activeMs: z.number().int().min(0).max(1e8),
/** 打开这道题(或上次提交)到这次提交的墙钟时长 */
sinceOpenMs: z.number().int().min(0).max(1e9),
/** 键入、输入法上屏、补全插入的字符数 */
typedChars: z.number().int().min(0).max(1e7),
/** 粘贴、从外部拖入的字符数 */
pastedChars: z.number().int().min(0).max(1e7),
pasteCount: z.number().int().min(0).max(1e5),
/** 单次最大粘贴的字符数 */
maxPaste: z.number().int().min(0).max(1e7),
deletedChars: z.number().int().min(0).max(1e7),
/** 页面切到后台的次数(切标签页、切窗口、最小化)。只作辅助,别单独拿来说事 */
blurCount: z.number().int().min(0).max(1e5),
/** 这一段开始时编辑器里已有的字符数(本地草稿 / 模板 / 上次提交后的代码) */
initialLen: z.number().int().min(0).max(1e7),
/** 提交时这道题正在课堂协作中。老师替学生交的那条也会是 true,统计时要排掉 */
collab: z.boolean(),
})
export const createSubmissionRequestSchema = z.object({ export const createSubmissionRequestSchema = z.object({
problemId: z.number().int().positive(), problemId: z.number().int().positive(),
/** /**
@@ -116,6 +132,13 @@ export const createSubmissionRequestSchema = z.object({
* *
*/ */
problemSetId: z.number().int().positive().optional(), problemSetId: z.number().int().positive().optional(),
/**
* submissionTraceSchema****`.catch`
* safeParse 400
*
*
*/
trace: submissionTraceSchema.optional().catch(undefined),
}) })
export const createSubmissionResponseSchema = z.object({ export const createSubmissionResponseSchema = z.object({
@@ -397,8 +420,8 @@ export const formatCodeRequestSchema = z.object({
export const formatCodeResponseSchema = z.object({ code: z.string() }) export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type StatisticInfo = z.infer<typeof statisticInfoSchema> export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.infer< export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema typeof createSubmissionRequestSchema
> >