Compare commits

..
41 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 6b37a4de80 style(提交详情): 答错时的例子对比换成新样子,三格横排、不一样的那行两边标浅黄
Deploy / deploy (push) Successful in 1m0s
- 和题目页结果页签同一套:黄色小节标题「例子 N 没对上」、正文大小的说明、输入灰 / 正确输出绿 / 学生的输出红
- 这一页一栏 1200 宽,三格横着并排,正确输出和学生的输出一行对一行,不一样的那行两边都垫浅黄
- 窄于 600(手机、课堂任务的弹窗)自动退回竖排

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:17:08 +08:00
xuyueandClaude Opus 5.5 566599614a style(结果页签): 分段、说明、对比改成和题面同一套样子;做对后「接着做」直接给下一题
Deploy / deploy (push) Successful in 1m0s
- 提交结果 / 运行例子 / 自己输入改成浅灰底的分段按钮;状态大字和测试点方块放大一档
- 哪里不对一律是黄色小节标题(例子 1 没对上、错在没给你看的测试点、第 2 行出错),说明用正文大小
- 答错的对比竖着排成灰 / 绿 / 红三格,和题面的例子一个样子;运行时错误把出错那一行放进红格
- 做对了去掉浅绿卡片,改成「接着做」一句话加写着下一题题号题名的大按钮
- 提交详情页的答错对比不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:09:31 +08:00
xuyueandClaude Opus 5.5 472ec69328 style(题目页): 顶上三层条合成一条、课堂和比赛改成左边竖栏;题面小标题和例子重新设计
Deploy / deploy (push) Successful in 58s
- 课堂、比赛在电脑上挪到左栏最左一条竖栏,只写题号(题名鼠标停上去看),顶上从 148px 降到 104px,
  页签行和编辑器工具栏对齐成一根线;专题条去掉蓝底、改成 40px 细横条
- 工具栏按钮只剩三种分量:实心提交、浅绿运行例子、其余不带框;统计/点评/我的提交改成图标 + 字
- 题面:题号题名难度状态一行;小标题改成线条图标 + 绿字 + 细线(语法要求、提示用黄色);
  去掉空段落、老师写的大标题降成粗体;输入输出上下排;来源缩成底部一行灰字;SQL 去掉图例
- 例子改成「输入 → 输出」:「无」写成「不用输入」、行首空格画成点、长句折行缩进两格写全,
  运行例子后序号上标 ✓ / ✗

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:52:58 +08:00
xuyueandClaude Opus 5.5 388f864aec style(题目页): 相似题目推荐的颜色太浅太淡,题目名绿得发虚、第二行灰得看不清
Deploy / deploy (push) Successful in 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:12:08 +08:00
xuyueandClaude Opus 5.5 6a1a36049e fix(看板娘): 老师进出题页时那张图带着绿边和两块被切断的碎片,画风也和别的对不上
Deploy / deploy (push) Successful in 59s
上游 state-tool 原图本身就是坏的(另一套画风、绿幕抠图残边、左右硬切),删掉不用,
出题页那组台词改配戴眼镜看电脑那张。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:50:22 +08:00
xuyueandClaude Opus 5.5 95049b6e4c feat(看板娘): 课堂任务页上她会告诉老师谁卡住了、哪种错扎堆、谁在线没动,卡住的人多起来和全班做完时自己开口
Deploy / deploy (push) Successful in 1m0s
- 点她按全班这一刻挑最要紧的一件:全班做完(「改题」)> 同一种错 ≥3 人(「看这堆」「讲第一个」)
  > 有人卡住(「看 xxx 的」直接打开讲错误)> 布置 10 分钟后在线却一道没交 > 做完的在做挑战。
  卡住、错法归堆和任务页同一套口径(cells.ts 的 isStuck、页面上的按错法归堆)。
- 主动开口:某道题卡住的人涨到 3 个、全班刚做完,每件事一节课说一次;刚打开页面那次只记数不说。
  老师电脑不投影,和学生一样至少隔 3 分钟。
- 老师首页说这节课做完几个人,不再是固定的「这节课的提交,筛好了」。
- 这几句也配了图:扎堆拿放大镜、卡住冒汗、做完拉礼花……

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:43:01 +08:00
xuyueandClaude Opus 5.5 b99f619c0a fix(看板娘): 深色模式下气泡一直是白的;睡着时进比赛会一直睡、点不醒;第一次缩到边上时图会被挤变形
- 深色气泡从 11-03 起就没生效过:scoped 样式把 `:global(html.dark) .bubble` 编译成了只剩 `html.dark`。
- 睡着时进比赛先叫醒;她主动开口也算醒;有人举手、卡住了这类要紧的事,点醒时直接说正事,不先说「没睡」。
- 三张探头图先下好;图没到之前按比例缩着放,不拉伸。
- 立绘没下到(断网、部署后老页面拿旧地址)就接着显示上一张,不再变成空白。
- 跨天的旧账被生闷气、越点越烦顶掉时不算说过,下次点开还会说。
- 左右探头时气泡尖角对准她的头;没有 requestIdleCallback 的浏览器(iPad Safari)不再报错。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:32:25 +08:00
xuyueandClaude Opus 5.5 bcd328a196 feat(看板娘): 换成一整套全身立绘,说什么话摆什么表情;闲着会做自己的事、久了打盹,还能藏到屏幕边上只探个头
立绘换成 Sutera-Diffusus/dsh-whale-musume 的 92 张(MIT,许可原文放在图旁边),全部用上:
- 每组台词配一两张:做对了笑、做错了垮脸、编译错误慌、炸毛、背过身冒黑气、被摸完头脸红冒爱心……
- 被拎着上半身拖和揪着尾巴拖是两张;一直连着摸会被摸得笑出来。
- 5 分钟没人碰换一张日常(做饭、钓鱼、画画、听歌……,冬天下雪、春夏打伞,晚上穿睡衣),
  20 分钟睡着,点她先被吵醒说一句。比赛页不换。
- 往屏幕外拖过一截再松手,就缩进下、左、右那条边只探个头,往外一拖又出来。
- 节日当天换节日装(春节、中秋、圣诞、万圣、情人节)。
只有平时那张跟页面一起下,别的用到才下、下好再换,不闪白。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:24:00 +08:00
xuyueandClaude Opus 5.5 aa8a3b8bfc feat(看板娘): 她会记得前几天被你惹生气、被摸过头,今天第一次点她时翻一次旧账;好感升档那天说一句「熟了一点」
- 只看 7 天内、今天以前的事,同一件事只翻一次(之后说过那句就不再提);顺序是升档 > 惹生气 > 摸头。
- 只在闲聊类情况(空白题目页、题目列表、打招呼、夸人)翻,错了、卡住时照常说正事。
- 好感档位照样不显示,「熟了一点」是唯一的暗示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:00:40 +08:00
xuyueandClaude Opus 5.5 47c0b5464d feat(看板娘): 做对的题越多,她说话越软、也越耐烦;点她多少次不算
Deploy / deploy (push) Successful in 57s
好感按一共做对几道分五档(不到 10 / 10 / 25 / 40 / 100 道,含比赛题,和成就同一个数),
档位不显示。门槛按最近 60 天交过题的 378 个学生定的:前四档大约各占四分之一,最高一档是前一成。
- 每档多一组自己的话,越往上越软,高档也抽得到低档的;只掺进闲聊、夸人、空白题目页、
  题目列表这几类情况,现有台词不改。
- 越熟越耐烦:最低档点到第 7 次背过身,最高档到第 10 次。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:26:56 +08:00
xuyueandClaude Opus 5.5 3987b83220 feat(看板娘): 连着刷她会越来越不耐烦,刷多了背过身生闷气,摸摸头才哄好;台词一组听完才重复
上线第一天六成的点开离上一次不到 15 秒,学生是在一句一句地刷台词。
- 两次点开隔不到 20 秒算连着:第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去生 30 秒闷气,
  期间点她只蹦不说话、也不主动开口;连点摸到头就哄好。有人举手、卡住了这类要紧的照说。
- 台词按副牌抽,一组听完才会重复(原来只是不连着重复,第一天就有三成是听过的)。
- 空白题目页去掉「运行例子」只留「举手求助」,「做过了」去掉「下一道」:第一天都没人点,
  没按钮的气泡 5 秒自己收起,不一直挡着。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:21:43 +08:00
xuyueandClaude Opus 5.5 f6f7a0c3af fix(看板娘): 把看板娘拖到右上角时,成就弹框会压在她头上,现在弹框让到她左边
Deploy / deploy (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 +08:00
xuyueandClaude Opus 5.5 cccf7a37be feat(成就): 多了 9 个好拿的档和「挑战成功」一条线,比赛里做的题也算,这学期八成学生多拿到成就
- 补档:做对 20 道、第一道中等题、交满 30 次、5 天交过题、一次就对 5 道、错 3 次后做对、一天做对 5 道;
  新线「挑战成功」1 次 / 5 次
- 比赛里的提交也计入成就(比赛其实是单班测验,2612 班整班在比赛里做题,原来一个成就都没有);
  参加过几场比赛改成判题时就更新,以前只在后台重扫时刷新
- 奖杯收藏家从 15 个提到 20 个
- 0044 迁移:按新口径从提交记录重数指标,给达标的人补发(已经拿到的不收回)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 +08:00
xuyueandClaude Opus 5.5 1fdee94812 docs(部署): 记下 runner 会「假在线」以及看门狗兜底
gitea-runner 5.2.4 拉任务超时一次后就不再拉,Gitea 显示没有在线的运行器。开发机上
fetch_timeout 放到 30s,另加用户级 gitea-runner-watchdog.timer 每 5 分钟检查、卡住就重启。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:36:48 +08:00
xuyueandClaude Opus 5.5 aa7e0a6eb8 feat(看板娘): 接上全班进度、难度、提示、知识点、排名等数据,说的话更贴每个人
Deploy / deploy (push) Successful in 50s
- 题目页:这节课同班做对的人多就说「全班已经 N 个人做对了」、都卡着就安慰、第一个做对的夸一句;
  通过率不到三成的题错了说「本来就难」、高于七成的说「再看看题」;AI 提示开了没用就提一句;
  做对了还有挑战就问敢不敢;流程图分低、拿 S 各有话
- 常点「太难了」的学生错了换软话;这周交得多、例子跑得少的念一句;卡住却从不举手的劝举手;
  这周要提示多的劝先自己想
- 首页(这节课没要做的时)每次戳随机说一件:知识点差几道升级、周榜差几道超过前一名、
  今天明天的比赛、三天内的新公告;两周以上没来说「好久不见」,只说一回
- 排名页第一名、差前一名几道;成就页差一点的;自学没全对的练一练;专题差最后一道拿奖章
- 运行例子、自己输入、举手也记进 mascot_event(trial、help),GET /mascot/profile
  多给「太难了」次数和这一周交、跑例子、举手、要提示的次数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:33:35 +08:00
xuyueandClaude Opus 5.5 81ef4dca6b feat(看板娘): 记得你常错在哪,题目页上会主动开口,互动都记下来
Deploy / deploy (push) Successful in 51s
- 按学生的行为挑话:最近 20 次提交里某类错特别多(运行出错细到 NameError、TypeError、
  ValueError、EOFError),再错这一类时点名说;今天做对第 3 道起偶尔数一数;
  第一次戳她有见面的话,戳得多了说「又是你」
- 题目页上主动开口:同一题错到第 5 次、连着 3 次没跑例子就交还错了、又犯老毛病、
  15 分钟没交没跑。比赛里不说,至少隔 3 分钟,同一件事一题一次,10 秒后收起
- 互动记进新表 mascot_event(0043):点开、点按钮、主动开口、摸头、点烦、被拖走;
  只记看板娘这边的事,每人每分钟最多 30 条
- GET /mascot/profile 给最近的错类、今天做对几道、点过几次;门槛按本机生产快照定
  (266 人里运行出错 ≥3 的 87 人、答案错误 ≥5 的 82 人)
- 编辑过程(粘贴)数据学生侧不用,免得冤枉人

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:22:58 +08:00
xuyueandClaude Opus 5.5 ef3c53c671 feat(看板娘): 点她会按页面说一句傲娇的话,再给两三个能直接办事的按钮
Deploy / deploy (push) Successful in 49s
- 她从不主动开口,点一下才冒气泡;比赛里不弹。台词 326 句(shared/mascot/lines.ts),
  口吻照「小鲸鱼」角色卡,学校里舍掉英文、粗口,「用户」改叫「同学」
- 按情况换话:题目页分没写、没交、例子没对上/对上、答错、编译错误、运行出错、超时、
  错很多次、做对、一次就对、终于对了、以前做过;首页、题目列表、提交、专题、自学、排名等各有一组
- 按钮真的去办:运行例子、我错在哪(切到结果里出错的那一段)、举手、输题号、随便来一道、
  下一道、选题……
- 老师也看得到:有学生举手时任何页面先说这件事;首页、课堂任务、统计、题目页各有一组
- 页面用 useMascotGuide 告诉她现在的情况(shared/mascot/guide.ts),以后按学生行为调整也接在这一层
- 修:连点时像卡住了(快点时按压过渡来不及显示,改成每下播一遍压扁回弹);
  一打开从左上角飞到右下角;去掉单击的摆动

设计稿与台词表:https://claude.ai/artifact/Qz2zPCfq2zCrqmxyms1UGx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:12:23 +08:00
xuyueandClaude Opus 5.5 15966ceb0d feat(看板娘): 前台右下角多了一个能拖、能点的看板娘,连点偶尔会被摸摸头
Deploy / deploy (push) Successful in 50s
形象和交互参照 MeteorNOX/DeepSeek-Balance-Whale-Widget,图片取自它的 assets/
(DSniang1.png 转 webp、rua.gif 原样)。那个仓库的图不在 MIT 范围内,见它的 PROVENANCE.md。

- 拖到哪停哪,靠近四边的四分之一就贴边;在左半边时镜像,脸朝页面中间
- 按下压扁、松手回弹,点一下蹦一下
- 彩蛋:连点时每轮三分之一的机会,在第 4~8 下随机一下被摸头
- 只在电脑宽度显示;位置存本机浏览器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:57:59 +08:00
xuyueandClaude Opus 5.5 0c94f2db87 perf(暗色): 每次打开页面不再白白强制重算两次整页样式
Deploy / deploy (push) Successful in 49s
上一版全站共用一个 useDark 之后,它一进页面仍要把 <html> 的 class 同步两遍,每遍都插
style + getComputedStyle 逼整页重算。class 已经对了就跳过,只有真要切换时才走原来那套。
CPU 降速 6 倍下每页省约 25ms;切换暗色、暗色下刷新、切回亮色都核过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:36:11 +08:00
xuyueandClaude Opus 5.5 f01870c072 fix(烟花): 「表情包」那一种在机房电脑上卡成幻灯片
canvas-confetti 画位图粒子是每帧按「位图边长 × scalar」填一个图案矩形,和表情实际多大
无关。原来位图 64px、scalar 5,表情显示 50px,每个粒子每帧却填 320×320,两团 32 个
粒子一帧三百多万像素。改成位图直接画成 50px、scalar 1、矩阵不缩放:显示大小和轨迹
不变(scalar 只管大小不管运动),填的正好是 50×50。

同样画法在无 GPU 的 Chrome 里复现一帧:17ms → 0.5ms(快的 CPU 核上,旧的已经吃满一帧)。
其余九种是圆点、星星、对勾、爱心这类路径,只填图形本身,没有这个问题。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:22:42 +08:00
xuyueandClaude Opus 5.5 95dc2f24e6 perf(提交列表): 按 ↑ ↓ 换到没看过的一条也不卡了 —— 右栏预取上一条、下一条
Deploy / deploy (push) Successful in 48s
原来换到没缓存的一条要先卸掉代码区、转圈、再整个挂回来,CPU 降速 6 倍下每次卡
60–100ms;走缓存的只换内容不卡。现在这一条加载完顺手把上下两条也拉回来,同一条
只发一次请求(预取还在路上时按到它就接着等),按住 ↓ 划过去的那些行不预取。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:57:34 +08:00
xuyueandClaude Opus 5.5 e511d82f3b perf(暗色): 提交列表、排名首屏卡掉的一秒多回来了 —— 全站共用一个 useDark
VueUse 的 useDark 每个实例挂载时都插一段 style、getComputedStyle 逼整页重算样式,
亮色下它的「没东西要改就跳过」永远不成立。提交列表每行的结果胶囊(useTone)都调它,
一页 30 行就是 30 次整页重算,翻页、换条再来。

改成 shared/composables/dark.ts 的 createGlobalState,auto-import 用 priority 盖掉
@vueuse/core 的同名项,显式 import 的 7 处一并换掉。

生产构建、CPU 降速 6 倍实测:提交列表首屏主线程 2015 → 1092ms(列表出来 1.34 → 0.85 秒),
排名 1950 → 850ms,比赛 1060 → 757ms;右栏换到没看过的一条不再每次卡 100ms。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:55:33 +08:00
xuyueandClaude Opus 5.5 55bdf317f1 fix(语法要求): 「自定义函数」不再把 main 算进去,运算符不再把取地址、指针、模板尖括号当成用了
Deploy / deploy (push) Successful in 55s
- C / C++ 的函数定义排掉 main,下拉改叫「自定义函数」。原来「必须使用」永远通过、「不能使用」永远失败,
  出题页挑战候选里 C 的「用自定义函数再写一遍」也因此一直挖不出来
- 运算符只认二元运算 / 复合赋值 / 自增自减里的 token,一元只认取反。scanf 的 &、int *p、vector<int>、
  *args、import *、**kw、not in、is not、case 1 | 2 不再算「用了」
- Python 的 math.sqrt(4) 算调用了 sqrt()(只认 import 进来的模块)
- Python 的「赋值语句」连 s += 1 一起算,和 C 一个口径
- 保存时拦下负数次数、至少比至多还多的次数规则

现有 19 道题的规则都不受影响:1265 条真实提交重判,结论和改之前一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:29:57 +08:00
xuyueandClaude Opus 5.5 1b730da401 docs(格式化): 补上 learn.css 也会被 vp fmt 和 bun fmt 来回改,以及怎么列出受影响的文件
Deploy / deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:07:14 +08:00
xuyueandClaude Opus 5.5 8c5399755e feat(相似题): 做对中等、困难题后不再只推一串简单题,每道推荐写出和原题共同的标签
- 共同标签一样多时先给难度最接近的,再并列才先简单的(原来一律简单的在前,
  做对困难题时五条里三条多是简单题)
- 每行第二行写共同的标签(知识点在前、主题在后)和做对人数;带类型标签、
  做过没对的红「−」,写法照题目列表
- 题面里的标题和「提示」「来源」一样,结果页签里的和「还能挑战」对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:06:49 +08:00
xuyueandClaude Opus 5.5 da360e2f68 docs(部署): 开发机上的 runner 改成注册在 xuyue 用户下,和 IOT_SIM 共用(多一个 iot-deploy 标签)
Deploy / deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:08:14 +08:00
xuyueandClaude Opus 5.5 3beb039dda fix(部署): 迁移没通过时把镜像名指回原来的,之后再起容器不会换上没迁移的新代码
Deploy / deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:24:06 +08:00
xuyueandClaude Opus 5.5 49643719b0 fix(部署): 开发机上编的后端到线上起不来(缺 libicu),改用官方 bun 编
Deploy / deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:17:36 +08:00
xuyueandClaude Opus 5.5 f1c4e89ec5 chore(部署): push origin 就由开发机编好传上去,不再绕道海外,晚上也是一分钟左右上线
Deploy / deploy (push) Failing after 37s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:08:03 +08:00
xuyueandClaude Opus 5.5 bcadab40b2 chore(部署): 同步到服务器少传约 20%,线路断了自动续传,不再一直卡着
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:50:42 +08:00
xuyueandClaude Opus 5.5 41d2e46aa9 perf(排名): 打开排名页快一些,看「全服 · 全部」从 0.27 秒降到 0.08 秒
Deploy / deploy (push) Canceled after 0s
做对时刻在库里换成毫秒、按人聚成数组再取回,不再一行一个 timestamptz
逐条解析(全服全部历史六万多行,光解析就占 150ms)。名次与原来完全一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:32:32 +08:00
xuyueandClaude Opus 5.5 f06bb063b4 feat(顶栏): 导航最左边加「首页」,和点站名一样回到首页
没登录时不显示:那时首页就是题目列表,和「题目」是同一页。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:36:41 +08:00
xuyueandClaude Opus 5.5 4a4cede99f style(专题): 列表没有「布置中」时不再另起一行「专题 14 个」,数量写在标题旁边
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:34:59 +08:00
xuyueandClaude Opus 5.5 91057c1096 fix(课堂任务): 先画一节、再用这几道题写代码时,老师这边也认没画图的还没做完
- 任务页格子、老师首页「全做对」和每道题做对几人,按今天几节里同一道题合起来的要求算
  (和学生那边同一份口径,挪进 services/lesson.ts 的 todayNeeds):写代码那节只交对了代码、
  没画图的,格子是「缺图」,不再打 ✓。后台和数据统计还是一节一行、各数各的
- 学生课堂条上「几人做对」也按合起来的要求数,不再把自己没做完的算进去
- 先画再写只交对了代码、图还没画的格子,不再挂挑战的圈
- 题目页标题下:流程图没画到 A 也写「流程图 C 级」(橙色),不再写「还没交过」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:32:57 +08:00
xuyueandClaude Opus 5.5 c3f1413545 fix(课堂任务): 后台改一节不再把它排到同一分钟布置的另一节前面;学生管理员算进统计的课
- 后台「改」时刻只填到分,原来一保存就把布置时刻截成整分:先画流程图、紧接着再开一节写代码,
  只改了做什么,「今天最后一节」就换回了画流程图那节。没改时刻就留着原来的
- 数据统计「一行一节课」只数 Regular User,学生管理员的提交不算,和「做完 / 全班」的花名册对不上
- 学生「老师改了这节课」:画流程图改成写 C、先画再写改成写代码,原来写「语言 改成 C语言」,
  看不出不用画了;现在写「做法 改成 写代码 · C语言」
- 学生课堂标题按每道题合起来的要求写:先画流程图、再用这几道题写代码时,不再只写「用 C语言做」
- 先画再写的题,切到别的题再回来不再被拽回画布(原来只记最后一道题)
- 课堂任务顶上写代码的课也写「写代码」;讲错误的弹层开着时不再因为 15 秒刷新换了个人

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:26:39 +08:00
xuyueandClaude Opus 5.5 b09d479893 style(课堂任务): 后台列表分清写代码、先画再写、画流程图,能按做法筛
原来写代码当默认值不写出来,只剩「C语言」「Python」,看着和别的分不开。
现在三种都用不同颜色的标签写出来,筛选多一个「做什么」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:05:23 +08:00
xuyueandClaude Opus 5.5 1b703d13ae style(专题): 「题单」改名「专题」,顶栏、列表、详情、后台、成就说法统一
Deploy / deploy (push) Canceled after 0s
只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。
设计稿页名「题单重设计」和已执行的迁移文件保留原样。
线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:12:18 +08:00
xuyueandClaude Opus 5.5 959c326bc9 style(名字): 名字列按去掉 ks 前缀后的长度收窄,不再留一大块空
Deploy / deploy (push) Canceled after 0s
原来的定宽是按 ks2312李鑫楠 定的(110~170px),现在名字最长 4 个字(在读的班 423 人里 5 人)。
只放名字的列收到 80px 左右;混着几个班、名字后面还有班号的(全部提交、全服 / 年级排名、
卡点分析没选班时)108px,最宽的「翁鑫叶炫 2312」95px 也放得下。改了提交列表、排名赛道和周榜、
比赛排名和成绩、题单全班情况、数据统计、后台自学情况和卡点分析、请假名单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:51:28 +08:00
xuyue dbb265b02f Update admin.vue
Deploy / deploy (push) Canceled after 0s
2026-11-02 23:47:05 +08:00
xuyueandClaude Opus 5.5 2bda05a978 feat(名字): 界面上不再显示 ks + 班号前缀,混着几个班的地方名字后面写班级
Deploy / deploy (push) Canceled after 0s
- 学生名字一律只显示名字(老号 ks232张三 显示成 张三),顶栏、首页问候、悬停提示、请假名单、比赛成绩表都一样
- 全服 / 年级排名、全部提交、几个班同时举手时,名字后面淡写班级;提交列表的名字列窄,只写班号(陈长 258)
- AI 班级分析点名「要多关心的同学」时不再带 ks 前缀
- 建号查重补漏:同班同名也比真名(加过数字的张三2、编号制老号);名字自己带数字(王芳1)也认得出被占;
  导入时后端再拦一次同班同名,建号页要等查重结果对上当前名单才能点
- 登录名单换成新接口 /classes/:c/members(名字按界面规则剥,换过班的老号不再露出全名),
  旧接口原样留给上线时还开着旧登录页的机房电脑
- 后台手写老格式 ks232张三 新建用户、没选班时按前缀补班级;PUT /me/profile 只带真名时不再 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:41:49 +08:00
xuyueandClaude Opus 5.5 221d264701 feat(账号): 新建的号不再加 ks + 班号前缀,班级单独选;学生改不了真名了
- 批量建号先选班再贴名单,用户名就是名字;被别的班或注册的号占了自动加数字(xuyue2)。
  查重改成查「这个班里有没有同名的」,老号按剥掉 ks 前缀的名字比
- 后台编辑、新建用户能直接选班级;以前班级只能从用户名的 ks 前缀推,改不了
- 登录点名字时名单接口一并给出用户名,新号老号都登得上;本机记的班级兼容旧的 ks257 写法
- 筛选、抽人、「看看他们的写法」都按班级认人,不再拼 ks 前缀(新号拼出来永远圈不进去)
- 接口 PUT /me/profile 不再收真名:界面早没入口,但早几届有 44 人把真名改成了外号
- 0042:两个被推成非班号班级的老号(xrm、王仁杰)改成没填班级
- 老账号不改名,只认 ks 前缀的地方收在契约 username.ts 一处

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:22:20 +08:00
269 changed files with 18531 additions and 1524 deletions

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物 → rsync 到服务器 →
# 服务器上跑 docker/deploy.sh --prebuilt 起栈。
#
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 oj2-deploy),不在服务器上。
# - 服务器只有 2 核,编一次要 5 分钟左右、光前端就 160 秒,编的时候判题会排队。
# - 原来在 GitHub 上编,海外 → 服务器晚高峰只有十几 KB/s,rsync 一次要五六分钟;
# 开发机 → 服务器实测 5MB 约 1 秒。
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑。
# runner 的装法和配置见 docs/deploy.md「部署 runner」。
#
# host 模式直接用开发机自己的东西:PATH 里的 bun、~/.ssh/config 里的 `debian`(root@服务器)
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout
# (它要去 GitHub 拉 action,在国内反而是最慢的一步)。
#
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows;GitHub 那份改成了只能手动
# 触发的备用路线(开发机不在时用),push 不会在两边各部署一次。
name: Deploy
on:
push:
branches:
- main
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
# 工作流判红。这是有意的:那种改动得有人先确认备份。
workflow_dispatch:
inputs:
allow_destructive:
description: "放行破坏性迁移(DROP TABLE / DROP COLUMN / ALTER TYPE …)。勾之前先确认已备份数据库。"
type: boolean
default: false
# 两次 push 挨得近时,别让两个 deploy.sh 在服务器上同时起栈。
# runner 的 capacity 也是 1,这里再保一道。
concurrency:
group: deploy
cancel-in-progress: false
env:
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
REMOTE_DIR: /root/OJDeploy/OJ2
jobs:
deploy:
runs-on: oj2-deploy
timeout-minutes: 20
steps:
# 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件
# (node_modules 留着,bun install 会对齐它)。
- name: 取代码
env:
SHA: ${{ gitea.sha }}
REPO: ${{ gitea.repository }}
run: |
git init -q .
git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA"
git checkout -q --force FETCH_HEAD
git clean -ffdxq -e node_modules
git log --oneline -1
# 单二进制把运行时一起打进去:这里用的 bun 是哪个,线上跑的就是哪个。
#
# ⚠️ **不能用开发机 PATH 里的 bun。** pacman 装的 bun 是 Arch 自己编的,动态链接系统的
# ICU(libicui18n.so.78),编出来的 oj2-api 到容器里就报找不到这个库 —— 2026-11-03
# 第一次跑就撞上了,好在迁移那步先炸、容器没动。官方发布的 bun 自带 ICU,
# 只依赖 libc,和 GitHub 上 setup-bun 装的是同一个东西。
#
# 所以这里按 packageManager 的版本下载官方二进制(核 sha256),缓存在
# ~/.cache/oj2-deploy/ 下,同一个版本只下一次;再把它放到 PATH 最前面,
# 后面的 bun install / bunx 都走它。
- name: 装 bun
run: |
ver=$(sed -nE 's/.*"packageManager": *"bun@([^"]+)".*/\1/p' package.json)
[ -n "$ver" ] || { echo "::error::package.json 里读不到 packageManager 的 bun 版本"; exit 1; }
dir="$HOME/.cache/oj2-deploy/bun-v$ver"
if [ ! -x "$dir/bun" ]; then
tmp=$(mktemp -d)
base="https://github.com/oven-sh/bun/releases/download/bun-v$ver"
curl -fsSL --retry 3 -o "$tmp/bun-linux-x64.zip" "$base/bun-linux-x64.zip"
curl -fsSL --retry 3 -o "$tmp/SHASUMS256.txt" "$base/SHASUMS256.txt"
(cd "$tmp" && grep ' bun-linux-x64.zip$' SHASUMS256.txt | sha256sum -c -)
unzip -q "$tmp/bun-linux-x64.zip" -d "$tmp"
mkdir -p "$dir"
install -m755 "$tmp/bun-linux-x64/bun" "$dir/bun"
rm -rf "$tmp"
fi
[ "$("$dir/bun" --version)" = "$ver" ] || { echo "::error::$dir/bun 的版本不是 $ver"; exit 1; }
echo "$dir" >> "$GITHUB_PATH"
echo "用 $dir/bun($ver)"
- name: 装依赖
run: bun install --frozen-lockfile
# 输出到 dist/oj2-api,正是 Dockerfile 里 artifacts-prebuilt 阶段要的位置。
# 编完先看它链接了什么:官方 bun 编出来的只依赖 libc 那几个,多出任何一个库
# (比如 libicu)都说明用错了 bun,到容器里一定起不来。在这里拦下,
# 别等服务器上构建完镜像、迁移时才炸 —— 那时 oj2-api:latest 已经被换成坏镜像了。
- name: 编后端
run: |
bun run --filter '@oj2/api' build
ldd dist/oj2-api
extra=$(ldd dist/oj2-api | awk '{print $1}' | grep -vE '^(linux-vdso\.so\.1|libc\.so\.6|libpthread\.so\.0|libdl\.so\.2|libm\.so\.6|/lib64/ld-linux-x86-64\.so\.2)$' || true)
[ -z "$extra" ] || { echo "::error::dist/oj2-api 多链接了这些库,容器里没有:$extra"; exit 1; }
# 默认 production 模式,读 apps/web/.env.production(在版本库里)。
- name: 编前端
run: cd apps/web && bun run build
# 格式、lint、前后端类型检查、路由遮蔽、AST 节点,有一项不过就不部署。
# 必须排在「编前端」之后:前端类型检查要 unplugin 生成的 auto-imports.d.ts /
# components.d.ts,它们不进版本库,vite build 时才生成。
- name: 检查
run: bunx vp run verify
# 排除项和 --no-owner --no-group 的理由见 .github/workflows/deploy.yml 同名一步,
# 一条都不能少(少了 data 就是删库,少了 docker/.env* 下一步自检直接 die)。
# -c 按内容比:每次 checkout 出来的 mtime 都是「刚才」,按 mtime 比会把
# 600 多个没改的文件逐个走一遍增量。
# --delay-updates 全部传完才一起换上去,中途断了服务器上不会留下半新半旧的源码。
# --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用。
- name: 同步到服务器
timeout-minutes: 10
run: |
for attempt in 1 2 3; do
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
--compress --compress-choice=zstd \
--timeout=60 --stats --human-readable \
-e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
--exclude node_modules \
--exclude .git \
--exclude data \
--exclude 'docker/.env*' \
./ "$REMOTE:$REMOTE_DIR/" && exit 0
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
sleep 10
done
exit 1
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出。
# 破坏性迁移会被拦下并退出 4,除非手动触发时勾了 allow_destructive。
#
# 放行与否在 shell 里按字符串 "true" 判:Gitea 的 inputs 是布尔还是字符串,
# "false" 这个非空字符串都不会被当成放行(GitHub 那份踩过 github.event.inputs 的坑)。
- name: 起栈
env:
ALLOW_INPUT: ${{ inputs.allow_destructive }}
run: |
allow=''
[ "$ALLOW_INPUT" = "true" ] && allow=1
echo "放行破坏性迁移:${allow:-否}"
ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" \
"cd $REMOTE_DIR && OJ2_ALLOW_DESTRUCTIVE='$allow' docker/deploy.sh --prebuilt"
+43 -26
View File
@@ -1,19 +1,20 @@
# 在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。 # 备用部署路线:**只能手动触发**(Actions 页面 → Deploy → Run workflow)。
# 平时的部署是 push origin 时由开发机上的 gitea-runner 跑 .gitea/workflows/deploy.yml,
# 这份只在开发机不在、又急着上线时用。
# #
# 为什么产物在这里编:服务器性能差,镜像 builder 阶段(1238 个包的 bun install + # 为什么不再 push 就跑:GitHub 的 runner 在海外,到服务器晚高峰只有十几 KB/s,
# bun compile + vite build)首次约 5 分钟,光前端就 160s。runner 编完传过去, # rsync 一次要五六分钟(后端改一行,--bytecode 那段约 6MB 字节码就整段重写,增量省不掉)。
# 服务器那边的镜像构建只剩几条 COPY。 # 而 origin 会镜像到 github,两边都 push 触发的话每次会部署两遍,两边的并发锁又不通。
#
# 流程:在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。
# 产物不在服务器上编,是因为服务器性能差,镜像 builder 阶段(1238 个包的 bun install +
# bun compile + vite build)首次约 5 分钟,光前端就 160s。
# #
# 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的 # 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的
# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖这个工作流。 # 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖任何工作流。
# #
# 触发的是 push 到 **github** 这个 remote: # job 上卡了 server_url:只在 GitHub 上跑。仓库有了 .gitea/workflows 之后 Gitea 本来就
# # 不再读这份,这道闸留着防万一。
# git push github main
#
# origin(git.xuyue.cc)是 Gitea,仓库里没有 .gitea/workflows 时它的 Actions 会退回来
# 读这份 .github/workflows,每次 push origin 都会把部署再跑一遍 —— 那边不需要。
# 所以下面 job 上卡了 server_url:只在 GitHub 上跑,Gitea 上直接跳过。
# #
# 仓库 Settings → Secrets 需要两个: # 仓库 Settings → Secrets 需要两个:
# HOST 服务器地址 # HOST 服务器地址
@@ -21,12 +22,8 @@
name: Deploy name: Deploy
on: on:
push: # 手动触发时可以放行破坏性迁移;不勾就停在闸门上把工作流判红。
branches: # 那种改动得有人先确认备份。
- main
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
# 工作流判红。这是有意的:那种改动得有人先确认备份。
workflow_dispatch: workflow_dispatch:
inputs: inputs:
allow_destructive: allow_destructive:
@@ -97,9 +94,21 @@ jobs:
chmod 600 ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
# 源码和产物一起传。dist/oj2-api 有 77MB,但 rsync 对已存在的文件做增量, # 源码和产物一起传。dist/oj2-api 有 115MB,但 rsync 对已存在的文件做增量,
# 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。 # 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。
# #
# 慢在线路上:runner 在海外,到服务器晚高峰只有十几 KB/s,传 3~4MB 就要五六分钟。
# 后端只要改了一行,--bytecode 那段字节码(约 6MB)就整段重写、增量也省不掉,
# 它占了每次传输量的七成多。下面几个开关都是为了少传、传不动时别干等:
# -c 按内容比,不看 mtime。checkout 出来每个文件的 mtime 都是「刚才」,
# 不加它 rsync 会把 600 多个没改的源码逐个走一遍增量、来回交换校验块。
# zstd 19 比默认的 zlib 再少传约 17%(实测 4.09MB → 3.38MB),压缩只花 runner 的 CPU。
# --delay-updates 全部传完才一次性换上去,中途断了服务器上不会留下半新半旧的源码。
# --timeout 60 秒没有任何数据进出就算断了,交给外面的循环重试;
# 重试时 --delay-updates 留在 .~tmp~ 里的半截文件会接着用,不从头传。
# ServerAlive 连接悄悄死掉时 ssh 自己能发现,不会一直挂着。
# --stats 日志里能看到这次到底传了多少字节。
#
# --delete 的排除项少一个就会出事,逐条说明: # --delete 的排除项少一个就会出事,逐条说明:
# docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。 # docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。
# 删了下一步 deploy.sh 自检就直接 die。 # 删了下一步 deploy.sh 自检就直接 die。
@@ -114,14 +123,22 @@ jobs:
# 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被 # 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被
# 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。 # 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。
- name: 同步到服务器 - name: 同步到服务器
timeout-minutes: 20
run: | run: |
rsync -az --no-owner --no-group --delete \ for attempt in 1 2 3; do
-e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT" \ rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
--exclude node_modules \ --compress --compress-choice=zstd --compress-level=19 \
--exclude .git \ --timeout=60 --stats --human-readable \
--exclude data \ -e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
--exclude 'docker/.env*' \ --exclude node_modules \
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" --exclude .git \
--exclude data \
--exclude 'docker/.env*' \
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" && exit 0
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
sleep 10
done
exit 1
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出, # deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出,
# 这一步跟着红,日志里能看到具体是哪一条。 # 这一步跟着红,日志里能看到具体是哪一条。
+5 -4
View File
@@ -62,7 +62,8 @@ cd apps/web && bun run build # 前端构建(不在 verify 里
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它; 配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks` `semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
(`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在 (`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在
长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css` 来回改。前端自己的构建 长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css`、`apps/web/src/oj/learn/learn.css` 来回改
(`vp fmt --list-different` 能列出当前受影响的文件)。前端自己的构建
配置在 `apps/web/vite.config.ts`,和根目录那份无关。 配置在 `apps/web/vite.config.ts`,和根目录那份无关。
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了 - 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
@@ -85,9 +86,9 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
## 提交 ## 提交
**直接在 `main` 上干活,不开特性分支。** 注意 push 到 `github` 这个 remote 的 `main` **直接在 `main` 上干活,不开特性分支。** 注意 **push `origin` 的 `main` 就会部署**:
会触发 CI 部署;而 `origin` 会镜像到 `github`,**平时 push `origin` 通常几秒后也就部署了** 开发机上的 gitea-runner 跑 `.gitea/workflows/deploy.yml`,进度在 git.xuyue.cc 的 Actions 页面。
(偶尔不跟,要 `git ls-remote github` 核实)。见 `docs/deploy.md`。 GitHub 那份只剩手动触发的备用路线。见 `docs/deploy.md`。
提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如 提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如
`fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。 `fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
+1 -1
View File
@@ -61,7 +61,7 @@ Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION: **外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION:
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 - **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
(problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存 (problem_tags)、专题/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
(user_stat)。 (user_stat)。
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大 - **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。 多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
@@ -0,0 +1,4 @@
-- 2026-11 班级不再从用户名的 ks 前缀推出来,改成建号时明确给。两个老号当年被推成了不是班号的班级
-- (ksxrm → 'xrm',ks王仁杰2 → '王仁杰'),任何班级下拉都选不到它们,后台一编辑还会被班号校验挡住,
-- 改成没填班级。只动不是 3~4 位数字的,正常的班一行不碰
UPDATE "user" SET "class_name" = NULL WHERE "class_name" IS NOT NULL AND "class_name" !~ '^[0-9]{3,4}$';
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "mascot_event" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "mascot_event_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"kind" text NOT NULL,
"situation" text,
"line" text,
"action" text,
"path" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mascot_event" ADD CONSTRAINT "mascot_event_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mascot_event_user_kind_idx" ON "mascot_event" USING btree ("user_id","kind");--> statement-breakpoint
CREATE INDEX "mascot_event_created_at_idx" ON "mascot_event" USING btree ("created_at");
@@ -0,0 +1,184 @@
-- 成就惠及更多学生(2026-11-03)。已经拿到的一个不收回。
--
-- 这学期 375 个交过题的学生,成就数中位数 5 个、三分之一只有 0–2 个。三件事:
--
-- 1. 比赛里的提交也算成就(判题结算同步改了,见 services/achievements.ts 的 solvedCounts)。
-- 这里的比赛其实是单班测验,2612 班整个班都在比赛里做题,人均做对 17 道却一个成就也没有;
-- 「参加过几场比赛」以前判题时也不更新,只在后台重扫时刷新,所以连「初入赛场」都没拿到。
-- 2. 几条线补上低一档(10 道和 50 道之间、一次就对 1 次和 20 次之间……),外加一条新线
-- 「挑战成功」。括号里是这学期学生已经达到的比例。
-- 3. 档多了,「奖杯收藏家」从 15 个提到 20 个,不然人人都有。
--
-- 然后从提交记录把含比赛的指标重数一遍,按新配置给所有达标的人补发(backfilled,不显示日期)。
-- 编译错误次数和按天的连续不重数:前者上线前的历史本来就没有,后者已经下架。
INSERT INTO "achievement" ("name", "description", "icon", "rarity", "hidden", "metric", "operator", "threshold", "visible", "unlock_count", "order", "create_time") VALUES
('稳扎稳打', '通过 20 道题目', 'noto:orange-book', 'bronze', false, 'accepted_count', 'gte', 20, true, 0, 2, now()), -- 44%
('初露锋芒', '通过 1 道中等难度题目', 'noto:sunrise-over-mountains', 'bronze', false, 'mid_ac_count', 'gte', 1, true, 0, 6, now()), -- 21%
('迎难而上', '挑战成功 1 次', 'noto:puzzle-piece', 'bronze', false, 'challenge_count', 'gte', 1, true, 0, 10, now()), -- 60%
('精益求精', '挑战成功 5 次', 'noto:gem-stone', 'silver', false, 'challenge_count', 'gte', 5, true, 0, 10, now()), -- 12%
('勤学苦练', '累计提交 30 次', 'noto:pencil', 'bronze', false, 'submission_count', 'gte', 30, true, 0, 11, now()), -- 48%
('渐成习惯', '累计 5 天有提交记录', 'noto:spiral-calendar', 'bronze', false, 'active_days', 'gte', 5, true, 0, 15, now()), -- 59%
('手感火热', '累计 5 次首次提交即通过', 'noto:glowing-star', 'bronze', true, 'first_try_ac_count', 'gte', 5, true, 0, 25, now()), -- 67%
('再试一次', '同一道题失败 3 次后终于通过', 'noto:counterclockwise-arrows-button', 'bronze', true, 'max_wa_before_ac', 'gte', 3, true, 0, 32, now()), -- 54%
('一鼓作气', '一天之内通过 5 道题', 'noto:dashing-away', 'bronze', true, 'max_ac_in_one_day', 'gte', 5, true, 0, 34, now()); -- 57%
--> statement-breakpoint
UPDATE "achievement" SET "threshold" = 20, "description" = '解锁 20 个成就(不含白金)'
WHERE "metric" = 'achievement_unlocked_count' AND "threshold" = 15;
--> statement-breakpoint
-- 交过题、还没有 user_stat 的人先建一行(2612 班大多是这样:比赛提交以前根本不进结算)
INSERT INTO "user_stat" ("user_id", "metrics", "update_time")
SELECT DISTINCT s."user_id", '{}'::jsonb, now()
FROM "submission" s
JOIN "user" u ON u."id" = s."user_id"
ON CONFLICT ("user_id") DO NOTHING;
--> statement-breakpoint
-- 按判题结算的口径从提交记录重数(services/achievements.ts):含比赛;在判的(6/7)不算,
-- 它们判完时结算会自己加;做对 = ACCEPTED(0) / AST_CHECK_FAILED(10);日历按东八区。
-- 「新做对」= 这道题第一次做对的那条提交。
UPDATE "user_stat" AS target
SET "metrics" = target."metrics" || fresh.metrics, "update_time" = now()
FROM (
WITH subs AS (
SELECT s."id", s."user_id", s."problem_id", s."contest_id", s."result", s."language", s."code", s."create_time",
(s."create_time" AT TIME ZONE 'Asia/Shanghai') AS local, p."difficulty",
s."result" IN (0, 10) AS ac,
-- 这道题在这条之前交过几次(同一时刻的不算「之前」,和结算里的 create_time < 一致)
rank() OVER (PARTITION BY s."user_id", s."problem_id" ORDER BY s."create_time") - 1 AS before
FROM "submission" s
JOIN "user" u ON u."id" = s."user_id"
JOIN "problem" p ON p."id" = s."problem_id"
WHERE s."result" NOT IN (6, 7)
),
totals AS (
SELECT "user_id",
count(DISTINCT "problem_id") FILTER (WHERE ac)::int AS accepted,
count(DISTINCT "problem_id") FILTER (WHERE ac AND "difficulty" = 'Mid')::int AS mid,
count(DISTINCT "problem_id") FILTER (WHERE ac AND "difficulty" = 'High')::int AS hard,
count(*)::int AS submissions,
count(DISTINCT "contest_id")::int AS contests,
count(*) FILTER (WHERE extract(hour FROM local) < 5)::int AS midnight,
count(*) FILTER (WHERE extract(hour FROM local) >= 5 AND extract(hour FROM local) < 7)::int AS early,
jsonb_agg(DISTINCT "language") AS languages,
jsonb_agg(DISTINCT to_char(local, 'YYYY-MM-DD')) AS dates
FROM subs GROUP BY "user_id"
),
first_try AS (
SELECT "user_id", count(*) FILTER (WHERE ac)::int AS n
FROM (SELECT DISTINCT ON ("user_id", "problem_id") "user_id", ac FROM subs ORDER BY "user_id", "problem_id", "create_time", "id") f
GROUP BY "user_id"
),
code_lines AS (
SELECT "user_id", max((
SELECT count(*) FROM regexp_split_to_table("code", E'\n') AS line WHERE btrim(line, E' \t\r') <> ''
))::int AS n
FROM subs WHERE ac GROUP BY "user_id"
),
first_ac AS (
SELECT DISTINCT ON ("user_id", "problem_id") "user_id", "problem_id", local, before
FROM subs WHERE ac ORDER BY "user_id", "problem_id", "create_time", "id"
),
wa_before AS (
SELECT "user_id", max(before)::int AS n FROM first_ac GROUP BY "user_id"
),
per_day AS (
SELECT "user_id", jsonb_object_agg(day, n) AS days, max(n)::int AS best
FROM (SELECT "user_id", to_char(local, 'YYYY-MM-DD') AS day, count(*)::int AS n FROM first_ac GROUP BY 1, 2) d
GROUP BY "user_id"
),
weeks AS (
SELECT DISTINCT "user_id", date_trunc('week', local)::date AS w FROM first_ac
),
runs AS (
SELECT "user_id", count(*)::int AS len, max(w) AS last_w
FROM (SELECT "user_id", w, w - (row_number() OVER (PARTITION BY "user_id" ORDER BY w))::int * 7 AS g FROM weeks) i
GROUP BY "user_id", g
),
streak AS (
SELECT "user_id", max(len) AS best, (array_agg(len ORDER BY last_w DESC))[1] AS current, max(last_w) AS last_w
FROM runs GROUP BY "user_id"
),
challenges AS (
SELECT "user_id", count(*)::int AS n FROM "challenge_success" GROUP BY "user_id"
)
SELECT t."user_id",
jsonb_build_object(
'accepted_count', t.accepted,
'mid_ac_count', t.mid,
'hard_ac_count', t.hard,
'first_try_ac_count', coalesce(ft.n, 0),
'submission_count', t.submissions,
'contest_joined', t.contests,
'midnight_submissions', t.midnight,
'early_bird_submissions', t.early,
'_languages', t.languages,
'languages_used', jsonb_array_length(t.languages),
'_active_dates', t.dates,
'active_days', jsonb_array_length(t.dates),
'max_code_lines', coalesce(cl.n, 0),
'max_wa_before_ac', coalesce(wb.n, 0),
'_ac_per_day', coalesce(pd.days, '{}'::jsonb),
'max_ac_in_one_day', coalesce(pd.best, 0),
'challenge_count', coalesce(ch.n, 0)
) || CASE WHEN st."user_id" IS NULL THEN '{}'::jsonb ELSE jsonb_build_object(
'max_ac_week_streak', st.best,
'_current_ac_week_streak', st.current,
'_last_ac_week', to_char(st.last_w, 'YYYY-MM-DD')
) END AS metrics
FROM totals t
LEFT JOIN first_try ft ON ft."user_id" = t."user_id"
LEFT JOIN code_lines cl ON cl."user_id" = t."user_id"
LEFT JOIN wa_before wb ON wb."user_id" = t."user_id"
LEFT JOIN per_day pd ON pd."user_id" = t."user_id"
LEFT JOIN streak st ON st."user_id" = t."user_id"
LEFT JOIN challenges ch ON ch."user_id" = t."user_id"
) fresh
WHERE target."user_id" = fresh."user_id";
--> statement-breakpoint
-- 挑战成功了、却一条提交都没有的(不会有,保险)也给个数
UPDATE "user_stat" AS target
SET "metrics" = jsonb_set(target."metrics", '{challenge_count}', to_jsonb(c.n))
FROM (SELECT "user_id", count(*)::int AS n FROM "challenge_success" GROUP BY "user_id") c
WHERE target."user_id" = c."user_id" AND (target."metrics" -> 'challenge_count') IS DISTINCT FROM to_jsonb(c.n);
--> statement-breakpoint
-- 补发:上架的、非元成就,达标没发的都发(口径同 rescanAchievement:backfilled、待通知)
INSERT INTO "user_achievement" ("user_id", "achievement_id", "unlock_time", "backfilled", "notified")
SELECT st."user_id", a."id", now(), true, false
FROM "user_stat" st
JOIN "achievement" a ON a."visible" AND a."metric" <> 'achievement_unlocked_count'
WHERE jsonb_typeof(st."metrics" -> a."metric") = 'number'
AND CASE WHEN a."operator" = 'gte' THEN (st."metrics" ->> a."metric")::numeric >= a."threshold"
ELSE (st."metrics" ->> a."metric")::numeric <= a."threshold" END
ON CONFLICT ("user_id", "achievement_id") DO NOTHING;
--> statement-breakpoint
-- 已解锁数(上架的非白金)重数,口径同 refreshUnlockedCount
UPDATE "user_stat" AS target
SET "metrics" = jsonb_set(target."metrics", '{achievement_unlocked_count}', to_jsonb(fresh.value))
FROM (
SELECT s."id", coalesce(c.value, 0) AS value
FROM "user_stat" s
LEFT JOIN (
SELECT ua."user_id", count(*)::int AS value
FROM "user_achievement" ua
JOIN "achievement" a ON a."id" = ua."achievement_id"
WHERE a."rarity" <> 'platinum' AND a."visible"
GROUP BY ua."user_id"
) c ON c."user_id" = s."user_id"
) fresh
WHERE target."id" = fresh."id"
AND (target."metrics" -> 'achievement_unlocked_count') IS DISTINCT FROM to_jsonb(fresh.value);
--> statement-breakpoint
-- 第二轮:元成就(奖杯收藏家)
INSERT INTO "user_achievement" ("user_id", "achievement_id", "unlock_time", "backfilled", "notified")
SELECT st."user_id", a."id", now(), true, false
FROM "user_stat" st
JOIN "achievement" a ON a."visible" AND a."metric" = 'achievement_unlocked_count'
WHERE jsonb_typeof(st."metrics" -> a."metric") = 'number'
AND CASE WHEN a."operator" = 'gte' THEN (st."metrics" ->> a."metric")::numeric >= a."threshold"
ELSE (st."metrics" ->> a."metric")::numeric <= a."threshold" END
ON CONFLICT ("user_id", "achievement_id") DO NOTHING;
--> statement-breakpoint
UPDATE "achievement" AS a
SET "unlock_count" = coalesce(c.n, 0)
FROM (SELECT "achievement_id", count(*)::int AS n FROM "user_achievement" GROUP BY "achievement_id") c
WHERE c."achievement_id" = a."id" AND a."unlock_count" IS DISTINCT FROM c.n;
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
+21
View File
@@ -295,6 +295,27 @@
"when": 1793626595966, "when": 1793626595966,
"tag": "0041_lesson_mode", "tag": "0041_lesson_mode",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1793632543220,
"tag": "0042_class_name_cleanup",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1793693911394,
"tag": "0043_mascot_event",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1793695621303,
"tag": "0044_achievement_more_tiers",
"breakpoints": true
} }
] ]
} }
+51 -8
View File
@@ -46,6 +46,7 @@ import type {
JudgeStatus, JudgeStatus,
LessonLanguage, LessonLanguage,
LessonMode, LessonMode,
MascotEventKind,
ProblemChallenges, ProblemChallenges,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
@@ -342,12 +343,12 @@ export const problemset = pgTable(
createdById: integer('created_by_id').notNull(), createdById: integer('created_by_id').notNull(),
/** /**
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。 * 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见 * 布置期内,专题里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。 * services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
* 两个都为空 = 没布置。 * 两个都为空 = 没布置。
* *
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、 * 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字 * 不一定同时升级,改列名会让没升级的那边一查专题就报错,所以只改代码里的名字
*/ */
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }), assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }), assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }),
@@ -613,9 +614,9 @@ export const submission = pgTable(
shared: boolean().default(false).notNull(), shared: boolean().default(false).notNull(),
statisticInfo: jsonb('statistic_info').default({}).notNull(), statisticInfo: jsonb('statistic_info').default({}).notNull(),
username: text().notNull(), username: text().notNull(),
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来, // 来源专题:学生从专题入口(/problemset/:id/problem/:pid)提交时记下来,
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它, // 提交列表据此标出「这条来自专题」。**只是来源标记**,专题进度、奖章一概不看它,
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。 // 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有专题」记账。
// 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。 // 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。
problemsetId: bigint('problemset_id', { mode: 'number' }), problemsetId: bigint('problemset_id', { mode: 'number' }),
}, },
@@ -745,13 +746,13 @@ export const submission = pgTable(
foreignColumns: [problem.id], foreignColumns: [problem.id],
name: 'submission_problem_id_76847b55_fk_problem_id', name: 'submission_problem_id_76847b55_fk_problem_id',
}), }),
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。 // 部分索引:绝大多数提交不来自专题,全列索引等于给 12 万行白建一遍。
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查 // 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删专题时的外键检查
// 也能用上它——不然那条检查要顺序扫全表。 // 也能用上它——不然那条检查要顺序扫全表。
index('submission_problemset_id_idx') index('submission_problemset_id_idx')
.using('btree', table.problemsetId.asc().nullsLast()) .using('btree', table.problemsetId.asc().nullsLast())
.where(sql`${table.problemsetId} is not null`), .where(sql`${table.problemsetId} is not null`),
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。 // 删掉专题不该带走提交:置空来源标记就行,提交本身照旧存在。
foreignKey({ foreignKey({
columns: [table.problemsetId], columns: [table.problemsetId],
foreignColumns: [problemset.id], foreignColumns: [problemset.id],
@@ -1411,3 +1412,45 @@ export const challengeSuccess = pgTable(
}).onDelete('cascade'), }).onDelete('cascade'),
], ],
) )
/**
* 看板娘的互动记录:点开气泡、点气泡里的按钮、她主动开口、摸头彩蛋、被拖走(种类见契约
* mascotEventSchema)。拿来看她说的话、给的按钮学生用不用,以后按学生的行为调整她也读这里;
* 「点过几次」(熟悉程度)直接按 user_id 数 open。
*
* 不是「学生做过什么」的证据,账号没了就没意义,user 外键 CASCADE(外键分档见 CLAUDE.md「数据库」)。
*/
export const mascotEvent = pgTable(
'mascot_event',
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: 'mascot_event_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
userId: integer('user_id').notNull(),
kind: text().$type<MascotEventKind>().notNull(),
situation: text(),
line: text(),
action: text(),
path: text().notNull(),
createdAt: timestamp('created_at', {
withTimezone: true,
mode: 'string',
})
.notNull()
.defaultNow(),
},
(table) => [
index('mascot_event_user_kind_idx').on(table.userId, table.kind),
index('mascot_event_created_at_idx').on(table.createdAt),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'mascot_event_user_id_fk_user_id',
}).onDelete('cascade'),
],
)
+2 -2
View File
@@ -268,9 +268,9 @@ export async function evaluateFlowchart(job: FlowchartJobData, { isFinalAttempt
evaluationTime: new Date().toISOString(), evaluationTime: new Date().toISOString(),
}) })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id)) .where(eq(schema.flowchartSubmission.id, row.flowchart.id))
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路 // 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的专题 —— 和判题那一路
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经 // 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
// 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单 // 落库,1.5 秒后跳回专题页看到的就是新数据。比赛题不进专题
if (isFlowchartPass(result.grade) && row.problem.contestId === null) { if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString()) await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString())
} }
+2
View File
@@ -21,6 +21,7 @@ import { problemRoutes } from './routes/problem'
import { problemStatsRoutes } from './routes/problem-stats' import { problemStatsRoutes } from './routes/problem-stats'
import { submissionRoutes } from './routes/submission' import { submissionRoutes } from './routes/submission'
import { trialRunRoutes } from './routes/trial-run' import { trialRunRoutes } from './routes/trial-run'
import { mascotRoutes } from './routes/mascot'
import { siteRoutes } from './routes/site' import { siteRoutes } from './routes/site'
import { scanPendingChallenges } from './services/challenge' import { scanPendingChallenges } from './services/challenge'
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket' import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
@@ -44,6 +45,7 @@ app.route('/api', problemRoutes)
app.route('/api', problemStatsRoutes) app.route('/api', problemStatsRoutes)
app.route('/api', submissionRoutes) app.route('/api', submissionRoutes)
app.route('/api', trialRunRoutes) app.route('/api', trialRunRoutes)
app.route('/api', mascotRoutes)
app.route('/api/admin', adminRoutes) app.route('/api/admin', adminRoutes)
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出 // 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
+99 -17
View File
@@ -63,15 +63,74 @@ async function loadLanguage(language: string) {
return loaded return loaded
} }
function collectNodes(root: Node, type: string, result: Node[] = []) { type Match = (node: Node) => boolean
if (root.type === type) result.push(root)
for (const child of root.children) collectNodes(child, type, result) function collectNodes(root: Node, match: Match, result: Node[] = []) {
if (match(root)) result.push(root)
for (const child of root.children) collectNodes(child, match, result)
return result return result
} }
function hasNode(root: Node, type: string): boolean { function hasNode(root: Node, match: Match): boolean {
if (root.type === type) return true if (match(root)) return true
return root.children.some((child) => hasNode(child, type)) return root.children.some((child) => hasNode(child, match))
}
const ofType =
(type: string): Match =>
(node) =>
node.type === type
/** C / C++ 函数定义的函数名:`int *f()` 的名字在 pointer_declarator 里面一层,沿着 declarator 往里找 */
function functionName(definition: Node) {
let declarator = definition.childForFieldName('declarator')
while (declarator && declarator.type !== 'function_declarator') declarator = declarator.childForFieldName('declarator')
return declarator?.childForFieldName('declarator')?.text
}
/**
* 语法 target 认哪些节点。多数就是比节点类型,例外:
*
* C / C++ 的「函数定义」不算 `main` —— 每个 C 程序都有 main,算进去的话「必须使用函数定义」
* 永远通过、「不能使用」永远失败,老师选它要的是「自己写了函数」。所以 C 那边叫「自定义函数」。
*
* Python 的「赋值语句」连 `s += 1` 一起算 —— 和 C 一个口径(C 的 assignment_expression
* 本来就包括 `+=`),学生也是这么理解的。tree-sitter-python 里 `+=` 是单独的 augmented_assignment。
*/
function nodeMatch(target: string, language: string): Match {
const type = astTargetNodeType(target, language)
if (target === 'function_definition' && language !== 'Python') {
return (node) => node.type === type && functionName(node) !== 'main'
}
if (target === 'assignment' && language === 'Python') {
return (node) => node.type === type || node.type === 'augmented_assignment'
}
return ofType(type)
}
/**
* 运算符只认真在做运算的那一个 token。光比 token 的话,下面这些全都算「用了」:
* C 的 `scanf("%d", &n)` 算 `&`、`int *p` 算 `*`,C++ 的 `vector<int>` 算 `>`,
* Python 的 `*args` / `from m import *` 算 `*`、`**kw` 算 `**`、`not in` / `is not` 算 `not`、
* `case 1 | 2` 算 `|`。所以看它的父节点:二元运算、复合赋值、自增自减。
*
* 一元的只认取反(`!x` / `not x`)。负号 `-x` 不算「用了减号」,同理 `*p` 不算乘、`&n` 不算按位与。
*/
const OPERATOR_PARENTS: Record<string, string[]> = {
C: ['binary_expression', 'assignment_expression', 'update_expression'],
'C++': ['binary_expression', 'assignment_expression', 'update_expression'],
Python: ['binary_operator', 'boolean_operator', 'comparison_operator', 'augmented_assignment'],
}
const NOT_PARENTS: Record<string, string[]> = {
C: ['unary_expression'],
'C++': ['unary_expression'],
Python: ['not_operator'],
}
function operatorMatch(target: string, language: string): Match {
const type = astTargetNodeType(target, language)
const parents = (target === 'not' ? NOT_PARENTS : OPERATOR_PARENTS)[language] ?? []
return (node) => node.type === type && node.parent !== null && parents.includes(node.parent.type)
} }
function targetName(rule: AstRule, language?: string) { function targetName(rule: AstRule, language?: string) {
@@ -201,6 +260,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
if (!rule.keyword?.trim()) return `${at}:参数名不能为空` if (!rule.keyword?.trim()) return `${at}:参数名不能为空`
} }
if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字` if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字`
if ([rule.exact, rule.min, rule.max].some((n) => n !== undefined && n < 0)) return `${at}:次数不能是负数`
if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) {
return `${at}:至少 ${rule.min} 次比至多 ${rule.max} 次还多,怎么写都过不了`
}
} }
} }
return null return null
@@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record<string, string> = {
Python: 'call', Python: 'call',
} }
/** `import math` / `import numpy as np` 引进来的模块名。`import os.path` 绑定的是 `os` */
function importedModules(root: Node) {
const names = new Set<string>()
for (const statement of collectNodes(root, ofType('import_statement'))) {
for (const item of statement.namedChildren) {
const name = item.type === 'aliased_import' ? item.childForFieldName('alias') : item.type === 'dotted_name' ? item.namedChildren[0] : null
if (name) names.add(name.text)
}
}
return names
}
function functionCalls(root: Node, target: string, language: string) { function functionCalls(root: Node, target: string, language: string) {
const callType = CALL_NODE_TYPES[language] ?? 'call' const callType = CALL_NODE_TYPES[language] ?? 'call'
return collectNodes(root, callType).filter((call) => { // Python 的 `math.sqrt(4)` 也算调用了 sqrt() —— 和 `from math import sqrt` 后写 `sqrt(4)`
// 是一回事,老师配「必须调用 sqrt()」时想的就是它。只认 import 进来的模块,
// `s.count()` 这种对象上的方法还是归「调用方法」
const modules = language === 'Python' ? importedModules(root) : null
return collectNodes(root, ofType(callType)).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
if (!fn) return false if (!fn) return false
if (fn.type === 'identifier') return fn.text === target if (fn.type === 'identifier') return fn.text === target
if (modules && fn.type === 'attribute') {
const object = fn.childForFieldName('object')
return object?.type === 'identifier' && modules.has(object.text) && fn.childForFieldName('attribute')?.text === target
}
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于 // `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
// 有没有 using namespace std,两种都得认,所以末段也比一次 // 有没有 using namespace std,两种都得认,所以末段也比一次
if (language === 'C++' && fn.type === 'qualified_identifier') { if (language === 'C++' && fn.type === 'qualified_identifier') {
@@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) {
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」 // 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
// 会静默地永远失败 // 会静默地永远失败
if (language === 'C++') { if (language === 'C++') {
return collectNodes(root, 'call_expression').filter((call) => { return collectNodes(root, ofType('call_expression')).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
}) })
} }
if (language !== 'Python') return [] if (language !== 'Python') return []
return collectNodes(root, 'call').filter((call) => { return collectNodes(root, ofType('call')).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target
}) })
@@ -279,20 +362,20 @@ function methodCalls(root: Node, target: string, language: string) {
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null { function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
const target = rule.target ?? '' const target = rule.target ?? ''
const nodeType = astTargetNodeType(target, language) const match = rule.engine === 'must_use_operator' ? operatorMatch(target, language) : nodeMatch(target, language)
switch (rule.engine) { switch (rule.engine) {
case 'must_exist_node': case 'must_exist_node':
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, match),
} }
case 'must_not_exist_node': { case 'must_not_exist_node': {
const nodes = collectNodes(root, nodeType) const nodes = collectNodes(root, match)
return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes) return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes)
} }
case 'count_node': { case 'count_node': {
const nodes = collectNodes(root, nodeType) const nodes = collectNodes(root, match)
return withLines( return withLines(
{ {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
@@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_use_operator': case 'must_use_operator':
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, match),
} }
case 'must_use_keyword': case 'must_use_keyword':
return { return {
@@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_have_nesting': { case 'must_have_nesting': {
const outer = rule.outer ?? '' const outer = rule.outer ?? ''
const inner = rule.inner ?? '' const inner = rule.inner ?? ''
const outerType = astTargetNodeType(outer, language) const innerMatch = nodeMatch(inner, language)
const innerType = astTargetNodeType(inner, language) const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch)))
const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)))
return { description: describeAstRule(rule, language), passed } return { description: describeAstRule(rule, language), passed }
} }
default: default:
+4 -4
View File
@@ -549,11 +549,11 @@ export async function judgeSubmission(job: JudgeJobData) {
) )
if (!saved) return if (!saved) return
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress, // 专题记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。 // 只认路由参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时 // 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
// 进度已经落库,跳回题单页看到的就是新数据。 // 进度已经落库,跳回专题页看到的就是新数据。
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。 // 比赛题不进专题(专题加题时卡了 contestId IS NULL),跳过。
if (row.submission.contestId === null && isAccepted(result)) { if (row.submission.contestId === null && isAccepted(result)) {
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime) await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
} }
+7 -4
View File
@@ -263,10 +263,13 @@ accountRoutes.put('/me/profile', requireAuth, async (c) => {
const parsed = await parseBody(c, updateProfileRequestSchema, 'Invalid profile payload') const parsed = await parseBody(c, updateProfileRequestSchema, 'Invalid profile payload')
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const values = Object.fromEntries(Object.entries(parsed.data).map(([key, value]) => [key, value === '' ? null : value])) const values = Object.fromEntries(Object.entries(parsed.data).map(([key, value]) => [key, value === '' ? null : value]))
await db // 只带了不收的字段(比如老页面还发 realName):没什么可改的,别让 drizzle 的空 set 报成 500
.update(schema.userProfile) if (Object.keys(values).length) {
.set(values) await db
.where(eq(schema.userProfile.userId, c.get('user')!.id)) .update(schema.userProfile)
.set(values)
.where(eq(schema.userProfile.userId, c.get('user')!.id))
}
const profile = await getUserProfileById(c.get('user')!.id, true) const profile = await getUserProfileById(c.get('user')!.id, true)
if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist') if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist')
return success(c, profile) return success(c, profile)
+82 -45
View File
@@ -2,6 +2,8 @@ import {
adminTypeSchema, adminTypeSchema,
checkUsernamesRequestSchema, checkUsernamesRequestSchema,
deleteUsersRequestSchema, deleteUsersRequestSchema,
displayName,
legacyClassOf,
NO_CLASS, NO_CLASS,
type CheckUsernamesResponse, type CheckUsernamesResponse,
importUsersRequestSchema, importUsersRequestSchema,
@@ -26,36 +28,12 @@ import { revokeUserSessions } from '../../auth/session'
import { requireSuperAdmin, type AppEnv } from '../../auth/middleware' import { requireSuperAdmin, type AppEnv } from '../../auth/middleware'
import { db, schema } from '../../db' import { db, schema } from '../../db'
import { failure, parseBody, success } from '../../http' import { failure, parseBody, success } from '../../http'
import { classPrefixCondition, queryInteger, sampleUser } from '../helpers' import { classKeywordCondition, queryInteger, sampleUser } from '../helpers'
import { publishConfigUpdate } from '../../events' import { publishConfigUpdate } from '../../events'
import { getWebsiteOptions } from '../../services/options' import { getWebsiteOptions } from '../../services/options'
export const adminAccountRoutes = new Hono<AppEnv>() export const adminAccountRoutes = new Hono<AppEnv>()
const CLASS_NAME_MIN_DIGITS = 3
const CLASS_NAME_MAX_DIGITS = 4
/**
* `ks251XXX` / `ks2510XX` → `251` / `2510`。不以 `ks+数字` 开头的(管理员、教师账号)返回 null。
*
* 位数不对**直接报错,不猜** —— 猜错会把 className 存歪,而剥前缀显示姓名、班级下拉、
* 统计页都依赖它准确。先用 `\d+` 抓全再判位数,不能直接用固定位数的正则匹配:
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
* 对齐旧 `account/views/admin.py:get_class_name`。
*/
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } {
const matched = /^ks(\d+)/.exec(username)
if (!matched) return { ok: true, value: null }
const digits = matched[1]!
if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) {
return {
ok: false,
message: `用户名 ${username} 的班级号 ${digits} 是 ${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
}
}
return { ok: true, value: digits }
}
/** /**
* 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission: * 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission:
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。 * 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
@@ -120,8 +98,8 @@ adminAccountRoutes.get('/rankings/users', requireSuperAdmin, async (c) => {
const where = and( const where = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]), inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false), eq(schema.user.isDisabled, false),
// 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含 // 填的是班号(251,或者老习惯 ks251)按班级圈,否则按用户名包含
keyword ? (classPrefixCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined, keyword ? (classKeywordCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
) )
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
@@ -220,20 +198,62 @@ adminAccountRoutes.get('/users', requireSuperAdmin, async (c) => {
}) })
/** /**
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。 * 这个班里已经有的、叫这几个名字的人(多半是同一个学生粘了两次)。三种写法都要认:老号 `ks232张三`
* 比较时归一成小写,和导入、登录同一个口径 * (剥掉前缀比)、名字被占过加了数字的新号 `张三2`、24 级数媒那批编号制用户名 —— 后两种名字只在真名里,
* 所以真名也比。比较归一成小写,和登录、导入同一个口径
*/
async function classmatesNamed(className: string, names: string[]) {
const wanted = new Set(names.map((name) => name.toLowerCase()))
const rows = await db
.select({ username: schema.user.username, realName: schema.userProfile.realName, createTime: schema.user.createTime })
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.className, className))
return rows.flatMap((row) => {
const name = [displayName(row.username), row.realName ?? ''].find((candidate) => candidate && wanted.has(candidate.toLowerCase()))
return name ? [{ name, username: row.username, createTime: row.createTime }] : []
})
}
/**
* 全站已经被占的用户名里,和这几个名字撞的:名字本身,以及「名字 + 数字」(给新号加数字时要绕开)。
* 名字自己就带数字(王芳1)也照样认 —— 不能先把库里的尾数剥掉再比,那样 `王芳1` 剥成 `王芳` 就漏了
*/
async function takenUsernames(names: string[]) {
const escape = (text: string) => text.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&')
const pattern = `^(${[...new Set(names.map((name) => escape(name.toLowerCase())))].join('|')})[0-9]*$`
const rows = await db
.select({ username: sql<string>`lower(${schema.user.username})` })
.from(schema.user)
.where(sql`lower(${schema.user.username}) ~ ${pattern}`)
return new Set(rows.map((row) => row.username))
}
/**
* 批量建号之前查重(批量建号页边粘名单边查):这个班里已经有谁了,以及每个名字会建成什么用户名
* (名字被别的班、注册的号占了就加数字)。导入时后端还会再查一遍同班同名,这里只是让老师提前看到
*/ */
adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => { adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, checkUsernamesRequestSchema) const parsed = await parseBody(c, checkUsernamesRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const names = [...new Set(parsed.data.usernames.map((name) => name.toLowerCase()))] const { className, names } = parsed.data
const rows = await db const [existing, taken] = await Promise.all([classmatesNamed(className, names), takenUsernames(names)])
.select({ username: schema.user.username, createTime: schema.user.createTime }) return success(c, { existing, usernames: suggestUsernames(names, taken) } satisfies CheckUsernamesResponse)
.from(schema.user)
.where(inArray(sql`lower(${schema.user.username})`, names))
return success(c, { existing: rows } satisfies CheckUsernamesResponse)
}) })
/**
* 新号的用户名:名字没被占就是名字本身,占了往后加数字(杨涵宇2、杨涵宇3)。同一批里先到先得。
* 比较按小写,和登录、导入同一个口径
*/
function suggestUsernames(names: string[], taken: Set<string>) {
return names.map((name) => {
let candidate = name
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${name}${n}`
taken.add(candidate.toLowerCase())
return candidate
})
}
adminAccountRoutes.get('/users/:id', requireSuperAdmin, async (c) => { adminAccountRoutes.get('/users/:id', requireSuperAdmin, async (c) => {
const [row] = await selectUser(queryInteger(c.req.param('id'), 0, { min: 1 })) const [row] = await selectUser(queryInteger(c.req.param('id'), 0, { min: 1 }))
if (!row) return failure(c, 404, 'user-not-found', 'User does not exist') if (!row) return failure(c, 404, 'user-not-found', 'User does not exist')
@@ -250,8 +270,8 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
const username = data.username.trim().toLowerCase() const username = data.username.trim().toLowerCase()
const email = data.email.trim().toLowerCase() const email = data.email.trim().toLowerCase()
const className = classNameOf(username) // 班级明确给了就用;老页面不发这一项,不动原来的班(以前是从用户名的 ks 前缀推,改名就跟着变)
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message) const className = data.className !== undefined ? data.className : existing.user.className
const [dupUsername] = await db const [dupUsername] = await db
.select({ id: schema.user.id }) .select({ id: schema.user.id })
@@ -270,7 +290,7 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
const patch: Partial<typeof schema.user.$inferInsert> = { const patch: Partial<typeof schema.user.$inferInsert> = {
username, username,
email, email,
className: className.value, className,
adminType: data.adminType, adminType: data.adminType,
isDisabled: data.isDisabled, isDisabled: data.isDisabled,
problemPermission: normalizePermission(data.adminType, data.problemPermission), problemPermission: normalizePermission(data.adminType, data.problemPermission),
@@ -337,10 +357,14 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的 // PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
// 查重建出第二个账号,两个人登录时撞成同一条记录。 // 查重建出第二个账号,两个人登录时撞成同一条记录。
const prepared: Prepared[] = [] const prepared: Prepared[] = []
for (const [username, password, email, realName] of rows) { for (const [username, password, email, realName, explicitClass] of rows) {
const name = username.toLowerCase() const name = username.toLowerCase()
const className = classNameOf(name) // 班级明确给了就用(新号不带 ks 前缀);老写法没给,退回从 ks 前缀推,位数不对报错不猜
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message) const legacy = explicitClass === undefined ? legacyClassOf(name) : null
if (legacy === undefined) {
return failure(c, 400, 'invalid-class-name', `用户名 ${name} 里 ks 后面的班级号位数不对,班级号要是 3~4 位数字`)
}
const className = explicitClass ?? legacy
const mail = email.trim().toLowerCase() const mail = email.trim().toLowerCase()
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前 // 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批 // 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
@@ -355,7 +379,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
raw: password, raw: password,
email: mail, email: mail,
realName, realName,
className: className.value, className,
}) })
} }
@@ -372,6 +396,19 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`) return failure(c, 409, 'email-exists', `这批名单里邮箱重复:${batchMails.join('、')}`)
} }
// 同班同名再查一遍:用户名不再带班号以后,「同一个学生粘了两次」撞不上用户名唯一约束了(新号会被加成张三2),
// 只靠批量建号页的查重挡 —— 那边有半秒防抖,粘完马上点就绕过去了
const byClass = new Map<string, string[]>()
for (const item of prepared) {
if (item.className) byClass.set(item.className, [...(byClass.get(item.className) ?? []), item.realName || displayName(item.username)])
}
for (const [className, names] of byClass) {
const clash = await classmatesNamed(className, names)
if (clash.length) {
return failure(c, 409, 'name-exists-in-class', `${className} 班已经有${clash.map((row) => `「${row.name}」`).join('、')}了`)
}
}
const existing = await db const existing = await db
.select({ username: schema.user.username, email: schema.user.email }) .select({ username: schema.user.username, email: schema.user.email })
.from(schema.user) .from(schema.user)
@@ -488,7 +525,7 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来: // 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。 // 撞外键说明该用户还有历史数据,应当禁用而不是删除。
// //
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、 // 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、专题进度、
// AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE —— // AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。 // 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个 // 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
@@ -496,10 +533,10 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
try { try {
const deleted = await db.transaction(async (tx) => { const deleted = await db.transaction(async (tx) => {
/** /**
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** —— * 外键拦得住成就、专题进度、比赛排名这些,**唯独提交拦不住** ——
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经 * `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票: * 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 —— * 只交过题、没拿过成就没进过专题的学生照样删得掉,提交留在库里成了孤儿 ——
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测: * 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
* 28 个已删账号留下 935 条这样的提交。 * 28 个已删账号留下 935 条这样的提交。
* *
+9 -9
View File
@@ -11,7 +11,7 @@ import {
import { randomInt } from 'node:crypto' import { randomInt } from 'node:crypto'
import { mkdir, readdir, rm, stat } from 'node:fs/promises' import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { count, countDistinct, desc, eq, gte, ilike, inArray, isNull, not, sql } from 'drizzle-orm' import { and, count, countDistinct, desc, eq, gte, inArray, isNull, not, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { requireAdmin, requireSuperAdmin, type AppEnv } from '../../auth/middleware' import { requireAdmin, requireSuperAdmin, type AppEnv } from '../../auth/middleware'
@@ -22,12 +22,12 @@ import { failure, parseBody, success } from '../../http'
import { sniffImageExtension } from '../../services/image' import { sniffImageExtension } from '../../services/image'
import { getWebsiteOptions } from '../../services/options' import { getWebsiteOptions } from '../../services/options'
import { calendarDay, dayStart, localTime, termStart, weekStart } from '../../time' import { calendarDay, dayStart, localTime, termStart, weekStart } from '../../time'
import { STUDENT_ROLES } from '@oj2/contract' import { displayName, STUDENT_ROLES } from '@oj2/contract'
import { TEACHER_ROLES } from '../helpers' import { TEACHER_ROLES } from '../helpers'
import { onlineUserIds } from '../../auth/presence' import { onlineUserIds } from '../../auth/presence'
import { JudgeStatus } from '../../judge/status' import { JudgeStatus } from '../../judge/status'
import { suggestActiveClass } from '../../services/class-activity' import { suggestActiveClass } from '../../services/class-activity'
import { classPrefixCondition } from '../helpers' import { classKeywordCondition } from '../helpers'
export const adminConfRoutes = new Hono<AppEnv>() export const adminConfRoutes = new Hono<AppEnv>()
@@ -346,20 +346,20 @@ adminConfRoutes.get('/dashboard', requireSuperAdmin, async (c) => {
}) })
adminConfRoutes.get('/random-usernames', requireSuperAdmin, async (c) => { adminConfRoutes.get('/random-usernames', requireSuperAdmin, async (c) => {
// 传的是**班级前缀**(形如 ks251),不是班级号 —— 前端输入框写的就是「班级前缀」, // 后台首页「抽一个人」:从这个班随机拿 10 个,回的是界面上显示的名字(老号剥掉 ks 前缀)。
// 拿到结果后按这个前缀 split 取姓名。班级前缀走 classPrefixCondition(ks251 不带上 // 班号按 class_name 精确匹配;老页面发的是 ks251,classKeywordCondition 两种都认
// ks2511);别的形状的输入照旧 istartswith,与旧接口一致
const classroom = c.req.query('classroom')?.trim() const classroom = c.req.query('classroom')?.trim()
if (!classroom) return failure(c, 400, 'invalid-request', '需要班级号') const where = classroom ? classKeywordCondition(classroom) : undefined
if (!where) return failure(c, 400, 'invalid-request', '需要班级号')
const rows = await db const rows = await db
.select({ username: schema.user.username }) .select({ username: schema.user.username })
.from(schema.user) .from(schema.user)
.where(classPrefixCondition(classroom) ?? ilike(schema.user.username, `${classroom}%`)) .where(and(where, eq(schema.user.isDisabled, false)))
.orderBy(sql`random()`) .orderBy(sql`random()`)
.limit(10) .limit(10)
return success( return success(
c, c,
rows.map((row) => row.username), rows.map((row) => displayName(row.username)),
) )
}) })
+2 -2
View File
@@ -53,8 +53,8 @@ function classCondition(value: string | null) {
} }
/** /**
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的, * 没有班级的学生**照样统计**(班级列显示为空)。班级是建号时给的(老号当年从用户名的 ks 前缀推),
* 推不出来时就是 null(见 admin/account.ts 的 classNameOf),把这些人过滤掉等于让他们 * 没给就是 null,把这些人过滤掉等于让他们
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。 * 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
*/ */
function studentCondition(value: string | null) { function studentCondition(value: string | null) {
+12 -3
View File
@@ -1,4 +1,4 @@
import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract' import { adminLessonRequestSchema, lessonModeSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm' import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
@@ -24,7 +24,12 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 }) const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
const className = c.req.query('className')?.trim() const className = c.req.query('className')?.trim()
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 }) const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 })
const where = and(className ? eq(schema.classLesson.className, className) : undefined, teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined) const mode = lessonModeSchema.safeParse(c.req.query('mode')).data
const where = and(
className ? eq(schema.classLesson.className, className) : undefined,
teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined,
mode ? eq(schema.classLesson.mode, mode) : undefined,
)
const [rows, [totalRow], teachers] = await Promise.all([ const [rows, [totalRow], teachers] = await Promise.all([
db db
@@ -156,10 +161,14 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次 // 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
const classes = await affectedClasses(id, result.values.className) const classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className))) const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 时刻只填到分:没改时刻就留着原来带秒的那个。不然只改了做什么,这一节也被挪到这一分钟的开头,
// 排到同一分钟里后布置的那节前面去 —— 「今天最后一节」就换了人(先画流程图、紧接着再开一节写代码,常在同一分钟里)
const [current] = await db.select({ createdAt: schema.classLesson.createdAt }).from(schema.classLesson).where(eq(schema.classLesson.id, id))
const sameMinute = current && Math.floor(Date.parse(current.createdAt) / 60_000) * 60_000 === Date.parse(result.values.createdAt)
// 布置的老师不换,和任务页上「改」一样认第一次 // 布置的老师不换,和任务页上「改」一样认第一次
const [row] = await db const [row] = await db
.update(schema.classLesson) .update(schema.classLesson)
.set({ ...result.values, updatedAt: new Date().toISOString() }) .set({ ...result.values, createdAt: sameMinute ? current.createdAt : result.values.createdAt, updatedAt: new Date().toISOString() })
.where(eq(schema.classLesson.id, id)) .where(eq(schema.classLesson.id, id))
.returning({ id: schema.classLesson.id }) .returning({ id: schema.classLesson.id })
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了') if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
+23 -23
View File
@@ -28,7 +28,7 @@ export const adminProblemSetRoutes = new Hono<AppEnv>()
type BadgeRow = typeof schema.problemsetBadge.$inferSelect type BadgeRow = typeof schema.problemsetBadge.$inferSelect
/** /**
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 —— * 取出专题并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。 * 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
*/ */
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) { async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) {
@@ -124,7 +124,7 @@ function assignment(requested: string | null, current: { assignedAt: string | nu
} }
} }
// ---------------------------------------------------------------- 题单本体 // ---------------------------------------------------------------- 专题本体
adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 })
@@ -132,8 +132,8 @@ adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
const user = c.get('user')! const user = c.get('user')!
const filters = [] const filters = []
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供 // 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失, // 「切换可见性」的接口 —— 一旦把专题设成不可见,它就从后台列表里消失,
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。 // 再也没法在界面上改回来。后台必须能看见自己管的全部专题。
if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id)) if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id))
const keyword = c.req.query('keyword')?.trim() const keyword = c.req.query('keyword')?.trim()
if (keyword) { if (keyword) {
@@ -174,13 +174,13 @@ adminProblemSetRoutes.post('/problem-sets', requireTeacher, async (c) => {
adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
return success(c, await serialize(row)) return success(c, await serialize(row))
}) })
adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const parsed = await parseBody(c, updateProblemSetRequestSchema) const parsed = await parseBody(c, updateProblemSetRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const period = assignment(parsed.data.assignedUntil, row) const period = assignment(parsed.data.assignedUntil, row)
@@ -201,7 +201,7 @@ adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关 // 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
const [updated] = await db const [updated] = await db
.update(schema.problemset) .update(schema.problemset)
@@ -213,18 +213,18 @@ adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async
adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
// 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐, // 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐,
// user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。 // user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id)) await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
return success(c, null) return success(c, null)
}) })
// ---------------------------------------------------------------- 题单里的题目 // ---------------------------------------------------------------- 专题里的题目
adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const rows = await db const rows = await db
.select({ item: schema.problemsetProblem, problem: schema.problem }) .select({ item: schema.problemsetProblem, problem: schema.problem })
.from(schema.problemsetProblem) .from(schema.problemsetProblem)
@@ -255,7 +255,7 @@ adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c
*/ */
adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => { adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const parsed = await parseBody(c, addProblemsToSetRequestSchema) const parsed = await parseBody(c, addProblemsToSetRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))] const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
@@ -298,7 +298,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (
// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉 // 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema) const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const links = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id)) const links = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id))
@@ -320,7 +320,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, as
adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误') const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误')
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const updated = await db const updated = await db
@@ -328,7 +328,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
.set(parsed.data) .set(parsed.data)
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id))) .where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
.returning({ id: schema.problemsetProblem.id }) .returning({ id: schema.problemsetProblem.id })
if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中') if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中')
// 必做 / 选做变了,分母和「做完」跟着变 // 必做 / 选做变了,分母和「做完」跟着变
await resyncProgress(row.id) await resyncProgress(row.id)
return success(c, null) return success(c, null)
@@ -336,12 +336,12 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => { adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const deleted = await db const deleted = await db
.delete(schema.problemsetProblem) .delete(schema.problemsetProblem)
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id))) .where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
.returning({ id: schema.problemsetProblem.id }) .returning({ id: schema.problemsetProblem.id })
if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中') if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中')
await resyncProgress(row.id) await resyncProgress(row.id)
return success(c, null) return success(c, null)
}) })
@@ -383,14 +383,14 @@ async function badgesWithCount(badges: BadgeRow[]) {
adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id)) const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
return success(c, await badgesWithCount(badges)) return success(c, await badgesWithCount(badges))
}) })
adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => { adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const parsed = await parseBody(c, createProblemSetBadgeRequestSchema) const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const [created] = await db const [created] = await db
@@ -407,7 +407,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c)
adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema) const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const [updated] = await db const [updated] = await db
@@ -422,11 +422,11 @@ adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, a
adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => { adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) const row = await loadOwned(c, c.get('user')!)
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 }) const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 })
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。 // 必须先确认这枚奖章确实属于本专题,再动 user_badge。
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单, // 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定专题,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录, // 于是「自己的专题 id + 别人的奖章 id」会真删掉别人的获奖记录,
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。 // 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
const [badge] = await db const [badge] = await db
.select({ id: schema.problemsetBadge.id }) .select({ id: schema.problemsetBadge.id })
+4 -1
View File
@@ -4,6 +4,7 @@ import {
aiHintRequestSchema, aiHintRequestSchema,
classAnalysisRequestSchema, classAnalysisRequestSchema,
classPkAnalysisRequestSchema, classPkAnalysisRequestSchema,
displayName,
HINT_MIN_FAILURES, HINT_MIN_FAILURES,
LEARNING_PERIOD_LABELS, LEARNING_PERIOD_LABELS,
learningPeriodSchema, learningPeriodSchema,
@@ -354,6 +355,8 @@ aiRoutes.post('/ai/class-analysis', requireAuth, async (c) => {
// 数字和抽屉里看到的是同一份(services/ranking.ts 的 classDetail),不再由前端整包传上来 // 数字和抽屉里看到的是同一份(services/ranking.ts 的 classDetail),不再由前端整包传上来
const detail = await classDetail(parsed.data.className, true) const detail = await classDetail(parsed.data.className, true)
if (!detail.members) return failure(c, 404, 'class-not-found', '这个班没有学生') if (!detail.members) return failure(c, 404, 'class-not-found', '这个班没有学生')
// 「要多关心的同学」会被点名:老号的用户名带 ks252 前缀,喂给模型之前剥掉,报告里写的才是界面上那个名字
const forModel = { ...detail, care: detail.care?.map((row) => ({ ...row, user: { ...row.user, username: displayName(row.user.username) } })) ?? null }
return streamChat( return streamChat(
[ [
'你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。', '你是编程课老师的助教。下面是一个班这学期在 OJ 上的做题数据(JSON,人名是用户名)。',
@@ -362,7 +365,7 @@ aiRoutes.post('/ai/class-analysis', requireAuth, async (c) => {
'**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。', '**分化**(前后 10%、人最多的那一档同分)、**建议**(点名 care 里的同学,给下节课能直接做的事)。',
'用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。', '用大白话,不要出现四分位数、标准差、方差这类统计词,不要编数据里没有的数字。',
].join(''), ].join(''),
JSON.stringify(detail), JSON.stringify(forModel),
) )
}) })
+13 -21
View File
@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { import {
displayName,
createFlowchartRequestSchema, createFlowchartRequestSchema,
isFlowchartPass, isFlowchartPass,
type CreateFlowchartResponse, type CreateFlowchartResponse,
@@ -27,18 +28,7 @@ import { consumeToken } from '../services/throttling'
import { buildWordFrequencies } from '../services/word-frequency' import { buildWordFrequencies } from '../services/word-frequency'
import { dayStart } from '../time' import { dayStart } from '../time'
import { unknownDisplayIds } from '../services/submission-filters' import { unknownDisplayIds } from '../services/submission-filters'
import { import { classCondition, isAdminRole, isTeacherOrAbove, matchedUsers, asRecord, parseDisplayIds, queryInteger, scopedUsers, rounded } from './helpers'
classCondition,
isAdminRole,
isTeacherOrAbove,
matchedUsers,
asRecord,
parseDisplayIds,
queryInteger,
scopedUsers,
rounded,
stripClassPrefix,
} from './helpers'
export const flowchartRoutes = new Hono<AppEnv>() export const flowchartRoutes = new Hono<AppEnv>()
@@ -48,7 +38,7 @@ function flowchartThrottleKey(userId: number) {
} }
/** /**
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸 * 专题防抄:专题布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图 * (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。 * 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
*/ */
@@ -69,7 +59,7 @@ function canView(
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
} }
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */ /** 只有学生看自己的提交时才需要查专题;其余情况给一张空表,省一次查询 */
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) { function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>()) if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId) const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
@@ -280,7 +270,7 @@ async function flowchartUserFilter(username: string, exact = false) {
const flowchartListColumns = { const flowchartListColumns = {
flowchart: { flowchart: {
id: schema.flowchartSubmission.id, id: schema.flowchartSubmission.id,
// showLink 判定要(本人 + 题单闸门),序列化本身用不到 // showLink 判定要(本人 + 专题闸门),序列化本身用不到
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId, problemId: schema.flowchartSubmission.problemId,
status: schema.flowchartSubmission.status, status: schema.flowchartSubmission.status,
@@ -293,6 +283,7 @@ const flowchartListColumns = {
evaluationTime: schema.flowchartSubmission.evaluationTime, evaluationTime: schema.flowchartSubmission.evaluationTime,
}, },
username: schema.user.username, username: schema.user.username,
className: schema.user.className,
problem: { problem: {
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
title: schema.problem.title, title: schema.problem.title,
@@ -349,10 +340,11 @@ flowchartRoutes.get('/flowcharts', requireAuth, async (c) => {
) )
return success(c, { return success(c, {
results: rows.map( results: rows.map(
({ flowchart, username, problem }) => ({ flowchart, username, className, problem }) =>
({ ({
id: flowchart.id, id: flowchart.id,
username, username,
className,
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
status: flowchart.status, status: flowchart.status,
@@ -540,7 +532,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({ const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: displayName(row.username),
bestScore: row.bestScore === null ? null : Number(row.bestScore), bestScore: row.bestScore === null ? null : Number(row.bestScore),
bestGrade: row.bestGrade, bestGrade: row.bestGrade,
count: row.count, count: row.count,
@@ -561,7 +553,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给 // 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
dataUnaccepted: roster.map((row) => ({ dataUnaccepted: roster.map((row) => ({
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: displayName(row.username),
})), })),
people: [], people: [],
peopleTruncated: false, peopleTruncated: false,
@@ -639,7 +631,7 @@ flowchartRoutes.get('/flowcharts/statistics', requireTeacher, async (c) => {
.filter((row) => !submitted.has(row.id)) .filter((row) => !submitted.has(row.id))
.map((row) => ({ .map((row) => ({
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: displayName(row.username),
})), })),
people, people,
peopleTruncated, peopleTruncated,
@@ -719,7 +711,7 @@ flowchartRoutes.post('/flowcharts/:id/retry', requireAuth, async (c) => {
}) })
/** /**
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。 * 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入专题之前的(专题闸门)。
* 结果页签的历次分数(scores)从这一份出。 * 结果页签的历次分数(scores)从这一份出。
* *
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次 * 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
@@ -748,7 +740,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
/** /**
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条, * 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。 * 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被专题闸门藏起来的次数,界面上给一句说明。
*/ */
flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => { flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => {
const problemId = queryInteger(c.req.param('id'), 0, { min: 1 }) const problemId = queryInteger(c.req.param('id'), 0, { min: 1 })
+11 -14
View File
@@ -1,6 +1,6 @@
import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from '@oj2/contract' import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from '@oj2/contract'
import { and, count, eq, ilike, inArray, isNull, notInArray, sql } from 'drizzle-orm' import { and, count, eq, ilike, inArray, isNull, notInArray } from 'drizzle-orm'
import type { AuthUser } from '../auth/session' import type { AuthUser } from '../auth/session'
import { db, schema } from '../db' import { db, schema } from '../db'
@@ -29,8 +29,9 @@ export function sampleUser(
} }
/** /**
* 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。 * 去掉老号用户名里的 `ks<班级号>` 前缀:`ks251张三` + `251` → `张三`。只剩旧版登录名单(site.ts 的
* 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。 * /classes/:className/usernames)在用 —— 旧登录页拿名字拼回「ks + 班号 + 名字」去登录,所以必须严格按这个班的班号剥。
* 界面上显示名字一律用契约的 `displayName`。
* *
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配, * 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。 * 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
@@ -42,19 +43,15 @@ export function stripClassPrefix(username: string, className: string | null | un
} }
/** /**
* 用户名按「班级前缀」匹配:`ks251` 只匹配 251 班,**不能**把 `ks2511张三` 也捞进来。 * 搜索框里填的是一个班(`232`,或者老习惯 `ks232`):按 `user.class_name` 精确圈这个班。
* 不是这个形状返回 undefined,由调用方退回按用户名包含匹配。
* *
* 班级号 3~4 位并存(见 admin/account.ts 的 classNameOf),裸 `ilike 'ks251%'` 会把 * 原来是拿用户名前缀 `ks232` 去 ilike,还得专门挡住 `ks2321…` 被 232 班捞走;
* 2510~2519 班全算进 251 班。所以输入是 `ks` + 3 位以上数字时,要求紧跟着的不是数字; * 2026-11 起新号不带前缀,前缀匹配会把他们全漏掉,所以只认班级这一列
* 1~2 位(`ks25`)是年级前缀,照旧宽松匹配。输入不是 `ks+数字` 形状时返回 undefined,
* 由调用方决定退回什么匹配方式。
*/ */
export function classPrefixCondition(input: string) { export function classKeywordCondition(input: string) {
const matched = /^ks(\d+)$/i.exec(input) const matched = /^(?:ks)?(\d{3,4})$/i.exec(input)
if (!matched) return undefined return matched ? eq(schema.user.className, matched[1]!) : undefined
const prefix = ilike(schema.user.username, `${input}%`)
if (matched[1]!.length < 3) return prefix
return and(prefix, sql`${schema.user.username} !~* ${`^${input}[0-9]`}`)
} }
/** /**
+4 -41
View File
@@ -1,5 +1,5 @@
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract' import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm' import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware' import { requireAuth, type AppEnv } from '../auth/middleware'
@@ -9,7 +9,6 @@ import { success } from '../http'
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity' import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import { accepted } from '../services/learning-stats'
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson' import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
import { calendarDay } from '../time'
/** /**
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。 * 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
@@ -145,7 +144,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS) const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null const steps = lesson.steps
const codeLanguage = steps ? steps.language : lesson.language const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查 // 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([ const [mine, drawn] = await Promise.all([
@@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
problems: lesson.problems.map((problem) => { problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id)) const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id)) const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id) const needs = steps?.needsOf(problem.id)
let myStatus: Step let myStatus: Step
if (needs) { if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完 // 老师布置的:要哪几步就看哪几步,全做到才算做完
@@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
}), }),
} satisfies ClassActivity) } satisfies ClassActivity)
}) })
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
+176
View File
@@ -0,0 +1,176 @@
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware'
import { db, schema } from '../db'
import { parseBody, success } from '../http'
import { JudgeStatus } from '../judge/status'
import { calendarDay, dayNumber, dayStart, weekStart } from '../time'
export const mascotRoutes = new Hono<AppEnv>()
/**
* 每人每分钟最多记这么多条。正常点她一分钟到不了十条(连点只记开头、彩蛋和嫌烦那几下),
* 拿脚本刷的才会撞上;撞上了就静默丢掉,不报错 —— 前台本来也不等这个结果
*/
const EVENTS_PER_MINUTE = 30
const windows = new Map<number, { start: number; count: number }>()
function allow(userId: number) {
const now = Date.now()
const entry = windows.get(userId)
if (!entry || now - entry.start >= 60_000) {
windows.set(userId, { start: now, count: 1 })
// 顺手清掉过期的,免得 Map 跟着在线人数一直涨
if (windows.size > 2000) for (const [id, it] of windows) if (now - it.start >= 60_000) windows.delete(id)
return true
}
entry.count++
return entry.count <= EVENTS_PER_MINUTE
}
/** 记一条看板娘互动(种类见契约 mascotEventSchema) */
mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
const parsed = await parseBody(c, mascotEventSchema, '看板娘记录的格式不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
if (allow(user.id)) {
const { kind, situation, line, action, path } = parsed.data
await db.insert(schema.mascotEvent).values({ userId: user.id, kind, situation, line, action, path })
}
return success(c, null)
})
/** 跨天记得的事只翻这么多天以内的 */
const MEMORY_DAYS = 7
/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */
function daysAgo(at: string | null, saidAt: string | null) {
// 按时刻比,别按字符串:库里出来的带微秒、位数不一定一样
if (!at || (saidAt && Date.parse(saidAt) > Date.parse(at))) return null
const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at))
return days >= 1 && days <= MEMORY_DAYS ? days : null
}
/** 跨天记得的事(契约 mascotProfileSchema.memory),一条语句查完 */
async function memoryOf(userId: number) {
const today = dayStart()
const since = new Date(Date.parse(today) - MEMORY_DAYS * 86_400_000).toISOString()
const e = schema.mascotEvent
/** 7 天内最后一次。`beforeToday`:今天的事不算「记得」,只取今天以前的 */
const last = (kind: string, situation: string | null, beforeToday: boolean) => sql`(
select max(${e.createdAt}) from ${e}
where ${e.userId} = ${userId} and ${e.kind} = ${kind}
${situation ? sql`and ${e.situation} = ${situation}` : sql``}
and ${e.createdAt} >= ${since}
${beforeToday ? sql`and ${e.createdAt} < ${today}` : sql``}
)`
const s = schema.submission
const [row] = await db.execute<{
sulk_at: string | null
grudge_at: string | null
pat_at: string | null
pat_said_at: string | null
closer_at: string | null
solved_before: number
}>(sql`
select
${last('open', 'sulk', true)} as sulk_at,
${last('open', 'memory.grudge', false)} as grudge_at,
${last('pat', null, true)} as pat_at,
${last('open', 'memory.pat', false)} as pat_said_at,
${last('open', 'memory.closer', false)} as closer_at,
(
select count(distinct ${s.problemId})::int from ${s}
where ${s.userId} = ${userId} and ${s.result} in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})
and ${s.createTime} < ${today}
) as solved_before
`)
return {
grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null),
patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null),
solvedBeforeToday: row?.solved_before ?? 0,
closerSaidToday: !!row?.closer_at && Date.parse(row.closer_at) >= Date.parse(today),
}
}
/** 最近多少次题库提交拿来看学生常犯哪类错 */
const RECENT_SUBMISSIONS = 20
/** 看板娘按学生的行为挑话用的那几个数(契约 mascotProfileSchema) */
mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
const userId = c.get('user')!.id
const s = schema.submission
const week = weekStart()
const e = schema.mascotEvent
const [pokes, solved, memory, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
db
.select({ value: count() })
.from(schema.mascotEvent)
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
// 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数
db
.select({ value: sql<number>`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` })
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId)),
memoryOf(userId),
db
.select({ value: countDistinct(s.problemId) })
.from(s)
.where(and(eq(s.userId, userId), eq(s.result, 0), gte(s.createTime, dayStart()))),
db
.select({ result: s.result, errorType: sql<string | null>`${s.statisticInfo}->'runtime_error'->>'type'` })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId)))
.orderBy(desc(s.createTime))
.limit(RECENT_SUBMISSIONS),
db
.select({ value: count() })
.from(schema.reaction)
.where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))),
db
.select({ value: count() })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))),
db
.select({ kind: e.kind, value: count() })
.from(e)
.where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week)))
.groupBy(e.kind),
db
.select({ value: count() })
.from(schema.aiHint)
.innerJoin(s, eq(s.id, schema.aiHint.submissionId))
.where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))),
])
const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0
const types = new Map<string, number>()
for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1)
const topType = [...types.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
const tally = (...results: number[]) => recent.filter((row) => results.includes(row.result)).length
return success(c, {
pokes: pokes[0]?.value ?? 0,
solved: solved[0]?.value ?? 0,
memory,
todaySolved: today[0]?.value ?? 0,
tooHard: tooHard[0]?.value ?? 0,
week: {
submits: weekSubmits[0]?.value ?? 0,
trials: eventCount('trial'),
helps: eventCount('help'),
aiHints: weekHints[0]?.value ?? 0,
},
recent: {
total: recent.length,
wrongAnswer: tally(-1),
compileError: tally(-2),
runtimeError: tally(4, 3),
timeout: tally(1, 2),
runtimeErrorType: topType,
},
} satisfies MascotProfile)
})
+26 -5
View File
@@ -12,6 +12,7 @@ import {
type ProblemListItem, type ProblemListItem,
type ProblemProgress, type ProblemProgress,
type ProblemTypeFilter, type ProblemTypeFilter,
type SimilarProblem,
type Tag, type Tag,
} from '@oj2/contract' } from '@oj2/contract'
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm' import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
@@ -327,13 +328,25 @@ problemRoutes.get('/challenges', requireAuth, async (c) => {
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => { problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const [target] = await db const [target] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id, difficulty: schema.problem.difficulty })
.from(schema.problem) .from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId))) .where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
.limit(1) .limit(1)
if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found') if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found')
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id)) // 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面
if (targetTags.length === 0) return success(c, []) // 只是为了「共同的标签」那行先写练的是什么
const targetTags = await db
.select({ id: schema.problemTags.problemtagId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTags.problemId, target.id))
// 同一类里大的在前,和题目列表「知识点」那列一样
.orderBy(
sql`${schema.problemTag.category} = 'knowledge' desc`,
sql`(select count(*) from ${schema.problemTags} pt where pt.problemtag_id = ${schema.problemTag.id}) desc`,
asc(schema.problemTag.name),
)
if (targetTags.length === 0) return success(c, [] satisfies SimilarProblem[])
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的 // 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)—— // 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
@@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const solvedIds = states ? [...states.solved] : [] const solvedIds = states ? [...states.solved] : []
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。 // High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
// 共同标签一样多时先给和原题难度最接近的:原来一律简单的在前,做对一道困难题,
// 五条推荐里三条多是简单题(本地库 35 道困难题:119 / 175)。再并列才先简单的,
// 照顾连错三次的人
const rankOf = (difficulty: string) => (difficulty === 'Low' ? 0 : difficulty === 'Mid' ? 1 : 2)
const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end` const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end`
const difficultyGap = sql`abs(${difficultyRank} - ${sql.raw(String(rankOf(target.difficulty)))})`
const sharedTags = count(schema.problemTags.problemtagId) const sharedTags = count(schema.problemTags.problemtagId)
const rows = await db const rows = await db
.select({ .select({
@@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
) )
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) .groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
// 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条 // 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
.orderBy(desc(sharedTags), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id)) .orderBy(desc(sharedTags), asc(difficultyGap), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id))
.limit(5) .limit(5)
const ids = rows.map((row) => row.problem.id) const ids = rows.map((row) => row.problem.id)
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)]) const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
return success( return success(
c, c,
rows.map((row) => listItem(row, tags, statuses, states, counts)), rows.map((row) => {
const own = new Set(tags.get(row.problem.id))
return { ...listItem(row, tags, statuses, states, counts), sharedTags: targetTags.map((tag) => tag.name).filter((name) => own.has(name)) }
}) satisfies SimilarProblem[],
) )
}) })
+12 -12
View File
@@ -27,7 +27,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */ /** 必做题的 problemId;一道必做都没标的专题退回全部(和 computeProgress 同一个口径) */
function gradedIds(links: { problemId: number; isRequired: boolean }[]) { function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
const required = links.filter((link) => link.isRequired) const required = links.filter((link) => link.isRequired)
return new Set((required.length ? required : links).map((link) => link.problemId)) return new Set((required.length ? required : links).map((link) => link.problemId))
@@ -56,7 +56,7 @@ function progressSummary(progress: ProgressRow | undefined) {
} }
/** /**
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生 * 学生能看到的专题:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
* 看到的样子、看全班情况,不该被 404 挡在外面。 * 看到的样子、看全班情况,不该被 404 挡在外面。
*/ */
async function loadVisible(id: number, user: AuthUser | null | undefined) { async function loadVisible(id: number, user: AuthUser | null | undefined) {
@@ -99,7 +99,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime
} }
/** /**
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。 * 一次把整页专题的附属数据全查回来,再在内存里按 problemsetId 分组。
* 固定 6 条查询,与行数无关(按行查就是 N+1)。 * 固定 6 条查询,与行数无关(按行查就是 N+1)。
*/ */
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) { async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) {
@@ -153,7 +153,7 @@ async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boole
assignedUntil: row.assignedUntil, assignedUntil: row.assignedUntil,
assigning, assigning,
problemsCount: countBySet.get(row.id)?.value ?? 0, problemsCount: countBySet.get(row.id)?.value ?? 0,
// 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径 // 一道必做都没标的专题退回「全部都算必做」,和 computeProgress 同一个口径
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0), requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
joinedCount: joinedBySet.get(row.id) ?? 0, joinedCount: joinedBySet.get(row.id) ?? 0,
userProgress: progressSummary(progress), userProgress: progressSummary(progress),
@@ -207,7 +207,7 @@ problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => {
problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => { problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user')) const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const [data] = await serializeProblemSets([row], c.get('user')?.id) const [data] = await serializeProblemSets([row], c.get('user')?.id)
return success(c, data) return success(c, data)
}) })
@@ -215,10 +215,10 @@ problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => { problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
const user = c.get('user') const user = c.get('user')
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user) const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user)
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const id = found.set.id const id = found.set.id
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标 // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变 // 编号的(1、2、3),老专题里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
const rows = await db const rows = await db
.select({ .select({
link: schema.problemsetProblem, link: schema.problemsetProblem,
@@ -295,14 +295,14 @@ problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => {
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get('user')! const user = c.get('user')!
const found = await loadVisible(parsed.data.problemSetId, user) const found = await loadVisible(parsed.data.problemSetId, user)
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const problemSet = found.set const problemSet = found.set
const [existing] = await db const [existing] = await db
.select({ id: schema.problemsetProgress.id }) .select({ id: schema.problemsetProgress.id })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))) .where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id)))
.limit(1) .limit(1)
if (existing) return failure(c, 409, 'already-joined', '已经加入该题单') if (existing) return failure(c, 409, 'already-joined', '已经加入该专题')
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const links = await tx const links = await tx
.select({ .select({
@@ -359,9 +359,9 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
}) })
/** /**
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 * 老师看一个班在这个专题里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
* *
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计 * 所有老师都能看,不只是专题的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
* 也是任何老师看任何班,这里和它们一致。 * 也是任何老师看任何班,这里和它们一致。
* *
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它, * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
@@ -370,7 +370,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
*/ */
problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => { problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => {
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user')) const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在') if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
const id = found.set.id const id = found.set.id
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES]) const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
const [links, joinedClasses] = await Promise.all([ const [links, joinedClasses] = await Promise.all([
+1 -8
View File
@@ -252,14 +252,7 @@ rankingRoutes.get('/rankings/champions', optionalAuth, async (c) => {
const champions: WeeklyChampion[] = [] const champions: WeeklyChampion[] = []
for (let k = 1; k <= CHAMPION_WEEKS; k++) { for (let k = 1; k <= CHAMPION_WEEKS; k++) {
const from = thisWeek - k * WEEK_MS const from = thisWeek - k * WEEK_MS
const inWeek = entrants.map((entrant) => { const inWeek = entrants.map((entrant) => ({ ...entrant, times: entrant.times.filter((time) => time >= from && time < from + WEEK_MS) }))
const keep = entrant.times.map((time, index) => [time, entrant.stamps[index]!] as const).filter(([time]) => time >= from && time < from + WEEK_MS)
return {
...entrant,
times: keep.map(([time]) => time),
stamps: keep.map(([, stamp]) => stamp),
}
})
const [top] = standings(inWeek, null, false) const [top] = standings(inWeek, null, false)
if (!top) continue if (!top) continue
champions.push({ champions.push({
+27 -4
View File
@@ -1,4 +1,4 @@
import type { Quote, WebsiteConfig } from '@oj2/contract' import { CLASS_NAME_RE, displayName, type ClassMember, type Quote, type WebsiteConfig } from '@oj2/contract'
import { asc, desc, eq } from 'drizzle-orm' import { asc, desc, eq } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { resolve } from 'node:path' import { resolve } from 'node:path'
@@ -92,9 +92,33 @@ siteRoutes.get('/quotes/random', async (c) => {
} }
}) })
siteRoutes.get('/classes/:className/usernames', async (c) => { /**
* 登录框点名字的名单:名字给人点,用户名拿去登录(老号 ks232张三,新号就是名字)。名字按界面上的显示规则剥,
* 和 UserName 一致 —— 不能只剥「ks + 当前班号」:班级现在能在后台改,换过班的老号会露出整串
*/
siteRoutes.get('/classes/:className/members', async (c) => {
const className = c.req.param('className').trim() const className = c.req.param('className').trim()
if (!/^\d{3,4}$/.test(className)) { if (!CLASS_NAME_RE.test(className)) {
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
}
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
return success(
c,
rows.map(({ username }) => ({ name: displayName(username), username }) satisfies ClassMember),
)
})
/**
* 旧版登录框的名单:只回名字,前端拼「ks + 班号 + 名字」去登录。2026-11 的新前端改走上面的 /members;
* 这条原样留着,是给上线时还开着旧登录页、没刷新的机房电脑用的(老号照样登得上)。过一阵可以删
*/
siteRoutes.get('/classes/:className/usernames', async (c) => {
const className = c.req.param('className').trim()
if (!CLASS_NAME_RE.test(className)) {
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits') return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
} }
const rows = await db const rows = await db
@@ -102,7 +126,6 @@ siteRoutes.get('/classes/:className/usernames', async (c) => {
.from(schema.user) .from(schema.user)
.where(eq(schema.user.className, className)) .where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id)) .orderBy(desc(schema.user.createTime), asc(schema.user.id))
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
return success( return success(
c, c,
rows.map(({ username }) => stripClassPrefix(username, className)), rows.map(({ username }) => stripClassPrefix(username, className)),
+15 -9
View File
@@ -7,6 +7,7 @@
*/ */
import { import {
displayName,
FLOWCHART_PASS_GRADES, FLOWCHART_PASS_GRADES,
NO_CLASS, NO_CLASS,
STUDENT_ROLES, STUDENT_ROLES,
@@ -27,7 +28,7 @@ import { type ContestEnv } from '../services/contest'
import { getBooleanOption } from '../services/options' import { getBooleanOption } from '../services/options'
import { lessonDoneCounts } from '../services/lesson' import { lessonDoneCounts } from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time' import { calendarDay, dayStart, localTime } from '../time'
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers' import { isAdminRole, parseDisplayIds, rounded, scopedUsers } from './helpers'
export const submissionStatisticsRoutes = new Hono<ContestEnv>() export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -345,7 +346,7 @@ submissionStatisticsRoutes.get('/submissions/statistics', requireTeacher, async
.filter((row) => !submittedUserIds.has(row.id)) .filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({ .map((row) => ({
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: displayName(row.username),
})) }))
/** /**
@@ -374,7 +375,7 @@ submissionStatisticsRoutes.get('/submissions/statistics', requireTeacher, async
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁, * 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。 * 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
*/ */
realName: scoped ? stripClassPrefix(row.username, row.className) : row.username, realName: scoped ? displayName(row.username) : row.username,
submissionCount: row.submissionCount, submissionCount: row.submissionCount,
solvedCount: row.solvedCount, solvedCount: row.solvedCount,
})) }))
@@ -403,7 +404,7 @@ const GRID_LIMIT = 5000
/** /**
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。 * 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。 * 班级和学生名的匹配和统计接口同一套(scopedUsers:班级精确、名字包含)。
*/ */
submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => { submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => {
const scope = await statisticsScope(c) const scope = await statisticsScope(c)
@@ -481,7 +482,7 @@ submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, a
if (!entry) { if (!entry) {
entry = { entry = {
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: displayName(row.username),
className: row.className, className: row.className,
submissions: [], submissions: [],
} }
@@ -526,6 +527,11 @@ const LESSON_LIVE_MINUTES = 15
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */ /** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
const LESSONS_DEFAULT = 50 const LESSONS_DEFAULT = 50
const LESSONS_MAX = 500 const LESSONS_MAX = 500
/** 课只算学生(STUDENT_ROLES,学生管理员也算),老师试题的提交不算 */
const studentRoles = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
type StatisticsRange = { start: string | null; end: string } type StatisticsRange = { start: string | null; end: string }
@@ -598,7 +604,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
where s.contest_id is null and p.mode <> 'flowchart' where s.contest_id is null and p.mode <> 'flowchart'
and s.create_time <= ${range.end} and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``} ${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false and u.admin_type in (${studentRoles}) and u.is_disabled = false
union all union all
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join( select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`), FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
@@ -611,7 +617,7 @@ async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
where p.mode <> 'code' where p.mode <> 'code'
and f.create_time <= ${range.end} and f.create_time <= ${range.end}
${range.start ? sql`and f.create_time >= ${range.start}` : sql``} ${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false and u.admin_type in (${studentRoles}) and u.is_disabled = false
) )
select k, problem_id, select k, problem_id,
count(distinct user_id)::int as users, count(distinct user_id)::int as users,
@@ -720,8 +726,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher
where s.contest_id is null where s.contest_id is null
and s.create_time <= ${range.end} and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``} ${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
-- 只算普通学生:老师试题的提交不该把一段拼成「一节课」 -- 只算学生(学生管理员也是学生):老师试题的提交不该把一段拼成「一节课」
and u.admin_type = 'Regular User' and u.is_disabled = false and u.admin_type in (${studentRoles}) and u.is_disabled = false
), ),
g as ( g as (
select *, select *,
+16 -16
View File
@@ -101,10 +101,10 @@ submissionRoutes.post('/submissions', requireAuth, async (c) => {
return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`) return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`)
} }
// 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了 // 来源专题:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
// 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 —— // 在提交列表里标出「这条是刷专题刷出来的」。校验只确认这道题确实在那个专题里 ——
// 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题), // 不查 visible / status,因为藏起来的专题里还困着已加入的学生(他们照样在做题),
// 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。 // 也不查有没有加入:没加入照样能从专题页点进题目,标记来源不该比入口本身更严。
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。 // 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
let problemsetId: number | null = null let problemsetId: number | null = null
if (contestId === null && parsed.data.problemSetId) { if (contestId === null && parsed.data.problemSetId) {
@@ -241,7 +241,7 @@ function canViewSubmission(
problemSetCutoffs?: Map<number, string>, problemSetCutoffs?: Map<number, string>,
) { ) {
if (!user) return false if (!user) return false
// 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。 // 专题防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
if (row.userId === user.id && !isTeacherOrAbove(user)) { if (row.userId === user.id && !isTeacherOrAbove(user)) {
const cutoff = problemSetCutoffs?.get(row.problemId) const cutoff = problemSetCutoffs?.get(row.problemId)
@@ -273,7 +273,7 @@ const submissionListColumns = {
id: schema.submission.id, id: schema.submission.id,
createTime: schema.submission.createTime, createTime: schema.submission.createTime,
userId: schema.submission.userId, userId: schema.submission.userId,
// 题单闸门要按题定位,序列化本身用不到它 // 专题闸门要按题定位,序列化本身用不到它
problemId: schema.submission.problemId, problemId: schema.submission.problemId,
/** /**
* 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的 * 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的
@@ -287,8 +287,8 @@ const submissionListColumns = {
result: schema.submission.result, result: schema.submission.result,
language: schema.submission.language, language: schema.submission.language,
statisticInfo: schema.submission.statisticInfo, statisticInfo: schema.submission.statisticInfo,
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来 // 只取 id,专题标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜 // 会动到下面那条调过的分页查询,而每页最多两三个不同的专题,PK 查一次更便宜
problemsetId: schema.submission.problemsetId, problemsetId: schema.submission.problemsetId,
/** /**
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。 * 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
@@ -331,7 +331,7 @@ function listCaseSummary(row: { language: string; casesTotal: number | null; cas
/** /**
* 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的 * 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的
* 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。 * 代码在专题布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
*/ */
async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) { async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) {
const [row] = await db const [row] = await db
@@ -355,7 +355,7 @@ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolea
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId]) const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId])
const visible = canViewSubmission(user, row.submission, row.problem, cutoffs) const visible = canViewSubmission(user, row.submission, row.problem, cutoffs)
// 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission) // 走到这里还看不了、又是自己的,只剩专题布置期这一种(见 canViewSubmission)
if (!visible && !own) return { status: 'forbidden' } as const if (!visible && !own) return { status: 'forbidden' } as const
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
@@ -489,8 +489,8 @@ async function paginateSubmissionRows(where: SQL | undefined, limit: number, off
} }
/** /**
* 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。 * 这一页里出现过的来源专题,id → 标题。传进来的数组允许带 null 和重复值。
* 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。 * 一页最多 250 行、实际能落到的专题数是个位数,按主键 IN 查一次就完了。
*/ */
async function problemsetTitleMap(ids: Array<number | null>) { async function problemsetTitleMap(ids: Array<number | null>) {
const unique = [...new Set(ids.filter((id): id is number => id !== null))] const unique = [...new Set(ids.filter((id): id is number => id !== null))]
@@ -545,7 +545,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
user && !isTeacherOrAbove(user) user && !isTeacherOrAbove(user)
? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))]) ? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))])
: undefined, : undefined,
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 // 来源专题的标题。一页里不同专题最多几个,按主键查一次就够
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
]) ])
return success(c, { return success(c, {
@@ -563,7 +563,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
statisticInfo: asRecord(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 // 专题被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet: problemSet:
submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? { ? {
@@ -609,7 +609,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
.offset(offset), .offset(offset),
displayId ? unknownDisplayIds(displayId, contest.id) : [], displayId ? unknownDisplayIds(displayId, contest.id) : [],
]) ])
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了 // 这里不挂专题防作弊闸门(对比公开列表):专题里的题必定是非比赛题——加题时卡了
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交, // `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
@@ -628,7 +628,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
statisticInfo: asRecord(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // 比赛提交没有来源专题:专题只收非比赛题(admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null, problemSet: null,
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema) // 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
+27 -12
View File
@@ -5,7 +5,6 @@ import {
type TaskBoard, type TaskBoard,
type TaskBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } from '@oj2/contract'
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
@@ -127,9 +126,14 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
const challengeDoneKeys = new Set( const challengeDoneKeys = new Set(
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`), challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
) )
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样) // 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)。
// 格子按每道题合起来的要求算(todayNeeds):先画流程图一节、再写代码一节,没画图的照样标「缺图」,
// 和学生那边的「做完」对得上。代码按的语言也是合起来那份:最后一节只画流程图时,用前面写代码那节的
const mode = lesson.source === 'teacher' ? lesson.mode : null const mode = lesson.source === 'teacher' ? lesson.mode : null
const needs = mode ? lessonNeeds(mode) : null const steps = lesson.steps
const needsOf = (problemId: number) => steps?.needsOf(problemId) ?? null
const anyDraw = ids.some((id) => needsOf(id)?.draw)
const codeLanguage = steps ? steps.language : lesson.language
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([ const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
@@ -223,7 +227,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
gte(schema.submission.createTime, start), gte(schema.submission.createTime, start),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.orderBy(schema.submission.createTime) .orderBy(schema.submission.createTime)
@@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数, * 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null * 老评法和还没评完的为 null
*/ */
needs?.draw && ids.length && userIds.length anyDraw && ids.length && userIds.length
? db ? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], { .selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
@@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
title: problem.title, title: problem.title,
isSql: problem.isSql, isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0, hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
mode: steps?.modeOf(problem.id) ?? null,
})), })),
students: roster.map( students: roster.map(
(student) => (student) =>
@@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
username: student.username, username: student.username,
realName: student.realName ?? null, realName: student.realName ?? null,
cells: ids.map((id) => { cells: ids.map((id) => {
const needs = needsOf(id)
const key = `${student.userId}:${id}` const key = `${student.userId}:${id}`
const cell = cellByKey.get(key) const cell = cellByKey.get(key)
const flow = drawnByKey.get(key) const flow = drawnByKey.get(key)
@@ -305,11 +311,11 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0) attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
} else { } else {
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步 // 要哪几步看哪几步:两步都做到的时刻是晚的那一步
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter( const wanted = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
(step) => step !== null, (step) => step !== null,
) )
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none' status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0) attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
} }
// 下面几样都是代码那一步的:只画流程图的课没有 // 下面几样都是代码那一步的:只画流程图的课没有
@@ -352,8 +358,9 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
})) }))
: [], : [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null, acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
challenge: challenge:
!codeWanted || !codeDone || !liveChallenges.get(id)?.size !codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
? null ? null
: challengeDoneKeys.has(key) : challengeDoneKeys.has(key)
? 'done' ? 'done'
@@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0 let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) { if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码) // 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
const counts = await lessonDoneCounts([ const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode }, {
id: lesson.lessonId,
className,
day: calendarDay(),
problemIds: ids,
language: lesson.steps?.language ?? lesson.language,
mode: lesson.mode,
modeOf: lesson.steps?.modeOf,
},
]) ])
allDoneCount = counts.get(lesson.lessonId) ?? 0 allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) { } else if (ids.length) {
+1 -1
View File
@@ -25,7 +25,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from '../se
* *
* **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info * **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info
* 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后 * 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。 * user_achievement 随成就级联,漂不了)、专题进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
* *
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate: * 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate:
* *
+15 -10
View File
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'accepted_count', key: 'accepted_count',
name: '做对的题数', name: '做对的题数',
helpText: '去重后通过的题目数量(不含比赛)', helpText: '去重后通过的题目数量(含比赛题)',
}, },
{ {
key: 'mid_ac_count', key: 'mid_ac_count',
name: '做对的中等题', name: '做对的中等题',
helpText: '去重后通过的中等难度题目数(不含比赛)', helpText: '去重后通过的中等难度题目数(含比赛题)',
}, },
{ {
key: 'hard_ac_count', key: 'hard_ac_count',
name: '做对的困难题', name: '做对的困难题',
helpText: '去重后通过的困难题目数(不含比赛)', helpText: '去重后通过的困难题目数(含比赛题)',
}, },
{ {
key: 'submission_count', key: 'submission_count',
name: '提交次数', name: '提交次数',
helpText: '提交次数(不含比赛)', helpText: '提交次数(含比赛)',
}, },
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' }, { key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
{ {
@@ -45,19 +45,24 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'max_ac_week_streak', key: 'max_ac_week_streak',
name: '连续有新做对的周数', name: '连续有新做对的周数',
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)', helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
}, },
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' }, { key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
{ {
key: 'contest_joined', key: 'contest_joined',
name: '参加比赛', name: '参加比赛',
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)', helpText: '交过题的比赛数量',
}, },
{ key: 'badge_count', name: '题单奖章', helpText: '获得的题单奖章数量' }, { key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
{ {
key: 'problemset_completed', key: 'problemset_completed',
name: '做完的题单', name: '做完的专题',
helpText: '完成的题单数量', helpText: '完成的专题数量',
},
{
key: 'challenge_count',
name: '挑战成功',
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
}, },
{ {
key: 'first_try_ac_count', key: 'first_try_ac_count',
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'max_code_lines', key: 'max_code_lines',
name: '最长的代码行数', name: '最长的代码行数',
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)', helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
}, },
{ {
key: 'achievement_unlocked_count', key: 'achievement_unlocked_count',
+20 -10
View File
@@ -1,4 +1,4 @@
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm' import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
import { db, schema, type DbOrTx } from '../db' import { db, schema, type DbOrTx } from '../db'
import { publishAchievementNotification } from '../events' import { publishAchievementNotification } from '../events'
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
/** /**
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。 * 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
* 参加过几场比赛、挑战成功几次也在这里一起数。
* *
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题 * 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条 * 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。 * 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx, * 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
* 一个人的提交也就几百条。 * 一个人的提交也就几百条。
*
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
*/ */
async function solvedCounts(tx: DbOrTx, userId: number) { async function solvedCounts(tx: DbOrTx, userId: number) {
const [row] = await tx.execute<{ const [row] = await tx.execute<{
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
mid: number mid: number
hard: number hard: number
first_try: number first_try: number
contests: number
challenges: number
}>( }>(
sql` sql`
with mine as ( with mine as (
select s.problem_id, s.result, s.create_time, s.id, p.difficulty select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
from ${schema.submission} s from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id join ${schema.problem} p on p.id = s.problem_id
where s.user_id = ${userId} and s.contest_id is null where s.user_id = ${userId}
) )
select select
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted, count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
select count(*)::int from ( select count(*)::int from (
select distinct on (problem_id) result from mine order by problem_id, create_time, id select distinct on (problem_id) result from mine order by problem_id, create_time, id
) first where result in (${ACCEPTED_RESULTS}) ) first where result in (${ACCEPTED_RESULTS})
) as first_try ) as first_try,
count(distinct contest_id)::int as contests,
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
from mine from mine
`, `,
) )
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 } return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
} }
/** /**
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)) .where(eq(schema.submission.id, submissionId))
.limit(1) .limit(1)
if (!row || row.submission.contestId !== null) return [] if (!row) return []
const rejudged = countedAs !== null const rejudged = countedAs !== null
const accepted = isAccepted(row.submission.result) const accepted = isAccepted(row.submission.result)
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
value.mid_ac_count = solved.mid value.mid_ac_count = solved.mid
value.hard_ac_count = solved.hard value.hard_ac_count = solved.hard
value.first_try_ac_count = solved.first_try value.first_try_ac_count = solved.first_try
value.contest_joined = solved.contests
value.challenge_count = solved.challenges
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1 if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
if (firstAc) { if (firstAc) {
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」 // 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
and( and(
eq(schema.submission.userId, row.submission.userId), eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId), eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
lt(schema.submission.createTime, row.submission.createTime), lt(schema.submission.createTime, row.submission.createTime),
), ),
) )
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
const metric = findMetric(achievement.metric) const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 } if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) // contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll() if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算 // max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll() if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并, * `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
* 其余指标不动,写法同 refreshContestJoinedForAll)。 * 其余指标不动,写法同 refreshContestJoinedForAll)。
* *
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径; * 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。 * 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
*/ */
async function refreshAcWeekStreakForAll() { async function refreshAcWeekStreakForAll() {
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有 // 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败 // 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))) .where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
.as('first_accepted') .as('first_accepted')
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')` const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
+60 -5
View File
@@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) {
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/ */
export async function lessonDoneCounts( export async function lessonDoneCounts(
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[], lessons: {
id: number
className: string
day: string
problemIds: number[]
language: LessonLanguage | null
mode: LessonMode
/** 每道题各自做什么(老师首页按今天几节合起来的要求算,见 todayNeeds);不给就都按 mode */
modeOf?: (problemId: number) => LessonMode
}[],
) { ) {
const rows = lessons.flatMap((lesson) => const rows = lessons.flatMap((lesson) =>
lesson.problemIds.map((problemId) => ({ lesson.problemIds.map((problemId) => ({
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
class_name: lesson.className, class_name: lesson.className,
problem_id: problemId, problem_id: problemId,
language: lesson.language, language: lesson.language,
mode: lesson.mode, mode: lesson.modeOf?.(problemId) ?? lesson.mode,
until: lessonDayEnd(lesson.day), until: lessonDayEnd(lesson.day),
})), })),
) )
@@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) {
return row?.problemIds.length ? row : null return row?.problemIds.length ? row : null
} }
/**
* 一道题今天要画还是要写:这个班今天布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再「用这几道题写代码」开一节时,还没画完的照样要画 ——
* 只看今天最后布置的那一节的话,一布置写代码,画图那一步就没了。学生的课堂条、任务页的格子、
* 老师首页的「全做对」都按它算,三处说的「做完」才一样(后台和数据统计还是一节一行、各数各的)。
*
* `language`:代码按什么语言数 —— 今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
export async function todayNeeds(className: string, problemIds: number[], latest: { id: number; language: LessonLanguage | null; mode: LessonMode }) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.id ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
const latestNeeds = lessonNeeds(latest.mode)
const needsOf = (id: number) => needs.get(id) ?? latestNeeds
const modeOf = (id: number): LessonMode => {
const { draw, code } = needsOf(id)
return draw && code ? 'both' : draw ? 'flowchart' : 'code'
}
return { needsOf, modeOf, language }
}
/** /**
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher), * 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 * 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
@@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
const plan = await lessonPlan(className, today) const plan = await lessonPlan(className, today)
if (plan) { if (plan) {
const planned = plan.problemIds const planned = plan.problemIds
const [problems, groups] = await Promise.all([ const steps = await todayNeeds(className, planned, plan)
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
const [problems, ...groupLists] = await Promise.all([
visibleProblems(planned), visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }), ...[...byMode].map(([mode, ids]) =>
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
),
]) ])
const groupById = new Map(groups.map((row) => [row.problemId, row])) const groupById = new Map(groupLists.flat().map((row) => [row.problemId, row]))
return { return {
source: 'teacher' as const, source: 'teacher' as const,
lessonId: plan.id, lessonId: plan.id,
day: today, day: today,
language: plan.language ?? null, language: plan.language ?? null,
mode: plan.mode, mode: plan.mode,
steps,
assignedAt: plan.createdAt, assignedAt: plan.createdAt,
assignedBy: plan.createdBy, assignedBy: plan.createdBy,
problems: planned.flatMap((id) => { problems: planned.flatMap((id) => {
@@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null, language: null,
mode: null, mode: null,
steps: null,
assignedAt: null, assignedAt: null,
assignedBy: null, assignedBy: null,
problems: picked.flatMap((row) => { problems: picked.flatMap((row) => {
+1 -1
View File
@@ -4,7 +4,7 @@ import type { AuthUser } from '../auth/session'
import { db, schema } from '../db' import { db, schema } from '../db'
/** /**
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、 * 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、专题、
* 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里 * 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里
* 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。 * 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@ import { cached } from './cache'
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。 * 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
* *
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到 * 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
* A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档 * A / S 也算做对、交过流程图就算交过 —— 和专题、课堂条一个口径(题目页设计文档
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。 * 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
*/ */
export async function problemStates(userId: number) { export async function problemStates(userId: number) {
+26 -26
View File
@@ -11,7 +11,7 @@ type ProblemLink = { problemId: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'> type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'>
/** /**
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。 * 专题进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
* *
* 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大, * 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大,
* 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。 * 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。
@@ -19,7 +19,7 @@ type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCou
*/ */
export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) { export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) {
const inSet = new Set(links.map((link) => String(link.problemId))) const inSet = new Set(links.map((link) => String(link.problemId)))
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。 // 已经移出专题的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。
// 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害 // 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害
const kept: Record<string, unknown> = {} const kept: Record<string, unknown> = {}
for (const [key, value] of Object.entries(detail)) { for (const [key, value] of Object.entries(detail)) {
@@ -27,17 +27,17 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
} }
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems // 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到 // 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
// (题单 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。 // (专题 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。
// //
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是 // 一道必做都没标的专题退回「全部都算必做」:那种专题多半是没用这个字段,而不是
// 真的整单选做;不兜住的话它永远完不成。 // 真的整单选做;不兜住的话它永远完不成。
const required = links.filter((link) => link.isRequired) const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId))) const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
const total = graded.length const total = graded.length
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」, // total > 0 这个前提不能省:0 === 0 同样成立,没有题目的专题会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。 // 还会写下 complete_time、计进「完成专题数」成就,而且后面补上题目也不会自愈。
const isCompleted = total > 0 && completed === total const isCompleted = total > 0 && completed === total
return { return {
progressDetail: kept, progressDetail: kept,
@@ -46,9 +46,9 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
isCompleted, isCompleted,
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。 // 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
// //
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它 // 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 专题 8 那批人在它
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。 // 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
// 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉, // 反过来清空的代价是不可逆:往一个 100 人已完成的专题里加一道题、再改主意删掉,
// 这 100 个人的历史完成时间就一起被冲成了「现在」。 // 这 100 个人的历史完成时间就一起被冲成了「现在」。
completeTime: previousCompleteTime ?? (isCompleted ? now : null), completeTime: previousCompleteTime ?? (isCompleted ? now : null),
} }
@@ -57,7 +57,7 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
type ProgressWrite = ReturnType<typeof computeProgress> & { id: number } type ProgressWrite = ReturnType<typeof computeProgress> & { id: number }
/** /**
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返, * 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的专题就是上百次往返,
* 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。 * 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。
*/ */
async function writeProgress(rows: ProgressWrite[]) { async function writeProgress(rows: ProgressWrite[]) {
@@ -93,11 +93,11 @@ async function writeProgress(rows: ProgressWrite[]) {
} }
/** /**
* 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 —— * 奖章达标判定的唯一实现。学生做出一题、后台改专题、补发脚本三处都调它 ——
* 以前是三份各写一遍。 * 以前是三份各写一遍。
* *
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount * problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
* (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5 * (自从分母只算必做,那个只数必做题)。老师当初是按专题的总题数设阈值的:专题 5
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间 * 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
* 不可得,76 个已经拿到的人被 recalculateBadge 收回。 * 不可得,76 个已经拿到的人被 recalculateBadge 收回。
*/ */
@@ -144,11 +144,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
} }
/** /**
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。 * 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这个专题的奖章。
* *
* 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了, * 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了,
* 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete * 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
* 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用, * 上重算全部参与者的进度、再重算该专题全部奖章的资格。重写时 views 里看不到显式调用,
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。 * 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。
* *
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补 * 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
@@ -177,16 +177,16 @@ export async function resyncProgress(problemsetId: number) {
} }
/** /**
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。 * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的专题。
* *
* 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调 * 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调
* 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失; * 路由参数里那一个专题。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
* 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次 * 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次
* 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。 * 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。
* *
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。 * 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
* *
* 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 * 不按 visible / status 过滤:进度是学生自己的记录,老师把专题藏起来不该让它停止累积。
*/ */
export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) { export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) {
const joined = await db const joined = await db
@@ -250,7 +250,7 @@ export async function recordSolvedProblem(userId: number, problemId: number, sol
} }
/** /**
* 记题单进度,并把这一下拿到的徽章、解锁的题单类成就推给学生。判题(judge/run.ts)和 * 记专题进度,并把这一下拿到的徽章、解锁的专题类成就推给学生。判题(judge/run.ts)和
* 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。 * 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。 * 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
*/ */
@@ -289,16 +289,16 @@ export async function recordSolvedAndNotify(userId: number, problemId: number, s
} }
} }
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */ /** 专题现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())` export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
/** /**
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻 * 专题防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts) * (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
* 共用这一道闸。 * 共用这一道闸。
* *
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前** * 规则(2026-10 题单重设计,用户拍板):专题**布置期内**,专题里这几道题,学生在**布置开始之前**
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的 * 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习专题:快照里 16% 的
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。 * 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
* *
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了; * 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
@@ -306,11 +306,11 @@ export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not
* 在这一两周里也看不到自己的旧代码,用户认了。 * 在这一两周里也看不到自己的旧代码,用户认了。
* *
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡: * 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 题单不在布置期(没布置,或者布置期过了) * - 专题不在布置期(没布置,或者布置期过了)
* - 题单没公开 * - 专题没公开
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key) * - 已经在这个专题里做对了这道题(progress_detail 里有这道题的 key)
* *
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。 * 一道题可能同时落在几个正在布置的专题里,取最晚的布置开始 ——「任一专题要求遮挡就遮挡」。
*/ */
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) { export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
const cutoffs = new Map<number, string>() const cutoffs = new Map<number, string>()
@@ -338,7 +338,7 @@ export async function problemSetLockCutoffs(userId: number, problemIds: number[]
return cutoffs return cutoffs
} }
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */ /** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的专题里 */
export async function problemSetLocksFor(userId: number, problemId: number) { export async function problemSetLocksFor(userId: number, problemId: number) {
return db return db
.select({ .select({
+31 -29
View File
@@ -36,9 +36,8 @@ export interface Entrant {
className: string | null className: string | null
avatar: string | null avatar: string | null
mood: string | null mood: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */ /** 每道题第一次做对的时刻(毫秒),升序 */
times: number[] times: number[]
stamps: string[]
} }
export interface Standing { export interface Standing {
@@ -64,10 +63,26 @@ export function avatarOf(avatar: string | null) {
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是 * 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。 * 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE * 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
* contest_id IS NULL),全服全部历史实测 24ms。 * contest_id IS NULL)。
*
* 时刻在库里就换成毫秒数、按人排好聚成数组再拿回来:全服全部历史有六万多个 (人, 题),
* 一行一个 timestamptz 的话,光 db/index.ts 那个保留微秒的 parser 加 `Date.parse` 就
* 要 150ms(两核上),库里的查询本身才 40ms。截到毫秒和原来 `Date.parse` 比较的精度一样,
* 名次不变;`reachedAt` 只拿来显示「比你早多久」,用不着微秒。
*/ */
export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> { export async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
const firstSolved = min(schema.submission.createTime) const firstSolved = min(schema.submission.createTime)
const firsts = db
.select({
userId: schema.submission.userId,
at: sql<number>`floor(extract(epoch from ${firstSolved}) * 1000)::float8`.as('at'),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(where, isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined)
.as('firsts')
const [people, solves] = await Promise.all([ const [people, solves] = await Promise.all([
db db
.select({ .select({
@@ -81,33 +96,20 @@ export async function loadEntrants(where: SQL, since: string | null): Promise<En
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where), .where(where),
db db
.select({ userId: schema.submission.userId, at: firstSolved }) .select({ userId: firsts.userId, times: sql<number[]>`array_agg(${firsts.at} order by ${firsts.at})` })
.from(schema.submission) .from(firsts)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .groupBy(firsts.userId),
.where(and(where, isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined),
]) ])
const byUser = new Map<number, string[]>() const byUser = new Map(solves.map((row) => [row.userId, row.times]))
for (const row of solves) { return people.map((person) => ({
if (!row.at) continue id: person.id,
const list = byUser.get(row.userId) username: person.username,
if (list) list.push(row.at) className: person.className,
else byUser.set(row.userId, [row.at]) avatar: avatarOf(person.avatar),
} mood: person.mood?.trim() || null,
return people.map((person) => { times: byUser.get(person.id) ?? [],
const pairs = (byUser.get(person.id) ?? []).map((stamp) => [Date.parse(stamp), stamp] as const).sort((a, b) => a[0] - b[0]) }))
return {
id: person.id,
username: person.username,
className: person.className,
avatar: avatarOf(person.avatar),
mood: person.mood?.trim() || null,
times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp),
}
})
} }
/** /**
@@ -127,7 +129,7 @@ export function standings(entrants: Entrant[], cutoff: number | null, includeZer
entrant, entrant,
solved, solved,
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY, reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
reachedAt: solved ? entrant.stamps[solved - 1]! : null, reachedAt: solved ? new Date(entrant.times[solved - 1]!).toISOString() : null,
} }
}) })
.filter((row) => includeZero || row.solved > 0) .filter((row) => includeZero || row.solved > 0)
+1 -1
View File
@@ -25,7 +25,7 @@ const editing = ref<AdminAchievement | null>(null)
const offline = computed(() => list.value.filter((a) => !a.visible).length) const offline = computed(() => list.value.filter((a) => !a.visible).length)
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */ /** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛和专题 → 趣味),组内按门槛从低到高 */
const groups = computed(() => { const groups = computed(() => {
const byMetric = new Map<string, AdminAchievement[]>() const byMetric = new Map<string, AdminAchievement[]>()
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a]) for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
@@ -205,7 +205,7 @@ const links = computed(() => {
<div class="muted small">交得最多的</div> <div class="muted small">交得最多的</div>
<div class="names"> <div class="names">
<div v-for="row in mostTries" :key="row.username" class="nrow"> <div v-for="row in mostTries" :key="row.username" class="nrow">
<UserName :username="row.username" class="uname" /> <UserName :username="row.username" :class-name="className ? null : row.className" short-class class="uname" :class="{ 'with-class': !className }" />
<span class="num strong ntries">交了 {{ row.tries }} 次</span> <span class="num strong ntries">交了 {{ row.tries }} 次</span>
<span class="muted num">{{ gap(row.minutes) }}</span> <span class="muted num">{{ gap(row.minutes) }}</span>
<span class="grow" /> <span class="grow" />
@@ -243,7 +243,7 @@ const links = computed(() => {
</div> </div>
<div class="names"> <div class="names">
<div v-for="row in stuckStudents" :key="row.username" class="nrow"> <div v-for="row in stuckStudents" :key="row.username" class="nrow">
<UserName :username="row.username" class="uname" /> <UserName :username="row.username" :class-name="className ? null : row.className" short-class class="uname" :class="{ 'with-class': !className }" />
<span class="muted num ntries">交了 {{ row.tries }} 次</span> <span class="muted num ntries">交了 {{ row.tries }} 次</span>
<span class="legend" :style="{ color: resultColor(row.lastResult) }"> <span class="legend" :style="{ color: resultColor(row.lastResult) }">
<i :style="{ background: resultColor(row.lastResult) }" />{{ resultName(row.lastResult) }} <i :style="{ background: resultColor(row.lastResult) }" />{{ resultName(row.lastResult) }}
@@ -450,10 +450,14 @@ const links = computed(() => {
font-size: 13px; font-size: 13px;
min-width: 0; min-width: 0;
} }
/* 名字最长 4 个字;没选班(全部班级)时名字后面还有班号(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽) */
.uname { .uname {
width: 110px; width: 80px;
flex-shrink: 0; flex-shrink: 0;
} }
.uname.with-class {
width: 108px;
}
.ntries { .ntries {
width: 64px; width: 64px;
flex-shrink: 0; flex-shrink: 0;
+11 -7
View File
@@ -103,6 +103,7 @@ export function getBaseInfo() {
return api.get<DashboardInfo>('admin/dashboard') return api.get<DashboardInfo>('admin/dashboard')
} }
/** 后台首页「抽一个人」:从这个班(班号)随机拿 10 个显示用的名字 */
export function randomUser10(classroom: string) { export function randomUser10(classroom: string) {
return api.get<string[]>('admin/random-usernames', { return api.get<string[]>('admin/random-usernames', {
params: { classroom }, params: { classroom },
@@ -245,9 +246,11 @@ export function getUserList(
} }
/** 批量建号之前查重:这些用户名哪些已经有了 */ /** 批量建号之前查重:这些用户名哪些已经有了 */
export function checkUsernames(usernames: string[]) { /** 批量建号查重:这个班里已经有的同名账号,和每个名字会建成的用户名 */
export function checkUsernames(className: string, names: string[]) {
return api.post<CheckUsernamesResponse>('admin/users/check', { return api.post<CheckUsernamesResponse>('admin/users/check', {
usernames, className,
names,
} satisfies CheckUsernamesRequest) } satisfies CheckUsernamesRequest)
} }
@@ -260,6 +263,7 @@ export function editUser(user: AdminUserForm) {
adminType: toAdminType(user.adminType), adminType: toAdminType(user.adminType),
problemPermission: toProblemPermission(user.problemPermission), problemPermission: toProblemPermission(user.problemPermission),
realName: user.realName ?? null, realName: user.realName ?? null,
className: user.className ?? null,
isDisabled: user.isDisabled, isDisabled: user.isDisabled,
password: user.password ?? '', password: user.password ?? '',
} satisfies UpdateUserRequest) } satisfies UpdateUserRequest)
@@ -600,7 +604,7 @@ export function updateACMHelperChecked(contest_id: number, rank_id: number, prob
} satisfies UpdateAcmHelperRequest) } satisfies UpdateAcmHelperRequest)
} }
// 题单管理 API // 专题管理 API
export function getProblemSetList(offset = 0, limit = 10, keyword = '') { export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
return api.get<AdminProblemSetList>('admin/problem-sets', { return api.get<AdminProblemSetList>('admin/problem-sets', {
params: { offset, limit, keyword }, params: { offset, limit, keyword },
@@ -635,7 +639,7 @@ export function toggleProblemSetVisible(id: number) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`) return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
} }
// 题单题目管理 API // 专题题目管理 API
export function getProblemSetProblems(problemSetId: number) { export function getProblemSetProblems(problemSetId: number) {
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`) return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
} }
@@ -663,7 +667,7 @@ export function removeProblemFromSet(problemSetId: number, itemId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`) return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
} }
// 题单奖章管理 API // 专题奖章管理 API
export function getProblemSetBadges(problemSetId: number) { export function getProblemSetBadges(problemSetId: number) {
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`) return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
} }
@@ -782,9 +786,9 @@ export function deleteAchievement(id: number) {
// ---------- 课堂任务(class_lesson) ---------- // ---------- 课堂任务(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null; mode: string }) {
return api.get<AdminLessonList>('admin/lessons', { return api.get<AdminLessonList>('admin/lessons', {
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' }, params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '', mode: filters.mode },
}) })
} }
+6 -5
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayName } from '@oj2/contract'
import type { DashboardInfo } from '@oj2/contract' import type { DashboardInfo } from '@oj2/contract'
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from 'chart.js' import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from 'chart.js'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
@@ -15,7 +16,7 @@ import { getBaseInfo, randomUser10 } from '../api'
/** /**
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、 * 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
* 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、 * 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、
* 提交、比赛、题单、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次; * 提交、比赛、专题、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次;
* 最下面是常用的四个入口和「抽一个人」。 * 最下面是常用的四个入口和「抽一个人」。
* *
* 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 —— * 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 ——
@@ -51,7 +52,7 @@ const siteTiles = computed(() => {
{ label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` }, { label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
{ label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' }, { label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' },
{ label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' }, { label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' },
{ label: '题单', value: n(site.problemSets), note: '', to: '/admin/problemset/list' }, { label: '专题', value: n(site.problemSets), note: '', to: '/admin/problemset/list' },
{ label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' }, { label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' },
] ]
}) })
@@ -118,8 +119,8 @@ function stopRolling() {
/** 名字滚两秒停下:接口一次给 10 个随机的,最后一个就是抽中的 */ /** 名字滚两秒停下:接口一次给 10 个随机的,最后一个就是抽中的 */
async function draw() { async function draw() {
if (!luckyClass.value) return if (!luckyClass.value) return
const prefix = `ks${luckyClass.value}` // 后端按班级随机拿 10 个,回的就是显示的名字(老号已经剥掉 ks 前缀)
const names = (await randomUser10(prefix)).map((name) => name.slice(prefix.length) || name) const names = await randomUser10(luckyClass.value)
if (!names.length) { if (!names.length) {
luckyGuy.value = '这个班没有人' luckyGuy.value = '这个班没有人'
return return
@@ -146,7 +147,7 @@ onUnmounted(stopRolling)
</script> </script>
<template> <template>
<PageHeader :title="`你好,${userStore.user?.username ?? '管理员'}`" /> <PageHeader :title="`你好,${userStore.user ? displayName(userStore.user.username) : '管理员'}`" />
<div v-if="info" class="tiles"> <div v-if="info" class="tiles">
<div class="tile"> <div class="tile">
@@ -22,7 +22,7 @@ const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
{ {
title: '学生', title: '学生',
key: 'username', key: 'username',
width: 140, width: 100,
render: (row) => h(UserName, { username: row.username }), render: (row) => h(UserName, { username: row.username }),
}, },
{ {
+1 -1
View File
@@ -49,7 +49,7 @@ const desktopColumns: DataTableColumn<ProblemReader>[] = [
{ {
title: '学生', title: '学生',
key: 'username', key: 'username',
width: 150, width: 100,
render: (row) => h(UserName, { username: row.username }), render: (row) => h(UserName, { username: row.username }),
}, },
{ title: '结果', key: 'result', width: 150, render: renderResult }, { title: '结果', key: 'result', width: 150, render: renderResult },
+3 -2
View File
@@ -503,8 +503,9 @@ onMounted(load)
padding-bottom: 6px; padding-bottom: 6px;
border-top: 0; border-top: 0;
} }
/* 名字最长 4 个字,表头「每课打开过」5 个小字也放得下(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽) */
.c-name { .c-name {
width: 110px; width: 80px;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -756,7 +757,7 @@ onMounted(load)
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 1; z-index: 1;
width: 96px; width: 72px;
margin-left: -16px; margin-left: -16px;
padding-left: 16px; padding-left: 16px;
background: v-bind('theme.bodyColor'); background: v-bind('theme.bodyColor');
+12 -12
View File
@@ -24,11 +24,11 @@ import {
import BadgeModal from './components/BadgeModal.vue' import BadgeModal from './components/BadgeModal.vue'
/** /**
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开; * 新建 / 编辑专题,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。 * 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
* *
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。 * 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
* 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。 * 新建时题目先攒在页面上,保存时建好专题再一次加进去;编辑时加、删、排、切选做当场生效。
*/ */
const props = defineProps<{ problemSetId?: string }>() const props = defineProps<{ problemSetId?: string }>()
@@ -196,7 +196,7 @@ function remove(item: Item) {
return return
} }
dialog.warning({ dialog.warning({
title: '从题单里拿掉这道题?', title: '从专题里拿掉这道题?',
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`, content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
positiveText: '拿掉', positiveText: '拿掉',
negativeText: '不拿', negativeText: '不拿',
@@ -312,7 +312,7 @@ async function load() {
async function submit() { async function submit() {
if (!form.title.trim()) { if (!form.title.trim()) {
message.error('题单还没起名字') message.error('专题还没起名字')
return return
} }
if (untilMs.value !== null && untilMs.value <= Date.now()) { if (untilMs.value !== null && untilMs.value <= Date.now()) {
@@ -346,7 +346,7 @@ async function submit() {
} }
} }
} }
message.success(items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : '题单建好了') message.success(items.value.length ? `专题建好了,${items.value.length} 道题都加进去了` : '专题建好了')
router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } }) router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } })
} catch (err) { } catch (err) {
message.error(errorMessage(err)) message.error(errorMessage(err))
@@ -358,7 +358,7 @@ async function submit() {
function removeSet() { function removeSet() {
const set = current.value! const set = current.value!
dialog.error({ dialog.error({
title: `删除题单「${set.title}」?`, title: `删除专题「${set.title}」?`,
content: set.participantCount content: set.participantCount
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。` ? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
: '还没有人加入。删掉找不回来。', : '还没有人加入。删掉找不回来。',
@@ -380,9 +380,9 @@ onMounted(load)
</script> </script>
<template> <template>
<PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }"> <PageHeader :title="editing ? '编辑专题' : '新建专题'" :back="{ name: 'admin problemset list' }">
<template v-if="editing" #actions> <template v-if="editing" #actions>
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个题单 </n-button> <n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个专题 </n-button>
</template> </template>
</PageHeader> </PageHeader>
@@ -410,7 +410,7 @@ onMounted(load)
</n-flex> </n-flex>
<span class="hint">{{ assignHint }}</span> <span class="hint">{{ assignHint }}</span>
<span class="hint"> <span class="hint">
布置期内,题单里这几道题学生以前交的代码先藏起来(不管加没加入),在题单里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。 布置期内,专题里这几道题学生以前交的代码先藏起来(不管加没加入),在专题里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
</span> </span>
</div> </div>
</div> </div>
@@ -418,7 +418,7 @@ onMounted(load)
<div class="field"> <div class="field">
<span class="label">公开</span> <span class="label">公开</span>
<n-switch v-model:value="form.visible" /> <n-switch v-model:value="form.visible" />
<span class="hint">{{ form.visible ? '学生能在题单列表里看到' : '藏起来,学生看不到也进不去' }}</span> <span class="hint">{{ form.visible ? '学生能在专题列表里看到' : '藏起来,学生看不到也进不去' }}</span>
</div> </div>
<div v-if="current" class="stats"> <div v-if="current" class="stats">
@@ -434,7 +434,7 @@ onMounted(load)
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span ><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
> >
<div class="spacer"></div> <div class="spacer"></div>
<n-button v-if="editing" text type="error" @click="removeSet">删除题单</n-button> <n-button v-if="editing" text type="error" @click="removeSet">删除专题</n-button>
</div> </div>
</section> </section>
@@ -503,7 +503,7 @@ onMounted(load)
<div class="spacer"></div> <div class="spacer"></div>
<n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button> <n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button>
</div> </div>
<div v-if="!editing" class="empty hint">先保存题单,再加奖章</div> <div v-if="!editing" class="empty hint">先保存专题,再加奖章</div>
<div v-else-if="badges.length" class="list"> <div v-else-if="badges.length" class="list">
<div v-for="badge in badges" :key="badge.id" class="item badge"> <div v-for="badge in badges" :key="badge.id" class="item badge">
<img :src="badge.icon" alt="" class="badge-icon" /> <img :src="badge.icon" alt="" class="badge-icon" />
+7 -7
View File
@@ -10,7 +10,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { useListLoader } from 'shared/composables/listLoader' import { useListLoader } from 'shared/composables/listLoader'
/** /**
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」), * 后台专题列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑 * 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
*/ */
const router = useRouter() const router = useRouter()
@@ -42,7 +42,7 @@ function renderVisible(row: AdminProblemSet) {
}) })
} }
/** 手机上一个题单一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */ /** 手机上一个专题一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */
function renderMobileCard(row: AdminProblemSet) { function renderMobileCard(row: AdminProblemSet) {
const items = [ const items = [
`${row.problemsCount} 道题`, `${row.problemsCount} 道题`,
@@ -62,14 +62,14 @@ function renderMobileCard(row: AdminProblemSet) {
} }
const mobileColumns: DataTableColumn<AdminProblemSet>[] = [ const mobileColumns: DataTableColumn<AdminProblemSet>[] = [
{ title: '题单', key: 'title', render: renderMobileCard }, { title: '专题', key: 'title', render: renderMobileCard },
{ title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible }, { title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible },
] ]
const desktopColumns: DataTableColumn<AdminProblemSet>[] = [ const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
{ title: 'ID', key: 'id', width: 70 }, { title: 'ID', key: 'id', width: 70 },
{ {
title: '题单', title: '专题',
key: 'title', key: 'title',
minWidth: 200, minWidth: 200,
render: renderTitle, render: renderTitle,
@@ -131,12 +131,12 @@ watch(() => [query.page, query.limit], listProblemSets)
</script> </script>
<template> <template>
<PageHeader title="题单列表"> <PageHeader title="专题列表">
<template #actions> <template #actions>
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建题单 </n-button> <n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建专题 </n-button>
</template> </template>
<template #filters> <template #filters>
<n-input v-model:value="query.keyword" placeholder="搜题单名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" /> <n-input v-model:value="query.keyword" placeholder="搜专题名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" />
</template> </template>
</PageHeader> </PageHeader>
<n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" /> <n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" />
+30 -11
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList } from '@oj2/contract' import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList, type LessonMode } from '@oj2/contract'
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import PageHeader from 'admin/components/PageHeader.vue' import PageHeader from 'admin/components/PageHeader.vue'
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui' import { NButton, NPopconfirm, NTag, NTooltip, useThemeVars } from 'naive-ui'
import { dayStartOfDate } from 'oj/statistics/period' import { dayStartOfDate } from 'oj/statistics/period'
import Pagination from 'shared/components/Pagination.vue' import Pagination from 'shared/components/Pagination.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
@@ -27,13 +27,16 @@ const configStore = useConfigStore()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 从任务页「布置过的课」进来带着 ?className= // 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({ const { query } = usePagination<{ className: string; teacher: string; mode: string }>({
className: useRouteQuery('className', '').value, className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value, teacher: useRouteQuery('teacher', '').value,
mode: useRouteQuery('mode', '').value,
}) })
const data = ref<AdminLessonList | null>(null) const data = ref<AdminLessonList | null>(null)
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])]) const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
const MODES: LessonMode[] = ['code', 'both', 'flowchart']
const modeOptions = [{ label: '全部做法', value: '' }, ...MODES.map((value) => ({ label: LESSON_MODE_LABEL[value], value }))]
const teacherOptions = computed(() => [ const teacherOptions = computed(() => [
{ label: '全部老师', value: '' }, { label: '全部老师', value: '' },
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })), ...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
@@ -46,13 +49,14 @@ async function list() {
getAdminLessons((query.page - 1) * query.limit, query.limit, { getAdminLessons((query.page - 1) * query.limit, query.limit, {
className: query.className, className: query.className,
teacher: query.teacher ? Number(query.teacher) : null, teacher: query.teacher ? Number(query.teacher) : null,
mode: query.mode,
}), }),
) )
if (res) data.value = res if (res) data.value = res
} }
onMounted(list) onMounted(list)
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次 // 换了班级、老师、做法 usePagination 会把页码拨回 1,同一拍里只拉一次
watch(() => [query.page, query.limit, query.className, query.teacher], list) watch(() => [query.page, query.limit, query.className, query.teacher, query.mode], list)
// ---- 改 / 补 / 删 ---- // ---- 改 / 补 / 删 ----
const editing = ref(false) const editing = ref(false)
@@ -133,17 +137,24 @@ function renderActions(lesson: AdminLesson) {
]) ])
} }
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */ /** 三种做法各一个颜色,一眼分得开;写代码也写出来,不当默认值省掉 */
function wayText(lesson: AdminLesson) { const MODE_TAG_TYPE: Record<LessonMode, 'default' | 'info' | 'warning'> = { code: 'default', both: 'info', flowchart: 'warning' }
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
/** 「写代码 C语言」「先画再写 Python」「画流程图」:画流程图不用语言 */
function renderWay(lesson: AdminLesson) {
return h('div', { class: 'way' }, [
h(NTag, { size: 'small', bordered: false, type: MODE_TAG_TYPE[lesson.mode] }, { default: () => LESSON_MODE_LABEL[lesson.mode] }),
lesson.language ? h('span', { class: 'way-lang' }, LANGUAGE_SHOW_VALUE[lesson.language]) : null,
])
} }
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */ /** 手机上一节课一张卡:日子 · 时刻 · 班级一行,做什么一行,题号一行,老师 · 做完一行小字,操作一行 */
function renderMobileCard(lesson: AdminLesson) { function renderMobileCard(lesson: AdminLesson) {
return h('div', { class: 'm-card' }, [ return h('div', { class: 'm-card' }, [
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`), h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
renderWay(lesson),
renderProblems(lesson), renderProblems(lesson),
h('div', { class: 'm-meta' }, [lesson.createdBy, wayText(lesson), `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · ')), h('div', { class: 'm-meta' }, `${lesson.createdBy} · 做完 ${renderDone(lesson)}`),
renderActions(lesson), renderActions(lesson),
]) ])
} }
@@ -153,7 +164,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') }, { title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) }, { title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
{ title: '老师', key: 'createdBy', width: 90 }, { title: '老师', key: 'createdBy', width: 90 },
{ title: '做什么', key: 'language', width: 120, render: (row) => wayText(row) || '—' }, { title: '做什么', key: 'mode', width: 150, render: renderWay },
{ title: '题目', key: 'problems', width: 300, render: renderProblems }, { title: '题目', key: 'problems', width: 300, render: renderProblems },
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone }, { title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
{ title: '', key: 'actions', render: renderActions }, { title: '', key: 'actions', render: renderActions },
@@ -173,6 +184,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
<template #filters> <template #filters>
<n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" /> <n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" />
<n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" /> <n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" />
<n-select v-model:value="query.mode" :options="modeOptions" aria-label="做什么" :style="{ width: isMobile ? '100%' : '120px' }" />
</template> </template>
</PageHeader> </PageHeader>
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" /> <n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
@@ -205,6 +217,13 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
text-decoration: line-through; text-decoration: line-through;
} }
.lesson-table :deep(.way) {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.lesson-table :deep(.actions) { .lesson-table :deep(.actions) {
display: flex; display: flex;
gap: 8px; gap: 8px;
+40 -24
View File
@@ -34,7 +34,8 @@ const classError = computed(() => {
} }
return '' return ''
}) })
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : '')) /** 填好了、格式也对的班号;没填或填错是空串 */
const klass = computed(() => (classNo.value.trim() && !classError.value ? classNo.value.trim() : ''))
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */ /** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
const names = computed(() => const names = computed(() =>
@@ -56,11 +57,16 @@ function passwordOf(name: string) {
return value return value
} }
/**
* 用户名就是名字(2026-11 起不再加 ks + 班号)。名字被别的班、注册的号占了,后端给的是加了数字的(杨涵宇2);
* 查重还没回来之前先按名字摆着
*/
const suggested = ref<Map<string, string>>(new Map())
const rows = computed(() => const rows = computed(() =>
names.value.map((name) => ({ names.value.map((name) => ({
name, name,
username: prefix.value + name, username: suggested.value.get(name) ?? name,
password: passwordOf(prefix.value + name), password: passwordOf(name),
})), })),
) )
@@ -69,7 +75,7 @@ const duplicates = computed(() => {
const first = new Map<string, number>() const first = new Map<string, number>()
const result: { line: number; firstLine: number; name: string }[] = [] const result: { line: number; firstLine: number; name: string }[] = []
rows.value.forEach((row, i) => { rows.value.forEach((row, i) => {
const key = row.username.toLowerCase() const key = row.name.toLowerCase()
const seen = first.get(key) const seen = first.get(key)
if (seen === undefined) first.set(key, i + 1) if (seen === undefined) first.set(key, i + 1)
else result.push({ line: i + 1, firstLine: seen, name: row.name }) else result.push({ line: i + 1, firstLine: seen, name: row.name })
@@ -77,24 +83,39 @@ const duplicates = computed(() => {
return result return result
}) })
// 已经有的账号:停手半秒再去查 // 这个班已经有的同名账号(老号按剥掉 ks 前缀的名字比),顺带拿回每个名字会建成的用户名:停手半秒再去查
const existing = ref<{ username: string; createTime: string | null }[]>([]) const existing = ref<{ name: string; username: string; createTime: string | null }[]>([])
const checking = ref(false) const checking = ref(false)
/** 查重结果对应的是哪一份「班级 + 名单」:和眼前的对不上(刚改过、还在防抖)就不让建 */
const inputKey = computed(() => `${klass.value}\n${names.value.join('\n')}`)
const checkedKey = ref('')
// 改得快时前一次请求可能后回来,只认最后发出去的那次
let checkSeq = 0
watchDebounced( watchDebounced(
() => rows.value.map((row) => row.username).join('\n'), inputKey,
async () => { async (key) => {
const usernames = [...new Set(rows.value.map((row) => row.username))] const seq = ++checkSeq
if (!usernames.length || classError.value || usernames.length > 500) { const list = [...new Set(names.value)]
if (!list.length || !klass.value || list.length > 500) {
existing.value = [] existing.value = []
suggested.value = new Map()
checkedKey.value = ''
return return
} }
checking.value = true checking.value = true
try { try {
existing.value = (await checkUsernames(usernames)).existing const res = await checkUsernames(klass.value, list)
if (seq !== checkSeq) return
existing.value = res.existing
suggested.value = new Map(list.map((name, i) => [name, res.usernames[i] ?? name]))
checkedKey.value = key
} catch { } catch {
if (seq !== checkSeq) return
existing.value = [] existing.value = []
suggested.value = new Map()
checkedKey.value = ''
} finally { } finally {
checking.value = false if (seq === checkSeq) checking.value = false
} }
}, },
{ debounce: 500, immediate: true }, { debounce: 500, immediate: true },
@@ -102,7 +123,7 @@ watchDebounced(
const tooMany = computed(() => rows.value.length > 500) const tooMany = computed(() => rows.value.length > 500)
const issueCount = computed(() => (classError.value ? 1 : 0) + duplicates.value.length + existing.value.length + (tooMany.value ? 1 : 0)) const issueCount = computed(() => (classError.value ? 1 : 0) + duplicates.value.length + existing.value.length + (tooMany.value ? 1 : 0))
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value) const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!klass.value && checkedKey.value === inputKey.value)
// ---------------------------------------------------------------- 建号 // ---------------------------------------------------------------- 建号
@@ -118,8 +139,9 @@ async function submit() {
const payload: ImportUsersRequest['users'] = rows.value.map((row, i) => [ const payload: ImportUsersRequest['users'] = rows.value.map((row, i) => [
row.username, row.username,
row.password, row.password,
`${prefix.value || 'user'}.${batch}.${i + 1}@example.com`, `c${klass.value}.${batch}.${i + 1}@example.com`,
row.name, row.name,
klass.value,
]) ])
saving.value = true saving.value = true
try { try {
@@ -142,7 +164,7 @@ function again() {
function download() { function download() {
if (!done.value) return if (!done.value) return
downloadAccountsCsv(done.value, prefix.value || '账号') downloadAccountsCsv(done.value, klass.value ? classLabel(klass.value) : '账号')
} }
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */ /** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
@@ -196,7 +218,6 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
<div class="left"> <div class="left">
<label class="label" for="class-no">班级号</label> <label class="label" for="class-no">班级号</label>
<div class="class-row"> <div class="class-row">
<span class="ks">ks</span>
<n-input v-model:value="classNo" :input-props="{ id: 'class-no', inputmode: 'numeric' }" placeholder="如 2612" style="width: 120px" /> <n-input v-model:value="classNo" :input-props="{ id: 'class-no', inputmode: 'numeric' }" placeholder="如 2612" style="width: 120px" />
<span v-if="classNo.trim() && !classError" class="class-name"> <span v-if="classNo.trim() && !classError" class="class-name">
{{ classLabel(classNo.trim()) }} {{ classLabel(classNo.trim()) }}
@@ -210,7 +231,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
<template v-if="rows.length"> <template v-if="rows.length">
<div class="head"> <div class="head">
<b>会建这 {{ rows.length }} 个账号</b> <b>会建这 {{ rows.length }} 个账号</b>
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round> 没有重名 </n-tag> <n-tag v-if="!issueCount && !checking && klass && checkedKey === inputKey" type="success" :bordered="false" round> 没有重名 </n-tag>
<n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag> <n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag>
</div> </div>
@@ -221,7 +242,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 —— 同班同名的,名字后面加个 1、2 区分 第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 —— 同班同名的,名字后面加个 1、2 区分
</div> </div>
<div v-for="it in existing" :key="it.username"> <div v-for="it in existing" :key="it.username">
{{ it.username }} 已经有这个账号了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template> {{ classLabel(klass) }}已经有「{{ it.name }}」了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template>
—— 删掉这一行,或者换个名字 —— 删掉这一行,或者换个名字
</div> </div>
</div> </div>
@@ -249,7 +270,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
<n-button type="primary" size="large" :disabled="!canSubmit" :loading="saving || checking" @click="submit"> <n-button type="primary" size="large" :disabled="!canSubmit" :loading="saving || checking" @click="submit">
建好这 {{ rows.length }} 个账号 建好这 {{ rows.length }} 个账号
</n-button> </n-button>
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text> <n-text v-if="!klass" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text>
</div> </div>
</template> </template>
<n-empty v-else :description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`" :style="{ marginTop: isMobile ? '24px' : '80px' }" /> <n-empty v-else :description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`" :style="{ marginTop: isMobile ? '24px' : '80px' }" />
@@ -288,11 +309,6 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
gap: 8px; gap: 8px;
} }
.ks {
font-size: 18px;
opacity: 0.6;
}
.class-name { .class-name {
font-size: 15px; font-size: 15px;
} }
+20 -6
View File
@@ -13,7 +13,7 @@ import UserNameCell from './components/UserNameCell.vue'
import PasswordCell from './components/PasswordCell.vue' import PasswordCell from './components/PasswordCell.vue'
import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants' import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants'
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import { NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract' import { legacyClassOf, NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract'
import { downloadAccountsCsv } from './accountsCsv' import { downloadAccountsCsv } from './accountsCsv'
import { classLabel, groupClassOptions } from 'utils/classes' import { classLabel, groupClassOptions } from 'utils/classes'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
@@ -50,6 +50,10 @@ const classOptions = computed(() => [
...groupClassOptions(configStore.config?.classList ?? []), ...groupClassOptions(configStore.config?.classList ?? []),
{ label: classLabel(NO_CLASS), value: NO_CLASS }, { label: classLabel(NO_CLASS), value: NO_CLASS },
]) ])
/** 编辑框的班级:配置里的班,外加这个人现在的班(老班不在配置里也要显示得出来) */
const editClassOptions = computed(() =>
groupClassOptions([...(configStore.config?.classList ?? []), ...(userEditing.value?.className ? [userEditing.value.className] : [])]),
)
const neverLoggedIn = ref(0) const neverLoggedIn = ref(0)
const total = ref(0) const total = ref(0)
@@ -311,10 +315,21 @@ async function handleEditUser() {
} }
// http 拦截器只对 login-required / permission-denied 自动弹提示, // http 拦截器只对 login-required / permission-denied 自动弹提示,
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示 // 其余业务错误(比如班级号位数不对)不接住就什么都不显示
if (create.value && !userEditing.value.className && legacyClassOf(userEditing.value.username) === undefined) {
message.error('用户名里 ks 后面的班级号位数不对,班级号要是 3~4 位数字')
return
}
try { try {
if (create.value) { if (create.value) {
const newUser: ImportUsersRequest['users'] = [ const newUser: ImportUsersRequest['users'] = [
[userEditing.value.username, password.value, userEditing.value.email ?? '', userEditing.value.realName ?? ''], [
userEditing.value.username,
password.value,
userEditing.value.email ?? '',
userEditing.value.realName ?? '',
// 没选班、但手写的是老格式 ks232张三:按前缀补上班级
userEditing.value.className ?? legacyClassOf(userEditing.value.username) ?? null,
],
] ]
await importUsers(newUser) await importUsers(newUser)
listUsers() listUsers()
@@ -388,10 +403,9 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
<n-form-item-gi :span="1" label="真名"> <n-form-item-gi :span="1" label="真名">
<n-input v-model:value="userEditing.realName" /> <n-input v-model:value="userEditing.realName" />
</n-form-item-gi> </n-form-item-gi>
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf), <!-- 班级单独存一列,2026-11 起不再从用户名的 ks 前缀推(新号不带前缀)。老师、管理员不填 -->
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 --> <n-form-item-gi :span="1" label="班级">
<n-form-item-gi v-if="!create" :span="1" label="班级"> <n-select v-model:value="userEditing.className" :options="editClassOptions" filterable clearable placeholder="没填班级" />
<n-input :value="userEditing.className" disabled placeholder="由用户名自动推导" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="1" label="邮箱"> <n-form-item-gi :span="1" label="邮箱">
<n-input v-model:value="userEditing.email" /> <n-input v-model:value="userEditing.email" />
+1 -1
View File
@@ -9,7 +9,7 @@ body {
机房屏 1366×768 的可用高度只有 640 左右,行高 48 是和用户商量过的:题目列表在 机房屏 1366×768 的可用高度只有 640 左右,行高 48 是和用户商量过的:题目列表在
这个高度还能看到 8 行。题目详情页(编辑器)不吃这张表,它按 640 高逐像素排过。 */ 这个高度还能看到 8 行。题目详情页(编辑器)不吃这张表,它按 640 高逐像素排过。 */
:root { :root {
--oj-page-width: 1200px; /* 阅读型页面(比赛、题单、公告……)的限宽,居中 */ --oj-page-width: 1200px; /* 阅读型页面(比赛、专题、公告……)的限宽,居中 */
--oj-fs-title: 24px; /* 页标题 */ --oj-fs-title: 24px; /* 页标题 */
--oj-fs-h2: 17px; /* 区块标题 */ --oj-fs-h2: 17px; /* 区块标题 */
--oj-fs-body: 15px; /* 列表主文字:题目名、比赛名 */ --oj-fs-body: 15px; /* 列表主文字:题目名、比赛名 */
@@ -6,11 +6,11 @@ import type { Group } from '../series'
import Medal from './Medal.vue' import Medal from './Medal.vue'
import SeriesRow from './SeriesRow.vue' import SeriesRow from './SeriesRow.vue'
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「题单和比赛」组末尾是拿到的题单奖章 */ /** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「专题和比赛」组末尾是拿到的专题奖章 */
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>() const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
const theme = useThemeVars() const theme = useThemeVars()
const showBadges = computed(() => props.group.name === '题单和比赛' && !!props.badges?.length) const showBadges = computed(() => props.group.name === '专题和比赛' && !!props.badges?.length)
</script> </script>
<template> <template>
@@ -27,7 +27,7 @@ const showBadges = computed(() => props.group.name === '题单和比赛' && !!pr
</div> </div>
</div> </div>
<div v-if="showBadges" class="badges"> <div v-if="showBadges" class="badges">
<span class="masked-title">拿到的题单奖章</span> <span class="masked-title">拿到的专题奖章</span>
<div class="badge-list"> <div class="badge-list">
<n-tooltip v-for="b in badges" :key="b.id" :delay="150"> <n-tooltip v-for="b in badges" :key="b.id" :delay="150">
<template #trigger> <template #trigger>
@@ -36,7 +36,7 @@ const showBadges = computed(() => props.group.name === '题单和比赛' && !!pr
<span>{{ b.badge.name }}</span> <span>{{ b.badge.name }}</span>
</router-link> </router-link>
</template> </template>
{{ b.badge.description }} · 来自题单「{{ b.problemSet.title }}」 {{ b.badge.description }} · 来自专题「{{ b.problemSet.title }}」
</n-tooltip> </n-tooltip>
</div> </div>
</div> </div>
+12 -2
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants' import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types' import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
import ClassCard from './components/ClassCard.vue' import ClassCard from './components/ClassCard.vue'
@@ -37,9 +38,9 @@ const got = computed(() => data.value?.achievements.filter((item) => item.unlock
const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze'] const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze']
const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length
// 桌面两栏:左边做题、其他、彩蛋,右边坚持、题单和比赛 —— 两边差不多高 // 桌面两栏:左边做题、其他、彩蛋,右边坚持、专题和比赛 —— 两边差不多高
const LEFT: GroupName[] = ['做题', '其他', '彩蛋'] const LEFT: GroupName[] = ['做题', '其他', '彩蛋']
const RIGHT: GroupName[] = ['坚持', '题单和比赛'] const RIGHT: GroupName[] = ['坚持', '专题和比赛']
const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name)) const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name))
async function load() { async function load() {
@@ -59,6 +60,15 @@ async function load() {
onMounted(load) onMounted(load)
watch(name, load) watch(name, load)
// 看板娘:只在看自己的成就时说话。「快拿到了」里最近的一个能说成「再做对 3 道」的,报它的名字;
// 「还差 3 次」「一天做对 5 道」这种前面加不了「再」,往后找
useMascotGuide(() => {
if (!data.value || other.value || userStore.isTeacherOrAbove) return null
const item = near.value.find((it) => it.remain.startsWith('再'))
if (!item) return { situation: 'achievement' }
return { situation: 'achievement.near', vars: { name: item.achievement.name, need: item.remain.slice(1) } }
})
</script> </script>
<template> <template>
+14 -8
View File
@@ -7,7 +7,7 @@ import type { Achievement } from 'utils/types'
* 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看, * 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看,
* 在后台加了新指标记得回来补一行。 * 在后台加了新指标记得回来补一行。
*/ */
export const GROUPS = ['做题', '坚持', '题单和比赛', '其他', '彩蛋'] as const export const GROUPS = ['做题', '坚持', '专题和比赛', '其他', '彩蛋'] as const
export type GroupName = (typeof GROUPS)[number] export type GroupName = (typeof GROUPS)[number]
interface MetricInfo { interface MetricInfo {
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
unit: '道', unit: '道',
remain: (r) => `再做对 ${r} 道困难题`, remain: (r) => `再做对 ${r} 道困难题`,
}, },
challenge_count: {
group: '做题',
label: '挑战成功',
unit: '次',
remain: (r) => `再挑战成功 ${r} 次`,
},
max_ac_week_streak: { max_ac_week_streak: {
group: '坚持', group: '坚持',
label: '连着几周有做对', label: '连着几周有做对',
@@ -55,19 +61,19 @@ const METRICS: Record<string, MetricInfo> = {
}, },
submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` }, submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` },
problemset_completed: { problemset_completed: {
group: '题单和比赛', group: '专题和比赛',
label: '做完的题单', label: '做完的专题',
unit: '个', unit: '个',
remain: (r) => `再做完 ${r} 个题单`, remain: (r) => `再做完 ${r} 个专题`,
}, },
badge_count: { badge_count: {
group: '题单和比赛', group: '专题和比赛',
label: '题单奖章', label: '专题奖章',
unit: '枚', unit: '枚',
remain: (r) => `再拿 ${r} 枚题单奖章`, remain: (r) => `再拿 ${r} 枚专题奖章`,
}, },
contest_joined: { contest_joined: {
group: '题单和比赛', group: '专题和比赛',
label: '参加的比赛', label: '参加的比赛',
unit: '场', unit: '场',
remain: (r) => `再参加 ${r} 场比赛`, remain: (r) => `再参加 ${r} 场比赛`,
+4
View File
@@ -5,6 +5,7 @@ import AdminLink from 'shared/components/AdminLink.vue'
import NotFound from 'shared/components/NotFound.vue' import NotFound from 'shared/components/NotFound.vue'
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 { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api' import { errorCode } from 'utils/api'
import { parseTime, zonedParts, zonedYear } from 'utils/functions' import { parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { Announcement, AnnouncementListItem } from 'utils/types' import type { Announcement, AnnouncementListItem } from 'utils/types'
@@ -27,6 +28,9 @@ const { isDesktop } = useBreakpoints()
const announcements = ref<AnnouncementListItem[]>([]) const announcements = ref<AnnouncementListItem[]>([])
const loaded = ref(false) const loaded = ref(false)
// 看板娘:只念叨一句,不给按钮
useMascotGuide(() => ({ situation: 'announcement' }))
onMounted(async () => { onMounted(async () => {
const res = await getAnnouncementList(0, LIMIT) const res = await getAnnouncementList(0, LIMIT)
announcements.value = res.results announcements.value = res.results
+10 -7
View File
@@ -41,6 +41,7 @@ import {
type ProblemRank, type ProblemRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemListItem, type ProblemListItem,
type SimilarProblem,
type ProblemStats, type ProblemStats,
type ProblemProgress, type ProblemProgress,
type CreateFlowchartResponse, type CreateFlowchartResponse,
@@ -179,7 +180,7 @@ export function getSubmissionStatistics(
duration: { start?: string; end: string }, duration: { start?: string; end: string },
problemDisplayId?: string, problemDisplayId?: string,
username?: string, username?: string,
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */ /** 班级(user.class_name)精确匹配。别拿用户名前缀当班级 —— 新号不带 ks 前缀,老号 `ks231` 还会连带 2311、2312 班 */
className?: string, className?: string,
) { ) {
return api.get<SubmissionStatistics>('submissions/statistics', { return api.get<SubmissionStatistics>('submissions/statistics', {
@@ -346,7 +347,7 @@ export function uploadAvatar(file: File) {
return api.post('me/avatar', form) return api.post('me/avatar', form)
} }
export function updateProfile(data: { realName?: string; mood?: string }) { export function updateProfile(data: { mood?: string }) {
return api.put<Profile>('me/profile', data satisfies UpdateProfileRequest) return api.put<Profile>('me/profile', data satisfies UpdateProfileRequest)
} }
@@ -412,7 +413,9 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
return api.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`).then((response) => response.map(toProblemRow)) return api
.get<SimilarProblem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map((item) => ({ ...toProblemRow(item), sharedTags: item.sharedTags })))
} }
// ==================== 挑战 ==================== // ==================== 挑战 ====================
@@ -463,12 +466,12 @@ export function retryFlowchartSubmission(submissionId: string) {
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`) return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
} }
/** 自己在这道题上评完的每一次(早的在前),题单布置期内藏着的已经滤掉 */ /** 自己在这道题上评完的每一次(早的在前),专题布置期内藏着的已经滤掉 */
export function getFlowchartScores(problemId: number) { export function getFlowchartScores(problemId: number) {
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`) return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
} }
// ==================== 题单相关API ==================== // ==================== 专题相关API ====================
export function getProblemSetList(offset = 0, limit = 10, keyword = '') { export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } }) return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } })
@@ -490,14 +493,14 @@ export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`) return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`)
} }
/** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */ /** 老师看某个班在专题里的情况;不传班级就看服务端推断的那个班 */
export function getProblemSetClassView(problemSetId: number, className = '') { export function getProblemSetClassView(problemSetId: number, className = '') {
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, { return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
params: className ? { className } : {}, params: className ? { className } : {},
}) })
} }
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */ /** 这道题以前的代码为什么藏着:落在哪些正在布置的专题里。传主键或题号都行 */
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) { export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
return api.get<ProblemSetLock[]>('problem-sets/locks', { return api.get<ProblemSetLock[]>('problem-sets/locks', {
params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId }, params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
+9
View File
@@ -12,6 +12,7 @@ import { usePagination } from 'shared/composables/pagination'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
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 { goAction, useMascotGuide } from 'shared/mascot/guide'
import { ContestStatus } from 'utils/constants' import { ContestStatus } from 'utils/constants'
import ContestBadge from './components/ContestBadge.vue' import ContestBadge from './components/ContestBadge.vue'
@@ -115,6 +116,14 @@ function open(contest: Contest) {
router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`) router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`)
} }
// 看板娘:有正在比的就带进去
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const live = active.value.find((c) => c.status === ContestStatus.underway)
if (!live) return { situation: 'contests.none' }
return { situation: 'contests.live', actions: [goAction(router, '进比赛', `/contest/${live.id}`)] }
})
function mineText(contest: Contest) { function mineText(contest: Contest) {
const mine = contest.mine const mine = contest.mine
if (!mine) return '' if (!mine) return ''
+18 -8
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayName } from '@oj2/contract'
import type { ContestClassView, ContestScoreCell, ContestScoreRow } from '@oj2/contract' import type { ContestClassView, ContestScoreCell, ContestScoreRow } from '@oj2/contract'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { updateACMHelperChecked } from 'admin/api' import { updateACMHelperChecked } from 'admin/api'
@@ -225,12 +226,13 @@ onKeyStroke(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], (event) => {
function exportGrades() { function exportGrades() {
const title = contestStore.contest?.title ?? '比赛' const title = contestStore.contest?.title ?? '比赛'
const head = ['用户名', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)] const head = ['名字', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)]
const lines = [head] const lines = [head]
const byUsername = [...rows.value].sort((a, b) => a.username.localeCompare(b.username, 'zh-CN')) // 按名字的拼音排:老号 ks 开头、新号是中文名,按用户名排两种号会各排各的
for (const row of byUsername) { const byName = [...rows.value].sort((a, b) => displayName(a.username).localeCompare(displayName(b.username), 'zh-CN'))
for (const row of byName) {
lines.push([ lines.push([
row.username, displayName(row.username),
row.className ? classLabel(row.className) : '', row.className ? classLabel(row.className) : '',
String(row.solved), String(row.solved),
String(row.rank ?? ''), String(row.rank ?? ''),
@@ -242,7 +244,14 @@ function exportGrades() {
]) ])
} }
for (const user of absent.value) { for (const user of absent.value) {
lines.push([user.username, user.className ? classLabel(user.className) : '', user.leave ? '请假' : '没考', '', '', ...problems.value.map(() => '')]) lines.push([
displayName(user.username),
user.className ? classLabel(user.className) : '',
user.leave ? '请假' : '没考',
'',
'',
...problems.value.map(() => ''),
])
} }
const csv = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(',')).join('\r\n') const csv = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(',')).join('\r\n')
// 带 BOM,Excel 双击打开中文才不乱码 // 带 BOM,Excel 双击打开中文才不乱码
@@ -258,8 +267,9 @@ function exportGrades() {
const success = computed(() => tone('success')) const success = computed(() => tone('success'))
const danger = computed(() => tone('error')) const danger = computed(() => tone('error'))
const warning = computed(() => tone('warning')) const warning = computed(() => tone('warning'))
const liveColumns = computed(() => `130px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`) // 名字列只放名字,最长 4 个字(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽)
const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.value.length}, 50px)`) const liveColumns = computed(() => `88px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`)
const gradeColumns = computed(() => `24px 88px 40px 36px repeat(${problems.value.length}, 50px)`)
</script> </script>
<template> <template>
@@ -430,7 +440,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
no: cellOf(row, p.id) && !cellOf(row, p.id)!.isAc, no: cellOf(row, p.id) && !cellOf(row, p.id)!.isAc,
sel: selected?.userId === row.userId && selected.problemId === p.id, sel: selected?.userId === row.userId && selected.problemId === p.id,
}" }"
:title="`${row.username} · 第 ${p._id} 题`" :title="`${displayName(row.username)} · 第 ${p._id} 题`"
@click="pick(row, p.id)" @click="pick(row, p.id)"
> >
<template v-if="cellOf(row, p.id)?.isAc">✓</template> <template v-if="cellOf(row, p.id)?.isAc">✓</template>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayName } from '@oj2/contract'
import type { ContestScoreRow } from '@oj2/contract' import type { ContestScoreRow } from '@oj2/contract'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useContestStore } from 'oj/store/contest' import { useContestStore } from 'oj/store/contest'
@@ -131,7 +132,7 @@ const warning = computed(() => tone('warning'))
<span class="count-num">{{ item.solved }} 人做对</span> <span class="count-num">{{ item.solved }} 人做对</span>
</span> </span>
<span class="first"> <span class="first">
<template v-if="item.first">最先:{{ item.first.username }} · {{ firstMinute(item.first.acTime) }}</template> <template v-if="item.first">最先:{{ displayName(item.first.username) }} · {{ firstMinute(item.first.acTime) }}</template>
<template v-else>还没人做对</template> <template v-else>还没人做对</template>
</span> </span>
</template> </template>
+2 -1
View File
@@ -91,7 +91,8 @@ const narrow = useMediaQuery('(max-width: 640px)')
const columns = computed(() => { const columns = computed(() => {
const move = running.value ? '36px ' : '' const move = running.value ? '36px ' : ''
const count = problems.value.length const count = problems.value.length
return narrow.value ? `28px ${move}112px 36px 70px repeat(${count}, 52px)` : `40px ${move}170px 48px 80px repeat(${count}, 64px)` // 名字列:名字最长 4 个字(2026-11 去掉 ks 前缀之前是按 ks2312李鑫楠 定的宽)
return narrow.value ? `28px ${move}76px 36px 70px repeat(${count}, 52px)` : `40px ${move}96px 48px 80px repeat(${count}, 64px)`
}) })
</script> </script>
+92 -6
View File
@@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayName } from '@oj2/contract'
import { continueStep, doneCount, tutorialPath, splitTitle, type TutorialType } from 'oj/learn/utils' import { continueStep, doneCount, tutorialPath, splitTitle, type TutorialType } from 'oj/learn/utils'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api' import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
import { getClassActivity } from 'shared/api' import { getClassActivity } from 'shared/api'
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue' import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
import { pickHeadline, type Headline } from 'oj/user/knowledge' import { levelName, MAX_LEVEL, pickHeadline, remainingToNext, tagLink, type Headline } from 'oj/user/knowledge'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { activityMode } from 'shared/store/lesson'
import { focusJumpAction, goAction, randomProblemAction, useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants' import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions' import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types' import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, KnowledgeMap, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
interface Track { interface Track {
type: TutorialType type: TutorialType
@@ -34,6 +37,8 @@ const classActivity = ref<ClassActivity | null>(null)
const lastVisit = ref<LastVisit | null>(null) const lastVisit = ref<LastVisit | null>(null)
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */ /** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
const knowledgeHeadline = ref<Headline | null>(null) const knowledgeHeadline = ref<Headline | null>(null)
/** 地图原样留着给看板娘:她要知识点名、档位名、还差几道,不是拼好的那句话 */
const knowledge = ref<KnowledgeMap | null>(null)
const loaded = ref(false) const loaded = ref(false)
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */ /** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
@@ -121,7 +126,8 @@ async function loadAnnouncements() {
} }
async function loadKnowledge() { async function loadKnowledge() {
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap()) knowledge.value = await getKnowledgeMap()
knowledgeHeadline.value = pickHeadline(knowledge.value)
} }
async function loadClassActivity() { async function loadClassActivity() {
@@ -190,7 +196,7 @@ const classActivityTitle = computed(() => {
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') { if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language const language = classActivity.value.language
const mode = classActivity.value.mode const mode = activityMode(classActivity.value)
if (mode === 'flowchart') return '这节课 · 画流程图' if (mode === 'flowchart') return '这节课 · 画流程图'
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码` if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}` return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
@@ -242,6 +248,86 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 看板娘:今天有课(和「这节课」那张卡同一个条件)就催今天的题,没课就随便做一道
let greetedBack = false
useMascotGuide(() => {
if (!loaded.value) return null
const random = randomProblemAction(router)
const learn = goAction(router, '自学', '/learn')
const lesson = classToday.value && !!classActivity.value?.problems.length
const next = nextProblem.value
if (lesson && next)
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
const base: MascotGuide = lesson
? { situation: 'home.done', actions: [random, learn] }
: { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
// 好久没来:只在这次进来后第一次点她时说,之后照常
if (awayDays.value >= 14 && !greetedBack) {
greetedBack = true
return { ...base, situation: 'home.back', n: awayDays.value }
}
// 这节课做完了或者没课:从别的数据里挑能说的,每点一次随机说一件,点几下听到的不一样
const extras = extraGuides(random)
return extras.length ? extras[Math.floor(Math.random() * extras.length)]! : base
})
const DAY_MS = 864e5
function extraGuides(random: MascotAction): MascotGuide[] {
const list: MascotGuide[] = []
// 比赛:进行中,或者 24 小时内开始(那就只可能是今天或明天)
const now = Date.now()
const contest = contests.value.find((it) => it.status === ContestStatus.underway || Date.parse(it.startTime) - now < DAY_MS)
if (contest) {
const when = contest.status === ContestStatus.underway ? '现在' : dayKey(contest.startTime) === dayKey(new Date()) ? '今天' : '明天'
list.push({ situation: 'home.contestSoon', vars: { when }, actions: [goAction(router, '去看看', `/contest/${contest.id}`), random] })
}
// 公告:三天内发的,挑最新那条
const fresh = announcements.value.filter((it) => now - Date.parse(it.createTime) < 3 * DAY_MS).sort((a, b) => b.createTime.localeCompare(a.createTime))[0]
if (fresh) {
list.push({
situation: 'home.newAnnouncement',
actions: [{ label: '去看看', run: () => router.push({ path: '/announcement', query: { id: fresh.id } }) }, random],
})
}
// 知识点:和首页那一行同一个先后(本周升级 > 离下一档最近 > 班里在学、自己还没碰过),只说一件
const map = knowledge.value
if (map) {
const touched = new Set(map.classTouched)
const go = (name: string) => ({ label: '去做', run: () => router.push(tagLink(name)) })
const upgraded = map.tags.filter((tag) => tag.level > tag.levelAtWeekStart).sort((a, b) => b.level - a.level)[0]
const learning = map.tags
.filter((tag) => tag.level >= 1 && tag.level < MAX_LEVEL)
.sort((a, b) => Number(touched.has(b.name)) - Number(touched.has(a.name)) || remainingToNext(a)! - remainingToNext(b)!)[0]
const untouched = map.tags.find((tag) => tag.level === 0 && touched.has(tag.name))
if (upgraded)
list.push({ situation: 'home.knowledgeUp', vars: { tag: upgraded.name, level: levelName(upgraded.level) }, actions: [go(upgraded.name), random] })
else if (learning)
list.push({
situation: 'home.knowledgeNext',
n: remainingToNext(learning)!,
vars: { tag: learning.name, level: levelName(learning.level + 1) },
actions: [go(learning.name), random],
})
else if (untouched) list.push({ situation: 'home.knowledgeFresh', vars: { tag: untouched.name }, actions: [go(untouched.name), random] })
}
// 本周进步榜:第一就说第一;前一名在榜面上(只回前几名)、差得不多就报还要几道。
// 同分时提交少的在前,追平不一定超得过,所以按多做 1 道算
const me = weekly.value?.me
if (me) {
const rank = goAction(router, '去排名', '/rank?period=week')
const ahead = weekly.value!.results.find((row) => row.rank === me.rank - 1)
if (me.rank === 1) list.push({ situation: 'rank.top', actions: [rank, random] })
else if (ahead && ahead.solvedCount - me.solvedCount <= 5)
list.push({ situation: 'rank.gap', n: ahead.solvedCount - me.solvedCount + 1, actions: [rank, random] })
}
return list
}
function learnLink(track: Track) { function learnLink(track: Track) {
return tutorialPath(track.type, track.step) return tutorialPath(track.type, track.step)
} }
@@ -276,8 +362,8 @@ function contestTime(contest: Contest) {
<div class="home"> <div class="home">
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 --> <!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
<section class="hero"> <section class="hero">
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1> <h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ displayName(userStore.user?.username ?? '') }}</h1>
<h1 v-else class="hello">{{ greeting }},{{ userStore.user?.username }}</h1> <h1 v-else class="hello">{{ greeting }},{{ displayName(userStore.user?.username ?? '') }}</h1>
<n-text v-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text> <n-text v-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text>
<n-text v-else depth="3"> <n-text v-else depth="3">
<template v-if="longAway">{{ awayText }} · </template> <template v-if="longAway">{{ awayText }} · </template>
+29 -1
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayName } from '@oj2/contract'
import type { ClassToday } from '@oj2/contract' import type { ClassToday } from '@oj2/contract'
import type { AnnouncementListItem } from 'utils/types' import type { AnnouncementListItem } from 'utils/types'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { getAnnouncementList, getClassToday } from 'oj/api' import { getAnnouncementList, getClassToday } from 'oj/api'
import { classLabel } from 'utils/classes' import { classLabel } from 'utils/classes'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { parseTime, zonedParts, zonedYear } from 'utils/functions' import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/** /**
@@ -65,6 +67,32 @@ function openTask(row: ClassRow) {
router.push({ name: 'task', query: { className: row.className } }) router.push({ name: 'task', query: { className: row.className } })
} }
/**
* 看板娘:今天有班布置了题,就带去那个班这节课的提交(和提交列表「这节课」同一组筛选:班、这几道题、今天),
* 有好几个班时挑正在上课的那个
*/
useMascotGuide(() => {
if (!today.value) return null
const assigned = today.value.classes.filter((row) => row.source === 'teacher' && row.problems.length)
const row = assigned.find((it) => stateOf(it).live) ?? assigned[0]
if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] }
const query = { className: row.className, problem: row.problems.map((problem) => problem.problemDisplayId).join(','), today: '1' }
// 有人交过了就说这节课做完几个,还没人交就照旧那几句
const progress = row.activeCount
? {
situation: row.allDoneCount >= row.rosterCount ? ('teacher.home.progressDone' as const) : ('teacher.home.progress' as const),
vars: { cls: classLabel(row.className), done: row.allDoneCount, total: row.rosterCount, left: row.rosterCount - row.allDoneCount },
}
: { situation: 'teacher.home.assigned' as const }
return {
...progress,
actions: [
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
{ label: '课堂任务', run: () => openTask(row) },
],
}
})
const shortcuts = computed(() => const shortcuts = computed(() =>
[ [
{ label: '课堂任务', to: '/task', show: true }, { label: '课堂任务', to: '/task', show: true },
@@ -78,7 +106,7 @@ const shortcuts = computed(() =>
<template> <template>
<div class="home"> <div class="home">
<section class="hero"> <section class="hero">
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1> <h1 class="hello">{{ greeting }},{{ displayName(userStore.user?.username ?? '') }}</h1>
<n-text v-if="today" depth="3" class="tagline"> <n-text v-if="today" depth="3" class="tagline">
{{ today.classes.length ? `今天 ${today.classes.length} 个班上过课` : '今天还没有班在上课' }} {{ today.classes.length ? `今天 ${today.classes.length} 个班上过课` : '今天还没有班在上课' }}
</n-text> </n-text>
@@ -3,7 +3,7 @@ import type { Exercise } from 'utils/types'
/** /**
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染: * 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
* *
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮; * - `heading`:二级标专题独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框; * - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块; * - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示; * 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
+18
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils' import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
import TutorialRow from './components/TutorialRow.vue' import TutorialRow from './components/TutorialRow.vue'
import ProjectTile from './components/ProjectTile.vue' import ProjectTile from './components/ProjectTile.vue'
@@ -94,6 +95,23 @@ function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
} }
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 1 课开始
useMascotGuide(() => {
if (!loaded.value || userStore.isTeacherOrAbove) return null
const it = resume.value
if (it) {
const next = goAction(router, '接着学', tutorialPath(it.course.type, it.step))
// 接着学的那课之前,离它最近的一课打开过、练一练却没全对(「在学」那一态):先叫回去看看
const { titles, progress } = it.course
for (let step = it.step - 1; step >= 1; step--) {
if (tutorialState(progress[titles[step - 1]!.id]) !== 'started') continue
return { situation: 'learn.wrongExercise', n: step, actions: [goAction(router, '去看看', tutorialPath(it.course.type, step)), next] }
}
return { situation: 'learn.continue', n: it.step, actions: [next] }
}
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
})
watch(() => userStore.isAuthed, load) watch(() => userStore.isAuthed, load)
onMounted(load) onMounted(load)
</script> </script>
+11
View File
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils' import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
import TutorialBody from './components/TutorialBody.vue' import TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue' import TutorialEnd from './components/TutorialEnd.vue'
@@ -233,6 +234,16 @@ function jump(id: string) {
function jumpExercise(id: number) { function jumpExercise(id: number) {
jump(`exercise-${id}`) jump(`exercise-${id}`)
} }
// 看板娘:练一练没做完就带去第一道没做对的;做完了(或这课没有练一练)就去下一课
useMascotGuide(() => {
if (!tutorial.value || userStore.isTeacherOrAbove) return null
const to = next.value?.to
const nextLesson: MascotAction[] = to ? [{ label: '下一课', run: () => router.push(to) }] : []
const unsolved = firstUnsolved.value
if (!unsolved) return { situation: 'tutorial.passed', actions: nextLesson }
return { situation: 'tutorial', actions: [{ label: '练一练', run: () => jumpExercise(unsolved.id) }, ...nextLesson] }
})
</script> </script>
<template> <template>
+5
View File
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
import { useAIStream } from 'shared/composables/aiStream' import { useAIStream } from 'shared/composables/aiStream'
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 { useMascotGuide } from 'shared/mascot/guide'
import PkDuel from './components/PkDuel.vue' import PkDuel from './components/PkDuel.vue'
import PkGrid from './components/PkGrid.vue' import PkGrid from './components/PkGrid.vue'
import PkRace from './components/PkRace.vue' import PkRace from './components/PkRace.vue'
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
const MAX_CLASSES = 8 const MAX_CLASSES = 8
const teacher = computed(() => userStore.isTeacherOrAbove) const teacher = computed(() => userStore.isTeacherOrAbove)
// 看板娘:学生念叨一句班级对决,老师只打招呼
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
const pk = ref<ClassPk | null>(null) const pk = ref<ClassPk | null>(null)
const loading = ref(false) const loading = ref(false)
const battle = ref<ClassBattleItem[]>([]) const battle = ref<ClassBattleItem[]>([])
@@ -71,8 +71,8 @@ const label = computed(() => {
} }
.block { .block {
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
display: inline-flex; display: inline-flex;
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { displayName } from '@oj2/contract'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
@@ -85,7 +86,7 @@ function finishAndNext() {
<button type="button" :class="next ? 'ghost' : 'end'" @click="finish"> <button type="button" :class="next ? 'ghost' : 'end'" @click="finish">
{{ peek ? '看完了' : '帮完了' }} {{ peek ? '看完了' : '帮完了' }}
</button> </button>
<button v-if="next" type="button" class="end" @click="finishAndNext">下一个:{{ next.studentName }} {{ next.problemId }} →</button> <button v-if="next" type="button" class="end" @click="finishAndNext">下一个:{{ displayName(next.studentName) }} {{ next.problemId }} →</button>
</div> </div>
</template> </template>
@@ -6,6 +6,7 @@ import { applyFixes, clearErrorMark, currentCode, showErrorMark, showPunctuation
import { explainCCompileError } from 'oj/problem/utils/cError' import { explainCCompileError } from 'oj/problem/utils/cError'
import { explainPythonCompileError } from 'utils/pythonError' import { explainPythonCompileError } from 'utils/pythonError'
import { stripJudgePath } from 'utils/judge' import { stripJudgePath } from 'utils/judge'
import SectionHead from './SectionHead.vue'
/** /**
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走 * 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
@@ -80,17 +81,17 @@ onUnmounted(clearErrorMark)
</script> </script>
<template> <template>
<div class="explain-card"> <!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明;那一行编辑器里已经标红了 -->
<div class="explain-section">
<template v-if="explanation"> <template v-if="explanation">
<p class="explain"> <SectionHead icon="problem" tone="amber">{{ explanation.line ? `第 ${explanation.line} 行` : '代码的格式写错了' }}</SectionHead>
<b v-if="explanation.line">第 {{ explanation.line }} 行:</b>{{ explanation.message }} <p class="explain">{{ explanation.message }}</p>
</p>
<p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p> <p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p>
<div v-if="explanation.punctuation"> <div v-if="explanation.punctuation" class="fix">
<n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button> <n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button>
<n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text> <n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text>
</div> </div>
<n-collapse> <n-collapse class="raw-collapse">
<n-collapse-item title="原始报错(英文)" name="raw"> <n-collapse-item title="原始报错(英文)" name="raw">
<div class="raw">{{ rawError }}</div> <div class="raw">{{ rawError }}</div>
</n-collapse-item> </n-collapse-item>
@@ -98,6 +99,7 @@ onUnmounted(clearErrorMark)
</template> </template>
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 --> <!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
<template v-else> <template v-else>
<SectionHead icon="problem" tone="amber">代码的格式写错了</SectionHead>
<p class="explain">请仔细检查,看看代码的格式是不是写错了!</p> <p class="explain">请仔细检查,看看代码的格式是不是写错了!</p>
<div class="raw">{{ rawError }}</div> <div class="raw">{{ rawError }}</div>
</template> </template>
@@ -105,20 +107,15 @@ onUnmounted(clearErrorMark)
</template> </template>
<style scoped> <style scoped>
/* 和答错的说明同一种浅灰卡片(设计稿「语法没过」) */ .explain-section {
.explain-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
} }
.explain { .explain {
margin: 0; margin: 0 0 10px;
line-height: 1.7; font-size: 16px;
line-height: 1.75;
} }
.explain.other { .explain.other {
@@ -126,6 +123,10 @@ onUnmounted(clearErrorMark)
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
} }
.fix {
margin-bottom: 10px;
}
.raw { .raw {
white-space: pre; white-space: pre;
overflow-x: auto; overflow-x: auto;
+244 -24
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api' import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
@@ -18,11 +19,11 @@ import ProblemChips, { type ChipItem } from './ProblemChips.vue'
* 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条: * 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条:
* *
* - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题 * - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题
* - 题单:题单名 + 进度 + 第几题 + 上一题 / 下一题 * - 专题:专题名 + 进度 + 第几题 + 上一题 / 下一题
* - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名 * - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名
* - 都不是:不显示 * - 都不是:不显示
* *
* 一道题既在这节课里、又是从题单进来的,按入口显示题单条。 * 一道题既在这节课里、又是从专题进来的,按入口显示专题条。
* 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。 * 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。
*/ */
@@ -33,15 +34,16 @@ const ctx = useProblemPageContext()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
const theme = useThemeVars() const theme = useThemeVars()
const isDark = useDark() const isDark = useDark()
/** 做完的实心绿圈里的勾:暗色下主题绿很亮,白勾看不清 */
const doneDotText = computed(() => (isDark.value ? 'rgba(0, 0, 0, 0.85)' : '#fff'))
/** /**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、题单浅蓝、比赛浅黄。 * 课堂、比赛在电脑上是左边的竖栏(课堂浅绿底、比赛浅黄底),专题是一条不带底色的细横条;
* 手机上三种都是横条(课堂浅绿、比赛浅黄)。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清 * 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/ */
const tone = computed(() => ({ const tone = computed(() => ({
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed, lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
setText: isDark.value ? '#8fb4ee' : '#1d4f9c',
setBar: isDark.value ? '#6f9be0' : '#2f6bc4',
contestText: isDark.value ? '#f2c97d' : '#8a5200', contestText: isDark.value ? '#f2c97d' : '#8a5200',
})) }))
const userStore = useUserStore() const userStore = useUserStore()
@@ -119,7 +121,7 @@ onSolvedHere((step) => {
lessonStore.load(true) lessonStore.load(true)
}) })
// ==================== 题单 ==================== // ==================== 专题 ====================
const problemSet = ref<ProblemSet | null>(null) const problemSet = ref<ProblemSet | null>(null)
const setProblems = ref<ProblemSetProblem[]>([]) const setProblems = ref<ProblemSetProblem[]>([])
@@ -131,12 +133,12 @@ watch(
if (!id) return if (!id) return
try { try {
const [detail, problems] = await Promise.all([getProblemSetDetail(Number(id)), getProblemSetProblems(Number(id))]) const [detail, problems] = await Promise.all([getProblemSetDetail(Number(id)), getProblemSetProblems(Number(id))])
// 拉的这一会儿已经离开了这个题单 // 拉的这一会儿已经离开了这个专题
if (ctx.value.problemSetId !== id) return if (ctx.value.problemSetId !== id) return
problemSet.value = detail problemSet.value = detail
setProblems.value = problems setProblems.value = problems
} catch { } catch {
// 拉不到就没有题单条,不影响做题 // 拉不到就没有专题条,不影响做题
} }
}, },
{ immediate: true }, { immediate: true },
@@ -153,8 +155,8 @@ const setBar = computed(() => {
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id }, params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
} }
: null : null
// 进度只数必做题,和题单页的「完成进度 2 / 8(另有 3 道选做)」同一个口径; // 进度只数必做题,和专题页的「完成进度 2 / 8(另有 3 道选做)」同一个口径;
// 一道必做都没有的题单才数全部 // 一道必做都没有的专题才数全部
const required = list.some((item) => item.isRequired) ? list.filter((item) => item.isRequired) : list const required = list.some((item) => item.isRequired) ? list.filter((item) => item.isRequired) : list
const done = required.filter((item) => item.isCompleted).length const done = required.filter((item) => item.isCompleted).length
return { return {
@@ -180,7 +182,7 @@ async function joinHere() {
await joinProblemSet(Number(id)) await joinProblemSet(Number(id))
problemSet.value = await getProblemSetDetail(Number(id)) problemSet.value = await getProblemSetDetail(Number(id))
} catch { } catch {
// 加入失败就还是那条提示,回题单页再点一次 // 加入失败就还是那条提示,回专题页再点一次
} finally { } finally {
joiningSet.value = false joiningSet.value = false
} }
@@ -241,8 +243,35 @@ onSolvedHere(() => {
</script> </script>
<template> <template>
<!-- 课堂 --> <!--
<div v-if="lesson" class="context-bar lesson"> 课堂(电脑):左栏最左边一条竖栏,只写题号,题名鼠标停上去看(学生认题靠题号)。
不占高度:机房 1366×768 那种屏,顶上每省一行,题面和编辑器就多一行(设计稿「题目页观感」合一版)
-->
<nav v-if="lesson && !isMobile" class="lesson-rail" aria-label="这节课的题">
<div class="rail-head">
<span>这节课</span>
<b class="rail-done">{{ lesson.done }}/{{ lesson.total }}</b>
<span v-if="lessonStore.languageText" class="rail-lang">{{ lessonStore.languageText }}</span>
</div>
<router-link
v-for="(chip, index) in lesson.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.note ? `${chip.title}(${chip.note})` : chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<span class="rail-dot">
<Icon v-if="chip.status === 'done'" icon="ph:check-bold" :width="14" />
<template v-else>{{ index + 1 }}</template>
</span>
<span class="rail-id">{{ chip.displayId }}</span>
</router-link>
</nav>
<!-- 课堂(手机):一条横的 -->
<div v-else-if="lesson" class="context-bar lesson">
<span class="lead lesson-lead" <span class="lead lesson-lead"
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span >这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
> >
@@ -296,10 +325,10 @@ onSolvedHere(() => {
</template> </template>
</div> </div>
<!-- 题单 --> <!-- 专题 -->
<div v-else-if="setBar" class="context-bar problemset"> <div v-else-if="setBar" class="context-bar problemset">
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`"> ‹ {{ setBar.title }} </router-link> <router-link :to="setBar.home" class="back set-back" :title="`回到专题:${setBar.title}`"> ‹ {{ setBar.title }} </router-link>
<!-- 手机上一排放不下题单名 + 进度条 + 做完几道 + 两个大箭头,进度条让掉,「做完 x/y」还在 --> <!-- 手机上一排放不下专题名 + 进度条 + 做完几道 + 两个大箭头,进度条让掉,「做完 x/y」还在 -->
<div v-if="setBar.joined && !isMobile" class="set-progress" aria-hidden="true"> <div v-if="setBar.joined && !isMobile" class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" /> <div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div> </div>
@@ -309,7 +338,7 @@ onSolvedHere(() => {
</span> </span>
<template v-else> <template v-else>
<span class="meta">{{ isMobile ? '没加入,不算进度' : '还没加入,在这里做对了不算进度' }}</span> <span class="meta">{{ isMobile ? '没加入,不算进度' : '还没加入,在这里做对了不算进度' }}</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere">加入题单</n-button> <n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere">加入专题</n-button>
</template> </template>
<div class="spacer" /> <div class="spacer" />
<component <component
@@ -356,7 +385,37 @@ onSolvedHere(() => {
</component> </component>
</div> </div>
<!-- 比赛 --> <!--
比赛(电脑):和课堂一样是左边一条竖栏 —— 比赛一般 3 到 10 道题,题号一列摆得下。
顶上「‹ 比赛」回比赛页(比赛名鼠标停上去看)、还剩多久;底下「排名」(期中期末进行中不给)
-->
<nav v-else-if="contestBar && !isMobile" class="lesson-rail contest-rail" aria-label="比赛的题">
<router-link :to="contestBar.home" class="rail-back" :title="`回到比赛:${contestBar.title}`">
<Icon icon="ph:caret-left-bold" :width="12" aria-hidden="true" />比赛
</router-link>
<div class="rail-head">
<template v-if="contestBar.remaining">
<span>还剩</span>
<b class="rail-time">{{ contestBar.remaining }}</b>
</template>
<span v-else>{{ contestBar.notStarted ? '还没开始' : '已结束' }}</span>
</div>
<router-link
v-for="chip in contestBar.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<!-- 比赛题号本来就短(1、2、A),直接写在圈里;做对了圈涂绿、号还在,认得出是哪道 -->
<span class="rail-dot">{{ chip.displayId }}</span>
</router-link>
<router-link v-if="contestBar.rank" :to="contestBar.rank" class="rail-rank"> <Icon icon="ph:ranking" :width="18" aria-hidden="true" />排名 </router-link>
</nav>
<!-- 比赛(手机) -->
<div v-else-if="contestBar" class="context-bar contest"> <div v-else-if="contestBar" class="context-bar contest">
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link> <router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" /> <ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
@@ -371,6 +430,165 @@ onSolvedHere(() => {
</template> </template>
<style scoped> <style scoped>
/* 比赛竖栏:浅黄底,和课堂分得开 */
.contest-rail {
background-color: rgba(208, 138, 28, 0.07);
border-right-color: rgba(208, 138, 28, 0.2);
}
.contest-rail .rail-item:hover {
background-color: rgba(208, 138, 28, 0.1);
}
.contest-rail .rail-dot {
font-size: 12px;
overflow: hidden;
}
.rail-back {
display: flex;
align-items: center;
justify-content: center;
gap: 1px;
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: v-bind('tone.contestText');
text-decoration: none;
}
.rail-back:hover {
text-decoration: underline;
}
.rail-time {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.contestText');
}
.rail-rank {
margin-top: auto;
padding: 8px 0 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 12px;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-rank:hover {
color: v-bind('tone.contestText');
}
/* ==================== 课堂竖栏(电脑) ==================== */
.lesson-rail {
width: 56px;
flex: none;
box-sizing: border-box;
padding: 8px 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
overflow-y: auto;
scrollbar-width: none;
background-color: rgba(24, 160, 88, 0.06);
border-right: 1px solid rgba(24, 160, 88, 0.14);
}
.rail-head {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
line-height: 1.35;
color: v-bind('theme.textColor3');
}
.rail-done {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.lessonText');
}
.rail-lang {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 7px 0;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-item:hover {
background-color: rgba(24, 160, 88, 0.08);
}
.rail-item.current {
font-weight: 700;
color: v-bind('tone.lessonText');
background-color: v-bind('theme.bodyColor');
box-shadow: inset 3px 0 v-bind('theme.primaryColor');
}
.rail-id {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-dot {
width: 28px;
height: 28px;
box-sizing: border-box;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
border: 1.5px solid rgba(128, 128, 128, 0.4);
background-color: v-bind('theme.bodyColor');
color: v-bind('theme.textColor2');
}
.done .rail-dot {
border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: v-bind('doneDotText');
}
/* 做过没对:红圈,和题目列表的红「−」一个意思 */
.tried .rail-dot {
border-color: v-bind('theme.errorColor');
color: v-bind('theme.errorColor');
}
.current .rail-dot {
border-width: 2px;
border-color: v-bind('theme.primaryColor');
color: v-bind('tone.lessonText');
}
.current.done .rail-dot {
color: v-bind('doneDotText');
}
.context-bar { .context-bar {
height: 44px; height: 44px;
flex: none; flex: none;
@@ -390,10 +608,12 @@ onSolvedHere(() => {
border-bottom-color: rgba(24, 160, 88, 0.18); border-bottom-color: rgba(24, 160, 88, 0.18);
} }
/* 专题:不带底色的一条细横条(设计稿「题目页观感」合一版)。专题可以有几十道题、还有「加入专题」,
竖栏放不下,留在页签行上面 */
.problemset { .problemset {
height: 40px;
gap: 10px; gap: 10px;
background-color: rgba(47, 107, 196, 0.06); border-bottom-color: v-bind('theme.dividerColor');
border-bottom-color: rgba(47, 107, 196, 0.16);
} }
.contest { .contest {
@@ -437,7 +657,7 @@ onSolvedHere(() => {
} }
.set-back { .set-back {
color: v-bind('tone.setText'); color: v-bind('theme.textColor1');
} }
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */ /* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
@@ -452,14 +672,14 @@ onSolvedHere(() => {
width: 110px; width: 110px;
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16); background-color: rgba(128, 128, 128, 0.16);
overflow: hidden; overflow: hidden;
} }
.set-progress-fill { .set-progress-fill {
height: 100%; height: 100%;
border-radius: 3px; border-radius: 3px;
background-color: v-bind('tone.setBar'); background-color: v-bind('theme.primaryColor');
} }
.countdown { .countdown {
@@ -503,7 +723,7 @@ onSolvedHere(() => {
} }
.problemset .step.forward { .problemset .step.forward {
color: v-bind('tone.setText'); color: v-bind('theme.textColor1');
} }
.step:not(.disabled):hover { .step:not(.disabled):hover {
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useCodeStore } from 'oj/store/code' import { useCodeStore } from 'oj/store/code'
@@ -29,7 +31,7 @@ function fillSample(input: string) {
<template> <template>
<!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) --> <!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) -->
<div class="custom"> <div class="custom">
<span class="label">输入</span> <SectionHead icon="input">输入</SectionHead>
<n-input <n-input
v-model:value="customInput" v-model:value="customInput"
type="textarea" type="textarea"
@@ -38,15 +40,16 @@ function fillSample(input: string) {
class="mono" class="mono"
/> />
<div class="actions"> <div class="actions">
<n-button type="primary" ghost :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()"> <n-button type="primary" secondary :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()">
<template #icon><Icon icon="ph:play-fill" /></template>
用这组数据运行 用这组数据运行
</n-button> </n-button>
<n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text class="fill" @click="fillSample(sample.input)"> <n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text type="primary" class="fill" @click="fillSample(sample.input)">
填入例子 {{ index + 1 }} 填入例子 {{ index + 1 }}
</n-button> </n-button>
</div> </div>
<span class="label">输出</span> <SectionHead icon="output" class="output-head">输出</SectionHead>
<TrialErrorNote <TrialErrorNote
v-if="customResult !== null && customResult !== SubmissionStatus.accepted" v-if="customResult !== null && customResult !== SubmissionStatus.accepted"
:result="customResult" :result="customResult"
@@ -68,13 +71,14 @@ function fillSample(input: string) {
gap: 8px; gap: 8px;
} }
.label { .output-head {
font-size: 13px; margin-top: 14px;
font-weight: 600;
} }
.mono :deep(textarea) { .mono :deep(textarea) {
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
} }
.actions { .actions {
@@ -85,13 +89,12 @@ function fillSample(input: string) {
} }
.fill { .fill {
font-size: 13px; font-size: 14px;
color: v-bind('theme.textColor2');
} }
.output { .output {
margin: 0; margin: 0;
min-height: 88px; min-height: 60px;
box-sizing: border-box; box-sizing: border-box;
padding: 10px 10px; padding: 10px 10px;
border-radius: 6px; border-radius: 6px;
@@ -99,7 +102,9 @@ function fillSample(input: string) {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 14px; font-size: 16px;
line-height: 24px;
border: 1px solid rgba(128, 128, 128, 0.14);
} }
.output.empty { .output.empty {
@@ -182,7 +182,9 @@ const changeLanguage = (v: LANGUAGE) => {
<n-select <n-select
v-if="!drawing" v-if="!drawing"
v-model:value="codeStore.code.language" v-model:value="codeStore.code.language"
class="language"
style="width: 120px" style="width: 120px"
:bordered="false"
:size="buttonSize" :size="buttonSize"
:options="languageOptions" :options="languageOptions"
:disabled="showCollabBar" :disabled="showCollabBar"
@@ -190,15 +192,19 @@ const changeLanguage = (v: LANGUAGE) => {
/> />
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 --> <!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
<span v-if="!drawing" class="divider" aria-hidden="true" />
<!-- 浅绿底:比实心的「提交」轻一档,又比旁边不带框的按钮重(设计稿「题目页观感」合一版) -->
<n-button <n-button
v-if="canRunSamples && isDesktop" v-if="canRunSamples && isDesktop"
:size="buttonSize" :size="buttonSize"
type="primary" type="primary"
ghost secondary
:loading="samplesRunning" :loading="samplesRunning"
:disabled="!codeStore.code.value.trim()" :disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()" @click="submissionStore.runSamples()"
> >
<template #icon><Icon icon="ph:play-fill" /></template>
运行例子 运行例子
</n-button> </n-button>
@@ -215,22 +221,26 @@ const changeLanguage = (v: LANGUAGE) => {
:size="buttonSize" :size="buttonSize"
:type="helpButtonType" :type="helpButtonType"
:secondary="teacherIn || collabStore.helpStatus !== 'idle'" :secondary="teacherIn || collabStore.helpStatus !== 'idle'"
:quaternary="!teacherIn && collabStore.helpStatus === 'idle'"
:title="helpButtonTitle" :title="helpButtonTitle"
class="help" class="help"
:class="{ busy: teacherIn }" :class="{ busy: teacherIn }"
@click="toggleHelp" @click="toggleHelp"
> >
<span v-if="teacherIn" class="dot" aria-hidden="true" /> <span v-if="teacherIn" class="dot" aria-hidden="true" />
<Icon v-else-if="collabStore.helpStatus === 'idle'" icon="ph:hand" :width="17" class="help-icon" aria-hidden="true" />
{{ helpButtonText }} {{ helpButtonText }}
</n-button> </n-button>
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" @click="openStatistics(problem!._id)"> <n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" quaternary @click="openStatistics(problem!._id)">
课堂统计 课堂统计
</n-button> </n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 --> <!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
<n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect"> <n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect">
<n-button :size="buttonSize" class="more" aria-label="更多:去自测猫、复制代码、重置代码……"> ⋯ </n-button> <n-button :size="buttonSize" quaternary class="more" aria-label="更多:去自测猫、复制代码、重置代码……">
<Icon icon="ph:dots-three-bold" :width="18" />
</n-button>
</n-dropdown> </n-dropdown>
</div> </div>
</template> </template>
@@ -288,6 +298,23 @@ const changeLanguage = (v: LANGUAGE) => {
padding: 0; padding: 0;
} }
/* 语言选择不带框,字加粗:它不是一个要常去点的按钮 */
.language :deep(.n-base-selection-label) {
font-weight: 600;
}
.divider {
flex: none;
width: 1px;
height: 20px;
margin: 0 2px;
background-color: v-bind('theme.dividerColor');
}
.help-icon {
margin-right: 4px;
}
.help.busy { .help.busy {
cursor: default; cursor: default;
} }
@@ -226,7 +226,7 @@ function goCode() {
<span class="attempt pending">第 {{ scores.length + 1 }} 次</span> <span class="attempt pending">第 {{ scores.length + 1 }} 次</span>
</template> </template>
</div> </div>
<p v-if="hiddenCount" class="note">另外 {{ hiddenCount }} 次是题单布置之前画的,先藏起来了:在题单里画到 A 或 S,或者布置期结束就解锁</p> <p v-if="hiddenCount" class="note">另外 {{ hiddenCount }} 次是专题布置之前画的,先藏起来了:在专题里画到 A 或 S,或者布置期结束就解锁</p>
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 --> <!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
<template v-if="phase === 'evaluating' && viewingLatest"> <template v-if="phase === 'evaluating' && viewingLatest">
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useLessonStore } from 'shared/store/lesson' import { useLessonStore } from 'shared/store/lesson'
import ChallengeList from './ChallengeList.vue' import ChallengeList from './ChallengeList.vue'
@@ -44,16 +45,17 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</script> </script>
<template> <template>
<!-- 设计稿「答案正确:下一题在结果页签里」:浅绿卡片,一个 44px 的大按钮 --> <!--
<div v-if="lesson" class="lesson-next"> 设计稿「题目页观感」结果 X:「接着做」一节,一句话说还剩几道,一个 44px 的大按钮写下一题。
原来是一张浅绿卡片:左边竖栏已经有这节课的进度,不用再框一块
-->
<section v-if="lesson" class="lesson-next">
<SectionHead icon="next">接着做</SectionHead>
<template v-if="lesson.next"> <template v-if="lesson.next">
<div class="text"> <p class="text">
<span class="headline">{{ label }}还剩 {{ lesson.remaining }} 道</span> {{ label }}还剩 {{ lesson.remaining }} 道,下一道 {{ lesson.next.problemDisplayId }}
<span class="detail"> {{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}。
{{ lesson.next.problemDisplayId }} </p>
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}
</span>
</div>
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next"> <router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
<span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span> <span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span>
<svg <svg
@@ -72,48 +74,30 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</router-link> </router-link>
</template> </template>
<template v-else> <template v-else>
<div class="text"> <p class="text">{{ label }}都做完了,一共 {{ lesson.total }} 道。<template v-if="!challenges">想接着练,可以去专题里挑一个。</template></p>
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span v-if="!challenges" class="detail">想接着练,可以去题单里挑一个</span>
</div>
<router-link v-if="!challenges" to="/problemset" class="next secondary"> <router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去题单继续练</span> <span class="next-label">去专题继续练</span>
</router-link> </router-link>
</template> </template>
</div> </section>
<!-- 做完了:先给这节课里还能挑战的,再给题单(设计稿 StkDone)。挑战一条都没有时只剩题单那句 --> <!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
<template v-if="lesson && !lesson.next && challenges"> <template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" /> <ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
<router-link to="/problemset" class="more">去题单继续练</router-link> <router-link to="/problemset" class="more">去专题继续练</router-link>
</template> </template>
</template> </template>
<style scoped> <style scoped>
.lesson-next { .lesson-next {
margin-top: 12px; margin-top: 4px;
padding: 16px 18px;
border-radius: 8px;
background-color: rgba(24, 160, 88, 0.07);
border: 1px solid rgba(24, 160, 88, 0.25);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
} }
.text { .text {
display: flex; margin: 0 0 12px;
flex-direction: column; font-size: 16px;
gap: 4px; line-height: 1.75;
}
.headline {
font-size: 15px;
font-weight: 600;
}
.detail {
font-size: 13px;
color: v-bind('theme.textColor2');
} }
.next { .next {
@@ -125,10 +109,10 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
gap: 8px; gap: 8px;
height: 44px; height: 44px;
padding: 0 20px; padding: 0 20px;
border-radius: 6px; border-radius: 8px;
background-color: v-bind('theme.primaryColor'); background-color: v-bind('theme.primaryColor');
color: v-bind('buttonText'); color: v-bind('buttonText');
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
@@ -1,14 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { isSqlProblem, parseFlowDoc } from '@oj2/contract' import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { DIFFICULTY } from 'utils/constants' import { DIFFICULTY } from 'utils/constants'
import { copyToClipboard, getTagColor } from 'utils/functions' import { getTagColor } from 'utils/functions'
import { useSubmissionStore } from 'oj/store/submission' import { useSubmissionStore } from 'oj/store/submission'
import Markdown from 'shared/components/Markdown.vue' import Markdown from 'shared/components/Markdown.vue'
import SqlProblemData from './SqlProblemData.vue' import SqlProblemData from './SqlProblemData.vue'
import SimilarProblems from './SimilarProblems.vue' import SimilarProblems from './SimilarProblems.vue'
import SampleList from './SampleList.vue'
import SectionHead from './SectionHead.vue'
import { tidyStatement } from '../utils/statement'
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart' import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
import { useMyFlowchartStore } from 'shared/store/myFlowchart' import { useMyFlowchartStore } from 'shared/store/myFlowchart'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
@@ -28,12 +32,12 @@ const ctx = useProblemPageContext()
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果 // SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value)) const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
// 相似题推荐:做对了、或者错了 3 次以上才给(比赛、题单里不给,见 problemPageContext) // 相似题推荐:做对了、或者错了 3 次以上才给(比赛、专题里不给,见 problemPageContext)
const showSimilar = computed(() => ctx.value.similar && (problem.value?.myStatus === 0 || problemStore.failCount >= 3)) const showSimilar = computed(() => ctx.value.similar && (problem.value?.myStatus === 0 || problemStore.failCount >= 3))
/** /**
* 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有 * 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有
* 通过」),占掉一屏里最值钱的那几行。题单里不给(入口差异表)。 * 通过」),占掉一屏里最值钱的那几行。专题里不给(入口差异表)。
*/ */
const myStatus = computed(() => { const myStatus = computed(() => {
if (!ctx.value.statusTag || !userStore.isAuthed) return null if (!ctx.value.statusTag || !userStore.isAuthed) return null
@@ -73,27 +77,15 @@ watch(
() => (myFlowchartOpen.value = true), () => (myFlowchartOpen.value = true),
) )
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */ /**
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
*/
const flowchartGrade = computed(() => { const flowchartGrade = computed(() => {
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade)) const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
if (!passed.length) return '' return best?.grade ?? ''
return passed.some((row) => row.grade === 'S') ? 'S' : 'A'
}) })
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, {
immediate: false,
})
async function copySample(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。 // 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来 // 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
@@ -105,140 +97,96 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位), // 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? []) const samples = computed(() => problem.value?.samples ?? [])
const submissionStore = useSubmissionStore() const submissionStore = useSubmissionStore()
const SAMPLE_SIDES = ['input', 'output'] as const /** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 utils/statement) */
const SAMPLE_SIDE_LABEL = { input: '输入', output: '输出' } as const const statement = computed(() => ({
description: tidyStatement(problem.value?.description),
/* input: tidyStatement(problem.value?.inputDescription),
* 例子默认摆成「例子 N | 输入 | 输出」的表,可机房那两种屏上左栏只有 550px 左右,每格只放得下 output: tidyStatement(problem.value?.outputDescription),
* 二十来个字符,而例子输出最长那行的 p90 就有 28 个字符宽(中文按两个算)—— 一成的题会在 hint: tidyStatement(problem.value?.hint),
* 半格里横着滚。所以按这道题最长那行量一下:半格放得下就摆表,放不下这道题的例子就整体改成 }))
* 上下排(输入一行、输出一行,各占满宽)。整宽还放不下的二十来道才横着滚。不自动折行:折出来
* 的行学生分不清是不是真换了行,照着它输出就错了。
* 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数。
*/
const SAMPLE_FONT = '14px Consolas, Monaco, monospace'
const SAMPLE_NO_W = 56
const SAMPLE_GAP = 8
const SAMPLE_PAD_L = 10
const SAMPLE_PAD_R = 4
const SAMPLE_COPY_W = 44
const sampleCss = {
font: SAMPLE_FONT,
noW: `${SAMPLE_NO_W}px`,
gap: `${SAMPLE_GAP}px`,
pad: `2px ${SAMPLE_PAD_R}px 2px ${SAMPLE_PAD_L}px`,
copyW: `${SAMPLE_COPY_W}px`,
}
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const sampleTextWidth = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = SAMPLE_FONT
let widest = 0
for (const sample of samples.value) {
for (const side of SAMPLE_SIDES) {
for (const line of sample[side].split('\n')) {
// <pre> 里 tab 按 8 列排
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > widest) widest = width
}
}
}
return widest
})
const sampleTable = useTemplateRef<HTMLElement>('sampleTable')
const { width: sampleTableWidth } = useElementSize(sampleTable)
/**
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
*/
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
/** 摆成表时,半格里留给文字的宽度 */
const sampleHalfWidth = computed(
() => (sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) / 2 - SAMPLE_PAD_L - SAMPLE_PAD_R - SAMPLE_COPY_W,
)
const samplesStacked = computed(() => sampleTableWidth.value > 0 && sampleTextWidth.value > sampleHalfWidth.value)
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS + // 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约 // ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
// astRequirementSchema。规则原文(engine / target)不下发给学生。 // astRequirementSchema。规则原文(engine / target)不下发给学生。
const KIND_TAG_TYPE = { // 每条规则前面一个小图标说是哪一类,规则的字照后端给的原样写(里面已经有「必须 / 不能」)
require: 'success', const KIND_ICON = {
forbid: 'error', require: 'ph:check-circle-fill',
count: 'info', forbid: 'ph:prohibit-fill',
count: 'ph:hash-bold',
} as const } as const
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {})) const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
</script> </script>
<template> <template>
<div v-if="problem" class="problem-content"> <div v-if="problem" class="problem-content">
<!-- 题号、题名、难度和状态一行(设计稿「题目页观感」定稿)。题名太长折到下一行,标签留在右边 -->
<header class="problem-head"> <header class="problem-head">
<div class="title-row"> <span class="pid">{{ problem._id }}</span>
<span class="pid">{{ problem._id }}</span> <h1 class="problem-title">{{ problem.title }}</h1>
<h1 class="problem-title">{{ problem.title }}</h1>
</div>
<div class="meta"> <div class="meta">
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)"> <n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }} {{ DIFFICULTY[problem.difficulty] }}
</n-tag> </n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag> <n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" class="solved"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」 <!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码 --> 「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
<template v-else-if="!flowchartGrade"> 「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag> 「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag> <template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="error" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
</template> </template>
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag> <n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div> </div>
</header> </header>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 --> <!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
<div v-if="astRequirements.length > 0" class="requirements"> 黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
<span class="requirements-head">语法要求</span> <section v-if="astRequirements.length > 0" class="block">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<template v-for="[lang, rules] in astRequirements" :key="lang"> <template v-for="[lang, rules] in astRequirements" :key="lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span> <div v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</div>
<n-tag v-for="(rule, i) in rules" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false"> <ul class="rules">
{{ rule.description }} <li v-for="(rule, i) in rules" :key="`${lang}-${i}`" :class="rule.kind">
</n-tag> <Icon :icon="KIND_ICON[rule.kind]" :width="16" class="rule-icon" aria-hidden="true" />
<span>{{ rule.description }}</span>
</li>
</ul>
</template> </template>
<span class="requirements-note">提交时会逐条检查</span>
</div>
<section class="block">
<h3 class="title">描述</h3>
<Markdown html :content="problem.description" />
</section> </section>
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 --> <section class="block">
<div v-if="!isSQL" class="io"> <SectionHead icon="description">描述</SectionHead>
<Markdown html :content="statement.description" />
</section>
<!-- 输入、输出上下排:并排的时候多一两句就挤成窄条(2020 的输入折成三行),旁边一栏空着 -->
<template v-if="!isSQL">
<section class="block"> <section class="block">
<h3 class="title">输入</h3> <SectionHead icon="input">输入</SectionHead>
<Markdown html :content="problem.inputDescription" /> <Markdown html :content="statement.input" />
</section> </section>
<section class="block"> <section class="block">
<h3 class="title">输出</h3> <SectionHead icon="output">输出</SectionHead>
<Markdown html :content="problem.outputDescription" /> <Markdown html :content="statement.output" />
</section> </section>
</div> </template>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 --> <!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block"> <section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
<div class="block-head"> <SectionHead icon="flowchart">
<h3 class="title">你画的流程图</h3> 你画的流程图
<n-button text type="primary" size="small" @click="myFlowchartZoom = true"> 放大看 </n-button> <template #extra>
<n-button text type="primary" size="small" @click="myFlowchartOpen = !myFlowchartOpen"> <n-button text type="primary" @click="myFlowchartZoom = true">放大看 ›</n-button>
{{ myFlowchartOpen ? '收起' : '展开' }} <n-button text type="primary" @click="myFlowchartOpen = !myFlowchartOpen">
</n-button> {{ myFlowchartOpen ? '收起' : '展开' }}
</div> </n-button>
</template>
</SectionHead>
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true"> <div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
<MyFlowchart compact /> <MyFlowchart compact />
</div> </div>
@@ -247,49 +195,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal> </n-modal>
</section> </section>
<template v-if="!isSQL"> <section v-if="!isSQL && samples.length" class="block">
<!-- <SectionHead icon="samples">
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子 例子
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」 <template #extra>
--> <n-button text type="primary" @click="submissionStore.revealResult('custom')">自己输入数据试试 ›</n-button>
<section v-if="samples.length" class="block"> </template>
<div class="block-head"> </SectionHead>
<h3 class="title">例子</h3> <SampleList :samples="samples" />
<n-button text type="primary" size="small" class="block-link" @click="submissionStore.revealResult('custom')"> 自己输入数据试试 › </n-button> </section>
</div>
<!-- 半格放不下最长那行就整体改成上下排,见 samplesStacked -->
<div ref="sampleTable" class="sample-table" :class="{ stacked: samplesStacked, narrow: sampleNarrow }" role="table" aria-label="例子">
<div v-if="!samplesStacked" class="sample-row" role="row">
<span v-if="!sampleNarrow" role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="sample-row" role="row">
<span class="sample-no" role="rowheader">例子 {{ index + 1 }}</span>
<template v-for="side in SAMPLE_SIDES" :key="side">
<span v-if="samplesStacked" class="sample-label sample-side">
{{ SAMPLE_SIDE_LABEL[side] }}
</span>
<div class="sample-cell" role="cell">
<pre class="testcase">{{ sample[side] }}</pre>
<button
type="button"
class="sample-copy"
:aria-label="`复制例子 ${index + 1} 的${SAMPLE_SIDE_LABEL[side]}`"
@click="copySample(`${index}-${side}`, sample[side])"
>
{{ copiedKey === `${index}-${side}` ? '已复制' : '复制' }}
</button>
</div>
</template>
</div>
</div>
</section>
</template>
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive"> <SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
<template #title="{ text }"> <template #title="{ text }">
<h3 class="title sql-title">{{ text }}</h3> <SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
</template> </template>
</SqlProblemData> </SqlProblemData>
@@ -298,10 +216,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏 折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
--> -->
<section v-if="canShowReferenceFlowchart" class="block"> <section v-if="canShowReferenceFlowchart" class="block">
<div class="block-head"> <SectionHead icon="flowchart">
<h3 class="title">参考流程图</h3> 参考流程图
<n-button text type="primary" size="small" class="block-link" @click="referenceZoom = true"> 放大看 </n-button> <template #extra>
</div> <n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
</template>
</SectionHead>
<div <div
class="reference-flowchart" class="reference-flowchart"
role="button" role="button"
@@ -319,268 +239,158 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal> </n-modal>
</section> </section>
<section v-if="problem.hint" class="block"> <section v-if="statement.hint" class="block">
<h3 class="title">提示</h3> <SectionHead icon="hint" tone="amber">提示</SectionHead>
<Markdown html :content="problem.hint" /> <Markdown html :content="statement.hint" />
</section> </section>
<section v-if="problem.source" class="block"> <SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
<h3 class="title">来源</h3>
<Markdown html :content="problem.source" />
</section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" /> <!-- 来源不单起一节:学生做题用不上它,缩成最底下一行灰字 -->
<footer v-if="problem.source" class="source">
<span class="source-label">来源</span>
<Markdown html :content="problem.source" class="source-text" />
</footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* 设计稿「题目页签:还没运行过」 */
.problem-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.pid {
flex: none;
padding: 2px 8px;
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.12);
font-size: 13px;
color: v-bind('theme.textColor2');
}
.problem-title {
margin: 0;
font-size: 22px;
line-height: 1.3;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
}
.block {
margin-top: 18px;
}
.block-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.block-head .title {
margin: 0;
}
.problem-content { .problem-content {
container: problem-content / inline-size; container: problem-content / inline-size;
} }
.block-link { /* 题号 | 题名 | ……… | 难度 状态 */
margin-left: auto; .problem-head {
}
/*
* 小节标题(描述 / 输入 / 输出 / 例子 / 提示……)。画板上是 15px 半粗,比 14px 的正文只大一号,
* 一屏题面扫下来分不出哪是标题(用户 2026-09-29 提出):加粗、大一号,左边一道主色竖条
*/
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
margin: 0 0 6px;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
}
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
@container problem-content (max-width: 480px) {
.io {
grid-template-columns: minmax(0, 1fr);
}
}
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px 8px; align-items: baseline;
font-size: 13px; gap: 4px 10px;
} }
.requirements-head { .pid {
font-weight: 600; flex: none;
} font-size: 16px;
font-variant-numeric: tabular-nums;
.requirements-note {
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.lang-label { .problem-title {
margin: 0;
min-width: 0;
font-size: 22px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
align-self: center;
}
/* 「已解决」实心绿:做完了要一眼看得出 */
.meta .solved {
background-color: v-bind('theme.successColor');
color: #fff;
font-weight: 600; font-weight: 600;
} }
/* 参考流程图:和例子的格子同一种浅灰底 */ .block {
margin-top: 20px;
}
/* 题面里老师自己写的标题(2020 开头的「鸣潮公式」是 h1):降成和正文一样大的粗体,
不然比题名还大,顶在「描述」下面像另起了一道题 */
.problem-content :deep(.oj-markdown :is(h1, h2, h3, h4, h5, h6)) {
margin: 0.8em 0 0.3em;
font-size: 16px;
font-weight: 700;
line-height: 1.5;
}
/* 语法要求:每条一行 */
.lang-label {
margin: 6px 0 2px;
font-size: 14px;
font-weight: 600;
}
.rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.rules li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.rule-icon {
flex: none;
margin-top: 0.3em;
}
.rules .require .rule-icon {
color: v-bind('theme.successColor');
}
.rules .forbid .rule-icon {
color: v-bind('theme.errorColor');
}
.rules .count .rule-icon {
color: v-bind('theme.infoColor');
}
/* SQL 的「数据表 / 期望结果」和别的小节一样隔开 */
.sql-head {
margin-top: 20px;
}
/* 参考流程图:和例子的输入格同一种浅灰底 */
.reference-flowchart { .reference-flowchart {
cursor: zoom-in; cursor: zoom-in;
border-radius: 6px; border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05); background-color: rgba(128, 128, 128, 0.05);
padding: 16px 12px; padding: 16px 12px;
} }
.my-flowchart { .my-flowchart {
cursor: zoom-in; cursor: zoom-in;
border-radius: 6px; border-radius: 8px;
background-color: rgba(128, 128, 128, 0.06); border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 8px; padding: 8px;
} }
/* .source {
* 例子:例子 N | 输入 | 输出,每格右边一个「复制」。 margin-top: 24px;
* 放不下时(.stacked)每个例子两行:例子 N | 输入 | 那格,下一行 输出 | 那格 padding-top: 10px;
*/ border-top: 1px solid v-bind('theme.dividerColor');
.sample-table {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 6px; gap: 8px;
} font-size: 13px;
.sample-table.stacked {
gap: 12px;
}
.sample-row {
display: grid;
grid-template-columns: v-bind('sampleCss.noW') minmax(0, 1fr) minmax(0, 1fr);
gap: 6px v-bind('sampleCss.gap');
align-items: stretch;
}
.stacked .sample-row {
grid-template-columns: v-bind('sampleCss.noW') 32px minmax(0, 1fr);
}
.sample-label,
.sample-no {
font-size: 12px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.sample-no { .source-label {
display: flex;
align-items: center;
}
/* 上下排时「例子 N」竖跨输入、输出两行,和「输入」对齐在顶上 */
.stacked .sample-no {
grid-row: span 2;
align-items: flex-start;
}
.stacked .sample-no,
.sample-side {
padding-top: 8px;
line-height: 1.5;
}
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
.narrow .sample-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.narrow.stacked .sample-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.narrow .sample-no,
.narrow.stacked .sample-no {
grid-column: 1 / -1;
grid-row: auto;
padding-top: 4px;
}
.sample-table.narrow {
gap: 10px;
}
.sample-cell {
min-width: 0;
display: flex;
align-items: flex-start;
padding: v-bind('sampleCss.pad');
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
}
.testcase {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 0;
font: v-bind('sampleCss.font');
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
/* 整宽也放不下、只好横着滚的那二十来道:Windows 上默认那根带箭头的粗滚动条比例子本身还显眼 */
.testcase::-webkit-scrollbar {
height: 6px;
}
.testcase::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.35);
}
.testcase::-webkit-scrollbar-track {
background: transparent;
}
.sample-copy {
flex: none; flex: none;
width: v-bind('sampleCss.copyW');
height: 24px;
margin-top: 2px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind('theme.primaryColorPressed');
cursor: pointer;
} }
.sample-copy:hover { .source-text {
background-color: rgba(24, 160, 88, 0.1); min-width: 0;
} font-size: 13px;
line-height: 1.6;
.sql-title {
margin-top: 14px;
} }
</style> </style>
@@ -94,7 +94,7 @@ const mobileExtensions = [...desktopExtensions, ...hangWrapExtensions]
const editorExtensions = computed(() => (isDesktop.value ? desktopExtensions : mobileExtensions)) const editorExtensions = computed(() => (isDesktop.value ? desktopExtensions : mobileExtensions))
const contestID = route.params.contestID || null const contestID = route.params.contestID || null
// 草稿存在哪个键下(题单入口单独一份),和「重置代码」共用一份算法 // 草稿存在哪个键下(专题入口单独一份),和「重置代码」共用一份算法
const storageKey = useDraftKey() const storageKey = useDraftKey()
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。 // 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
@@ -120,8 +120,8 @@ onMounted(loadCode)
watch(() => problem.value?._id, loadCode) watch(() => problem.value?._id, loadCode)
/** /**
* 流程图草稿:每道题一份;题单入口单独一份 —— 加入题单之前在题库里画到 A 的那张,不能一进 * 流程图草稿:每道题一份;专题入口单独一份 —— 加入专题之前在题库里画到 A 的那张,不能一进
* 题单就摆在画布上(设计文档第 11 节 3,和代码草稿同理)。 * 专题就摆在画布上(设计文档第 11 节 3,和代码草稿同理)。
* 键名带 v2:2026-10 之前的草稿是 vue-flow 的格式,读不了,也不去覆盖它 * 键名带 v2:2026-10 之前的草稿是 vue-flow 的格式,读不了,也不去覆盖它
*/ */
const flowchartDraftKey = computed(() => { const flowchartDraftKey = computed(() => {
@@ -182,16 +182,25 @@ watch(
* 没有画这一步的课,画着流程图时不动他 * 没有画这一步的课,画着流程图时不动他
*/ */
const lessonStore = useLessonStore() const lessonStore = useLessonStore()
let lessonLanguageFor: string | null = null /**
* 换过的(题 + 这一节 + 该做的那一步)都记下:换到别的题再回来,同一步不再换第二次。
* 编辑器在换题时不重新挂载(detail.vue 只重新 init),只记最后一个的话一回来又被拽回去
*/
const lessonSwitched = new Set<string>()
let lessonViewing: string | null = null
watch( watch(
() => [problem.value?._id, lessonStore.activity] as const, () => [problem.value?._id, lessonStore.activity] as const,
([id, activity]) => { ([id, activity]) => {
if (!id || !activity) return if (!id || !activity) return
const key = [id, activity.day, activity.mode, activity.language].join('|') const lesson = [id, activity.day, activity.mode, activity.language].join('|')
if (lessonLanguageFor === key) return
lessonLanguageFor = key
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
const item = lessonStore.find(id) const item = lessonStore.find(id)
const key = [lesson, item?.draw && item.draw !== 'accepted' ? 'draw' : 'code'].join('|')
// 停在这道题上、只是课堂状态刷新了(比如刚画对图):不跟着换,也不记,下回打开这道题再换
const stay = lessonViewing === lesson
lessonViewing = lesson
if (lessonSwitched.has(key) || stay) return
lessonSwitched.add(key)
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
if (!item) return if (!item) return
if (item.draw && item.draw !== 'accepted') { if (item.draw && item.draw !== 'accepted') {
if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart') if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart')
@@ -44,7 +44,7 @@ const teacherCollab = useTeacherCollab()
const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null)) const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null))
// ==================== 班上第几个做对的 ==================== // ==================== 班上第几个做对的 ====================
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现 // 只在题库入口给:比赛有自己的排名,专题里整个抽屉都不出现
const rank = ref<ProblemRank | null>(null) const rank = ref<ProblemRank | null>(null)
const rankLine = computed(() => { const rankLine = computed(() => {
@@ -62,7 +62,7 @@ const rankLine = computed(() => {
return null return null
}) })
/** 「看看他们的写法」:同班的走 'ks' + 班级名的用户名前缀约定(设计文档第 13 节) */ /** 「看看他们的写法」:同班的按班级(class_name)筛,全站的不筛 */
function goAccepted() { function goAccepted() {
const r = rank.value const r = rank.value
if (!r) return if (!r) return
@@ -73,7 +73,7 @@ function goAccepted() {
result: '0', result: '0',
page: 1, page: 1,
limit: 10, limit: 10,
...(r.className ? { username: 'ks' + r.className } : {}), ...(r.className ? { className: r.className } : {}),
}, },
} }
// 协作中跳走这一页协作就断了 // 协作中跳走这一页协作就断了
@@ -109,7 +109,7 @@ async function listSubmissions() {
/** /**
* 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样 * 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样
* 只数当前这一页。被题单闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果 * 只数当前这一页。被专题闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果
*/ */
const statusDistribution = computed(() => { const statusDistribution = computed(() => {
const counts = new Map<number, number>() const counts = new Map<number, number>()
@@ -137,7 +137,7 @@ type Loaded = { code: string; language: LANGUAGE }
const details = reactive<Record<string, Loaded | 'loading' | 'failed'>>({}) const details = reactive<Record<string, Loaded | 'loading' | 'failed'>>({})
async function toggle(row: SubmissionListItem) { async function toggle(row: SubmissionListItem) {
// 题单布置期内藏着的旧提交,点不开(后端也不会给代码) // 专题布置期内藏着的旧提交,点不开(后端也不会给代码)
if (!row.showLink) return if (!row.showLink) return
if (expanded.value === row.id) { if (expanded.value === row.id) {
expanded.value = null expanded.value = null
@@ -154,7 +154,7 @@ async function toggle(row: SubmissionListItem) {
} }
} }
/** 这一页里被题单闸门藏起来的次数 */ /** 这一页里被专题闸门藏起来的次数 */
const lockedCount = computed(() => submissions.value.filter((row) => !row.showLink).length) const lockedCount = computed(() => submissions.value.filter((row) => !row.showLink).length)
/** 展开的代码先只露 5 行,「一共 11 行」点一下再全摆出来(设计稿) */ /** 展开的代码先只露 5 行,「一共 11 行」点一下再全摆出来(设计稿) */
@@ -304,7 +304,7 @@ watch(query, () => {
</div> </div>
</div> </div>
</template> </template>
<!-- 题单布置期内藏着的那几次:合成一行,点不开(后端也不给代码) --> <!-- 专题布置期内藏着的那几次:合成一行,点不开(后端也不给代码) -->
<div v-if="lockedCount" class="locked"> <div v-if="lockedCount" class="locked">
<svg <svg
width="13" width="13"
@@ -101,8 +101,8 @@ const color = computed(() => {
.result-header { .result-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
margin-bottom: 12px; margin-bottom: 18px;
} }
.line { .line {
@@ -114,8 +114,8 @@ const color = computed(() => {
.badge { .badge {
flex: none; flex: none;
width: 24px; width: 26px;
height: 24px; height: 26px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -125,12 +125,14 @@ const color = computed(() => {
} }
.title { .title {
font-size: 18px; font-size: 22px;
line-height: 1.3;
font-weight: 700; font-weight: 700;
color: v-bind('color.text'); color: v-bind('color.text');
} }
.sub { .sub {
font-size: 15px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
@@ -74,20 +74,22 @@ function pick(segment: Segment) {
<template> <template>
<FlowchartResult v-if="drawing" /> <FlowchartResult v-if="drawing" />
<div v-show="!drawing" class="result-pane"> <div v-show="!drawing" class="result-pane">
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 --> <!-- 三个分段,带上那次结果的时间 -->
<div v-if="showSegments" class="segments" role="tablist" aria-label="结果"> <div v-if="showSegments" class="segments">
<button <div class="segment-track" role="tablist" aria-label="结果">
v-for="segment in segments" <button
:key="segment.value" v-for="segment in segments"
type="button" :key="segment.value"
role="tab" type="button"
class="segment" role="tab"
:class="{ active: isActive(segment.value) }" class="segment"
:aria-selected="isActive(segment.value)" :class="{ active: isActive(segment.value) }"
@click="pick(segment.value)" :aria-selected="isActive(segment.value)"
> @click="pick(segment.value)"
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template> >
</button> {{ segment.label }}<span v-if="segment.time" class="segment-time">{{ segment.time }}</span>
</button>
</div>
<span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span> <span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span>
</div> </div>
@@ -116,35 +118,53 @@ function pick(segment: Segment) {
padding-bottom: 16px; padding-bottom: 16px;
} }
/* 设计稿「题目页观感」结果 X:浅灰底的分段按钮,选中的白底加粗(和工具栏「写代码 / 画流程图」一个样子) */
.segments { .segments {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; align-items: center;
margin-bottom: 14px; gap: 8px 12px;
margin-bottom: 16px;
}
.segment-track {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 8px;
background-color: rgba(128, 128, 128, 0.12);
} }
.segment { .segment {
height: 28px; height: 28px;
padding: 0 12px; padding: 0 12px;
border: 0; border: 0;
border-radius: 15px; border-radius: 6px;
background-color: rgba(128, 128, 128, 0.12); background: transparent;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
font: inherit; font: inherit;
font-size: 13px; font-size: 14px;
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.segment-time {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: v-bind('theme.textColor3');
}
.segment-note { .segment-note {
margin-left: auto;
align-self: center;
font-size: 13px; font-size: 13px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.segment.active { .segment.active {
background-color: v-bind('theme.textColor1'); background-color: v-bind('theme.cardColor');
color: v-bind('theme.cardColor'); color: v-bind('theme.textColor1');
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
} }
.formatted { .formatted {
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark' import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark'
import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError' import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError'
import SectionHead from './SectionHead.vue'
/** /**
* 运行时错误的中文说明卡片(诊断由后端 judge/runtime-diagnosis.ts 给)。 * 运行时错误的中文说明(诊断由后端 judge/runtime-diagnosis.ts 给)。
* 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。 * 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。
*/ */
const props = defineProps<{ const props = defineProps<{
@@ -34,34 +35,50 @@ onUnmounted(clearErrorMark)
</script> </script>
<template> <template>
<n-card embedded class="explain-card"> <!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明,再把那一行代码摆出来 -->
<n-flex vertical :size="12"> <div class="explain-section">
<div class="explain"> <SectionHead icon="problem" tone="amber">{{ info.line ? `第 ${info.line} 行出错` : '程序跑到一半出错了' }}</SectionHead>
<b v-if="info.line">第 {{ info.line }} 行运行时出错:</b>{{ message }} <p class="explain">{{ message }}</p>
</div> <div v-if="sourceLine" class="explain-code">
<pre v-if="sourceLine" class="explain-code">{{ sourceLine }}</pre> <span class="line-no">{{ info.line }}</span>
</n-flex> <pre>{{ sourceLine }}</pre>
</n-card> </div>
</div>
</template> </template>
<style scoped> <style scoped>
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
.explain-card {
max-width: 560px;
}
.explain { .explain {
margin: 0 0 10px;
font-size: 16px; font-size: 16px;
line-height: 1.7; line-height: 1.75;
} }
/* 出错的那一行:红底,前面写行号,和编辑器里标红的那一行对得上 */
.explain-code { .explain-code {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 5px 10px;
border-radius: 6px;
background-color: rgba(208, 48, 80, 0.06);
border: 1px solid rgba(208, 48, 80, 0.24);
font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
}
.line-no {
flex: none;
font-weight: 700;
color: #d03050;
}
.explain-code pre {
flex: 1 1 auto;
min-width: 0;
margin: 0; margin: 0;
padding: 8px 12px; font: inherit;
border-radius: 4px;
font-size: 15px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
background-color: rgba(208, 48, 80, 0.12);
} }
</style> </style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { copyToClipboard } from 'utils/functions'
import { SubmissionStatus } from 'utils/constants'
import { useCodeStore } from 'oj/store/code'
import { useSubmissionStore } from 'oj/store/submission'
/**
* 题面的例子:每组一行「输入 → 输出」,输出浅绿底(设计稿「题目页观感」ExCard,2026-11-04 定)。
*
* - 输入写的是「无」或者空的(常用题里 12% 的例子)写灰字「不用输入」,不给复制:
* 原来摆成一格可复制的「无」,学生以为要输入一个「无」。
* - 行首的空格画成浅灰点:菱形、三角形那几道靠行首空格对齐,数不清空格就照着排错了。
* 只是画成点,复制出去的还是空格。
* - 半格放不下最长那行,这道题的例子整体改成上下排(输入、↓、输出)。整宽还放不下的那一行
* 才折行,**折下来的部分缩进两格**:原来不折、横着滚,就是怕学生分不清折行和真换行、
* 照着多输出一个换行;缩进之后两者一眼能分开,又不用去找那根细滚动条。
* - 「运行例子」跑过之后,序号上标 ✓ / ✗(还是会跳到「结果」页签,切回来就看得到)。
* 代码改过就不再标:标的是上一份代码的对错,留着会让学生以为改完的还对。
*/
const { samples } = defineProps<{
samples: { input: string; output: string }[]
}>()
const SIDES = ['input', 'output'] as const
const SIDE_LABEL = { input: '输入', output: '输出' } as const
/** 输入写「无」或者空着:这道题不用输入 */
function noInput(text: string) {
const trimmed = text.trim()
return trimmed === '' || trimmed === '无'
}
/** 每一行拆成「行首空格数 + 余下的字」,模板里把行首空格画成点 */
function linesOf(text: string) {
return text.split('\n').map((line) => {
const lead = /^ +/.exec(line)?.[0].length ?? 0
return { lead, rest: line.slice(lead) }
})
}
// ==================== 摆成一行还是上下排 ====================
// 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数
const FONT_SIZE = 16
const FONT = `${FONT_SIZE}px Consolas, Monaco, monospace`
const DOT_W = 26
const ARROW_W = 22
const GAP = 6
/** 格子里除了字以外占掉的:左右内边距、复制按钮、按钮前的间距、边框 */
const PANE_CHROME = 10 + 4 + 26 + 4 + 2
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const widest = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = FONT
let max = 0
for (const sample of samples) {
for (const side of SIDES) {
if (side === 'input' && noInput(sample.input)) continue
for (const line of sample[side].split('\n')) {
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > max) max = width
}
}
}
return max
})
const list = useTemplateRef<HTMLElement>('list')
const { width: listWidth } = useElementSize(list)
const halfText = computed(() => (listWidth.value - DOT_W - ARROW_W - GAP * 3) / 2 - PANE_CHROME)
const stacked = computed(() => listWidth.value > 0 && widest.value > halfText.value)
// ==================== 复制 ====================
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, { immediate: false })
async function copy(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// ==================== 运行例子的 ✓ / ✗ ====================
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const marks = computed<Record<number, 'ok' | 'bad'>>(() => {
const trial = submissionStore.trial
const ran = trial.samplesCode.value
if (trial.samplesRunning.value || !ran) return {}
if (ran.value !== codeStore.code.value || ran.language !== codeStore.code.language) return {}
const out: Record<number, 'ok' | 'bad'> = {}
for (const run of trial.sampleRuns.value) {
out[run.index] = run.result === SubmissionStatus.accepted ? 'ok' : 'bad'
}
return out
})
function markLabel(index: number) {
const mark = marks.value[index]
if (mark === 'ok') return `例子 ${index + 1},运行结果对了`
if (mark === 'bad') return `例子 ${index + 1},运行结果不对`
return `例子 ${index + 1}`
}
const css = {
font: FONT,
dotW: `${DOT_W}px`,
arrowW: `${ARROW_W}px`,
gap: `${GAP}px`,
}
</script>
<template>
<div ref="list" class="sample-list" :class="{ stacked }" role="list" aria-label="例子">
<div v-if="!stacked" class="cols" aria-hidden="true">
<span />
<span>输入</span>
<span />
<span>输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="pair" role="listitem">
<span class="dot" :class="marks[index]" :aria-label="markLabel(index)">
<Icon v-if="marks[index] === 'ok'" icon="ph:check-bold" :width="13" />
<Icon v-else-if="marks[index] === 'bad'" icon="ph:x-bold" :width="12" />
<template v-else>{{ index + 1 }}</template>
</span>
<div class="sides">
<template v-for="side in SIDES" :key="side">
<span v-if="side === 'output'" class="arrow" aria-hidden="true">
<Icon :icon="stacked ? 'ph:arrow-down' : 'ph:arrow-right'" :width="16" />
</span>
<div v-if="side === 'input' && noInput(sample.input)" class="pane in none">不用输入</div>
<div v-else class="pane" :class="side === 'input' ? 'in' : 'out'">
<pre class="text" :aria-label="`例子 ${index + 1} 的${SIDE_LABEL[side]}`"><span
v-for="(line, i) of linesOf(sample[side])"
:key="i"
class="line"
><span v-if="line.lead" class="lead">{{ '·'.repeat(line.lead) }}</span>{{ line.rest }}</span
></pre>
<button type="button" class="copy" :aria-label="`复制例子 ${index + 1} 的${SIDE_LABEL[side]}`" @click="copy(`${index}-${side}`, sample[side])">
<Icon :icon="copiedKey === `${index}-${side}` ? 'ph:check' : 'ph:copy'" :width="15" />
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.sample-list {
display: flex;
flex-direction: column;
gap: 8px;
--pane-in-bg: rgba(128, 128, 128, 0.07);
--pane-in-border: rgba(128, 128, 128, 0.14);
--pane-out-bg: rgba(24, 160, 88, 0.08);
--pane-out-border: rgba(24, 160, 88, 0.26);
--lead: rgba(24, 160, 88, 0.45);
--muted: rgba(128, 128, 128, 0.75);
}
:global(html.dark) .sample-list {
--pane-out-bg: rgba(99, 226, 183, 0.08);
--pane-out-border: rgba(99, 226, 183, 0.26);
--lead: rgba(99, 226, 183, 0.45);
}
.sample-list.stacked {
gap: 12px;
}
.cols,
.pair {
display: grid;
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
}
.cols {
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
margin-bottom: -4px;
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
/* 一行:输入 → 输出;上下排:输入、↓、输出 */
.sides {
display: grid;
grid-template-columns: minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
align-items: stretch;
min-width: 0;
}
.stacked .sides {
grid-template-columns: minmax(0, 1fr);
}
.dot {
width: 22px;
height: 22px;
margin-top: 7px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
background-color: rgba(128, 128, 128, 0.14);
color: var(--muted);
}
.dot.ok {
background-color: #18a058;
color: #fff;
}
.dot.bad {
background-color: #d03050;
color: #fff;
}
.arrow {
display: flex;
justify-content: center;
padding-top: 10px;
color: var(--muted);
}
.stacked .arrow {
justify-content: flex-start;
padding: 2px 0 2px 14px;
}
.pane {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 5px 4px 5px 10px;
border-radius: 6px;
}
.pane.in {
background-color: var(--pane-in-bg);
border: 1px solid var(--pane-in-border);
}
.pane.out {
background-color: var(--pane-out-bg);
border: 1px solid var(--pane-out-border);
}
.pane.none {
align-self: start;
background-color: transparent;
border-style: dashed;
padding-right: 10px;
font-size: 14px;
line-height: 24px;
color: var(--muted);
}
.text {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font: v-bind('css.font');
line-height: 24px;
}
/* 一行一块:整宽还放不下才折,折下来的缩进两格,和真换行分得开 */
.line {
display: block;
min-height: 24px;
padding-left: 2ch;
text-indent: -2ch;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.lead {
color: var(--lead);
}
.copy {
flex: none;
width: 26px;
height: 24px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.copy:hover {
background-color: rgba(128, 128, 128, 0.14);
}
</style>
@@ -103,22 +103,14 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
</ResultHeader> </ResultHeader>
<template v-if="current"> <template v-if="current">
<!-- 对上了:三栏摆出来就行 --> <!-- 对上了:输入、正确输出、你的输出竖着摆出来就行,你的输出也是绿的 -->
<div v-if="current.result === SubmissionStatus.accepted" class="passed-card"> <div v-if="current.result === SubmissionStatus.accepted" class="passed-card">
<div class="blocks"> <span class="label">输入</span>
<div class="block"> <pre class="pane in">{{ current.input }}</pre>
<div class="label">输入</div> <span class="label">正确输出</span>
<pre>{{ current.input }}</pre> <pre class="pane ok">{{ current.expected }}</pre>
</div> <span class="label">你的输出</span>
<div class="block"> <pre class="pane ok">{{ current.output }}</pre>
<div class="label">正确输出</div>
<pre>{{ current.expected }}</pre>
</div>
<div class="block">
<div class="label">你的输出</div>
<pre>{{ current.output }}</pre>
</div>
</div>
</div> </div>
<!-- 跑完了、输出对不上:和判完之后同一套说明 --> <!-- 跑完了、输出对不上:和判完之后同一套说明 -->
<WrongAnswerExplain <WrongAnswerExplain
@@ -134,6 +126,7 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
:code="samplesCode?.value ?? ''" :code="samplesCode?.value ?? ''"
:language="samplesCode?.language ?? ''" :language="samplesCode?.language ?? ''"
:plain="inContest" :plain="inContest"
section="stack"
/> />
<!-- 没跑完:编译不过、运行出错、超时 --> <!-- 没跑完:编译不过、运行出错、超时 -->
<TrialErrorNote <TrialErrorNote
@@ -165,15 +158,16 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
} }
.run { .run {
height: 26px; height: 28px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 10px; padding: 0 10px;
border-radius: 13px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@@ -189,56 +183,58 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
color: v-bind('theme.errorColorPressed'); color: v-bind('theme.errorColorPressed');
} }
.run.active { /* 选中的那个涂实心:设计稿「运行例子」 */
font-weight: 600;
border-width: 1.5px;
}
.run.ok.active { .run.ok.active {
border-color: v-bind('theme.successColor'); border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: #fff;
} }
.run.bad.active { .run.bad.active {
border-color: v-bind('theme.errorColor'); border-color: v-bind('theme.errorColor');
background-color: v-bind('theme.errorColor');
color: #fff;
} }
/* 对上了:和答错时同一张两列的网格(标签 | 格子),只是你的输出也是绿的 */
.passed-card { .passed-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
}
.blocks {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: 64px minmax(0, 1fr);
gap: 8px; gap: 6px 8px;
} align-items: start;
.block {
min-width: 0;
padding: 6px 10px 8px;
border-radius: 5px;
background-color: v-bind('theme.cardColor');
border: 1px solid v-bind('theme.dividerColor');
} }
.label { .label {
font-size: 12px; font-size: 13px;
opacity: 0.7; line-height: 36px;
margin-bottom: 4px; white-space: nowrap;
color: v-bind('theme.textColor3');
} }
.block pre { .pane {
min-width: 0;
margin: 0; margin: 0;
padding: 5px 10px;
border-radius: 6px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: Monaco, Consolas, monospace; font-family: Consolas, Monaco, monospace;
font-size: 14px; font-size: 16px;
line-height: 24px;
}
.pane.in {
background-color: rgba(128, 128, 128, 0.07);
border: 1px solid rgba(128, 128, 128, 0.14);
}
.pane.ok {
background-color: rgba(24, 160, 88, 0.08);
border: 1px solid rgba(24, 160, 88, 0.26);
} }
.note { .note {
margin: 12px 0 0; margin: 16px 0 0;
font-size: 13px; font-size: 13px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
/**
* 题面的小节标题:线条图标 + 绿字 + 一根细线拖到头,线尾可以挂一个链接
* (「自己输入数据试试 ›」「放大看 ›」)。设计稿「题目页观感」⑦,2026-11-04 定。
*
* 语法要求、提示用黄色:一个是硬性条件、一个是帮忙的话,扫一眼要能和别的节分开。
* 结果页签里「例子 1 没对上」「第 2 行出错」这类说哪里不对的也用黄色(设计稿结果 X)。
* 明暗两套色都在 CSS 里(html.dark),别在这里调 useDark() —— 理由见 ProblemTypeTag。
*/
export type SectionIcon =
| 'description'
| 'input'
| 'output'
| 'samples'
| 'hint'
| 'rules'
| 'table'
| 'expected'
| 'flowchart'
| 'similar'
| 'problem'
| 'next'
| 'challenge'
const ICON: Record<SectionIcon, string> = {
description: 'ph:book-open',
input: 'ph:sign-in',
output: 'ph:sign-out',
samples: 'ph:flask',
hint: 'ph:lightbulb',
rules: 'ph:code',
table: 'ph:table',
expected: 'ph:check-circle',
flowchart: 'ph:flow-arrow',
similar: 'ph:shuffle-angular',
// 结果页签:哪里不对、接着做、还能挑战
problem: 'ph:warning-circle',
next: 'ph:arrow-right',
challenge: 'ph:target',
}
const { icon, tone = 'green' } = defineProps<{
icon: SectionIcon
tone?: 'green' | 'amber'
}>()
</script>
<template>
<div class="section-head" :class="tone">
<h3 class="section-title">
<Icon :icon="ICON[icon]" :width="19" class="section-icon" aria-hidden="true" />
<slot />
</h3>
<span class="section-line" aria-hidden="true" />
<span v-if="$slots.extra" class="section-extra"><slot name="extra" /></span>
</div>
</template>
<style scoped>
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
--head-text: #0c6b3b;
--head-icon: #18a058;
--head-line: rgba(24, 160, 88, 0.22);
}
.section-head.amber {
--head-text: #8a5a00;
--head-icon: #c98a00;
--head-line: rgba(201, 138, 0, 0.25);
}
:global(html.dark) .section-head {
--head-text: #63e2b7;
--head-icon: #63e2b7;
--head-line: rgba(99, 226, 183, 0.22);
}
:global(html.dark) .section-head.amber {
--head-text: #f2c97d;
--head-icon: #f2c97d;
--head-line: rgba(242, 201, 125, 0.25);
}
.section-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 2px;
white-space: nowrap;
color: var(--head-text);
}
.section-icon {
flex: none;
color: var(--head-icon);
}
.section-line {
flex: 1 1 auto;
min-width: 12px;
height: 1px;
background-color: var(--head-line);
}
.section-extra {
flex: none;
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 14px;
white-space: nowrap;
}
</style>
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { ProblemRow } from 'utils/types' import type { ProblemRow } from 'utils/types'
type SimilarRow = ProblemRow & { sharedTags: string[] }
/** /**
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了 * 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
* 每个实例各一份 * 每个实例各一份
*/ */
const cache = new Map<string, Promise<ProblemRow[]>>() const cache = new Map<string, Promise<SimilarRow[]>>()
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
@@ -13,17 +15,28 @@ import { storeToRefs } from 'pinia'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { getSimilarProblems } from 'oj/api' import { getSimilarProblems } from 'oj/api'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { useTone } from 'shared/composables/tone'
import { problemTypes } from '../utils/problemType'
import ProblemStatus from './ProblemStatus.vue'
import ProblemTypeTag from './ProblemTypeTag.vue'
import SectionHead from './SectionHead.vue'
/** /**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后) * 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。 * 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里用题面的小节标题(SectionHead),结果页签里和「还能挑战」对齐。
*
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)。整行可点,题目名
* 和题目列表一样用正文色、不用主题绿:绿字细、在白底上发虚
*/ */
const props = defineProps<{ title: string }>() const props = defineProps<{ title: string; place: 'content' | 'result' }>()
const theme = useThemeVars() const theme = useThemeVars()
const tone = useTone()
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
const list = ref<ProblemRow[]>([]) const list = ref<SimilarRow[]>([])
watch( watch(
() => problem.value?._id, () => problem.value?._id,
@@ -41,30 +54,37 @@ watch(
{ immediate: true }, { immediate: true },
) )
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High // 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
function difficultyType(difficulty: string) { const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
if (difficulty === '简单') return 'success'
if (difficulty === '困难') return 'error' const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
return 'warning'
function difficultyStyle(row: SimilarRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
} }
</script> </script>
<template> <template>
<section v-if="list.length" class="similar"> <section v-if="list.length" class="similar" :class="props.place">
<h3 class="title">{{ props.title }}</h3> <SectionHead v-if="props.place === 'content'" icon="similar">{{ props.title }}</SectionHead>
<n-list bordered> <h3 v-else class="heading">{{ props.title }}</h3>
<n-list-item v-for="sp in list" :key="sp._id"> <div class="rows">
<n-flex align="center" justify="space-between" :wrap="false"> <router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link"> <span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag> <span class="id">{{ sp._id }}</span>
<span class="name">{{ sp.title }}</span> <span class="name">
</router-link> <span class="text">{{ sp.title }}</span>
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)"> <ProblemTypeTag v-for="kind in problemTypes(sp)" :key="kind" :kind="kind" />
{{ sp.difficulty }} </span>
</n-tag> <span v-if="sp.difficulty" class="difficulty" :style="difficultyStyle(sp)">{{ sp.difficulty }}</span>
</n-flex> <span class="shared">{{ sp.sharedTags.join(' · ') }}</span>
</n-list-item> <span class="people"
</n-list> ><b>{{ sp.solvedUsers }}</b> 人做对</span
>
</router-link>
</div>
</section> </section>
</template> </template>
@@ -73,34 +93,126 @@ function difficultyType(difficulty: string) {
margin-top: 20px; margin-top: 20px;
} }
.title { .similar.result {
font-size: 16px; margin-top: 14px;
font-weight: 600; }
.heading {
margin: 0 0 8px; margin: 0 0 8px;
} }
/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */ /* 和结果页签里的「还能挑战」一样 */
.similar :deep(.n-list-item__main) { .result .heading {
min-width: 0; font-size: 15px;
font-weight: 700;
} }
.link { .rows {
display: inline-flex; border: 1px solid v-bind('theme.dividerColor');
border-radius: 6px;
overflow: hidden;
}
/*
* 两行:题号 | 题目名 + 类型标签 | 难度
* | 共同的标签 | 做对人数
* 做过没对的题最前面多一列红「−」
*/
.row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
'id name difficulty'
'. shared people';
align-items: center; align-items: center;
gap: 8px; gap: 4px 12px;
min-width: 0; padding: 10px 16px;
color: inherit; color: v-bind('theme.textColor1');
text-decoration: none; text-decoration: none;
} }
.link:hover .name { .row.with-status {
grid-template-columns: 16px auto minmax(0, 1fr) auto;
grid-template-areas:
'status id name difficulty'
'. . shared people';
}
.row + .row {
border-top: 1px solid v-bind('theme.dividerColor');
}
.row:hover {
background: v-bind('theme.hoverColor');
}
.row:hover .text {
text-decoration: underline; text-decoration: underline;
} }
.status {
grid-area: status;
display: flex;
justify-content: center;
}
.id {
grid-area: id;
min-width: 36px;
font-size: var(--oj-fs-body);
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
}
.name { .name {
color: v-bind('theme.primaryColor'); grid-area: name;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.text {
min-width: 0;
font-size: var(--oj-fs-body);
font-weight: 500;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.difficulty {
grid-area: difficulty;
justify-self: end;
height: 22px;
padding: 0 7px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
display: inline-flex;
align-items: center;
}
.shared {
grid-area: shared;
min-width: 0;
font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor2');
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.people {
grid-area: people;
justify-self: end;
font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor2');
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.people b {
font-weight: 600;
color: v-bind('theme.textColor1');
}
</style> </style>
@@ -33,8 +33,6 @@ const changedTables = computed(() => {
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */ /** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length) const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length)
const anyChanged = computed(() => changedTables.value.some(({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0)))
</script> </script>
<template> <template>
@@ -62,7 +60,6 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
</template> </template>
</div> </div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p> <p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend"><span class="swatch" aria-hidden="true"></span>执行后变了的格子</p>
</template> </template>
<style scoped> <style scoped>
@@ -77,22 +74,4 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
opacity: 0.65; opacity: 0.65;
margin: 0 0 8px; margin: 0 0 8px;
} }
.legend {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 12px;
height: 12px;
border: 1px solid #f0d48a;
background-color: #fff4d6;
}
html.dark .swatch {
border-color: rgba(240, 180, 40, 0.45);
background-color: rgba(240, 180, 40, 0.18);
}
</style> </style>
@@ -12,11 +12,11 @@ import { useProblemPageContext } from '../composables/problemPageContext'
import { caseRevealDuration } from '../utils/caseReveal' import { caseRevealDuration } from '../utils/caseReveal'
/** /**
* 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回题单。 * 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回专题。
* *
* 挂在题目页这一层(detail.vue),和编辑器、提交按钮都无关 —— 原来这些写在提交按钮 * 挂在题目页这一层(detail.vue),和编辑器、提交按钮都无关 —— 原来这些写在提交按钮
* 那个组件(SubmitCode)里,手机上切一下页签、桌面切一下分栏模式,编辑器一卸载它们 * 那个组件(SubmitCode)里,手机上切一下页签、桌面切一下分栏模式,编辑器一卸载它们
* 就跟着没了:判完不放烟花、不计失败次数、不弹点评、题单里不回题单。 * 就跟着没了:判完不放烟花、不计失败次数、不弹点评、专题里不回专题。
* 状态本身在 submission store,这里只是看着它做事。 * 状态本身在 submission store,这里只是看着它做事。
*/ */
@@ -167,7 +167,7 @@ const { start: goToProblemSetDelayed, stop: cancelGoToProblemSet } = useTimeoutF
) )
/** /**
* 换题时收掉通过之后等着的点评和回题单 —— 评价弹窗是按**当前**题目去查、去弹的, * 换题时收掉通过之后等着的点评和回专题 —— 评价弹窗是按**当前**题目去查、去弹的,
* 不取消的话就会给一道还没做的题弹评价。欠着的点评走不到这里:离开这道题的导航 * 不取消的话就会给一道还没做的题弹评价。欠着的点评走不到这里:离开这道题的导航
* 已经被 holdForReview 拦下、评完才放行;还能走到这里的只有弹窗开着时按了浏览器后退。 * 已经被 holdForReview 拦下、评完才放行;还能走到这里的只有弹窗开着时按了浏览器后退。
* 结果面板、还在跟的那条提交由 submission store 自己收。 * 结果面板、还在跟的那条提交由 submission store 自己收。
@@ -221,14 +221,14 @@ watch(
// 1. 刷新题目状态 // 1. 刷新题目状态
problem.value!.myStatus = 0 problem.value!.myStatus = 0
// 题单进度不在这里更新了。以前是 AC 之后回调 PUT /problem-set-progress,只认路由 // 专题进度不在这里更新了。以前是 AC 之后回调 PUT /problem-set-progress,只认路由
// 参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖、页面提前关掉进度 // 参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖、页面提前关掉进度
// 就静默丢失。现在判题那一路直接记账(judge/run.ts),而且是记进所有已加入且包含 // 就静默丢失。现在判题那一路直接记账(judge/run.ts),而且是记进所有已加入且包含
// 这道题的题单;收到「判完了」的时候进度已经落库,跳回题单页看到的就是新数据。 // 这道题的专题;收到「判完了」的时候进度已经落库,跳回专题页看到的就是新数据。
if (result !== SubmissionStatus.accepted) return if (result !== SubmissionStatus.accepted) return
// 3. 烟花放一会儿弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉 // 3. 烟花放一会儿弹出评价框。比赛里不打扰;专题里 1.5 秒后要跳回专题页,弹了也会被冲掉
// (评价是欠着的这件事马上记下:方块还在亮的时候离开这道题,也要先评) // (评价是欠着的这件事马上记下:方块还在亮的时候离开这道题,也要先评)
if (ctx.value.reviewAfterAccepted) reviewOwed.value = true if (ctx.value.reviewAfterAccepted) reviewOwed.value = true
@@ -241,15 +241,15 @@ watch(
}) })
if (ctx.value.backToProblemSet) { if (ctx.value.backToProblemSet) {
// 延迟回到题单页面 // 延迟回到专题页面
goToProblemSetDelayed() goToProblemSetDelayed()
} }
}, },
) )
// ==================== 流程图画到 A / S ==================== // ==================== 流程图画到 A / S ====================
// 题单里流程图作业拿到 A 或 S 就算这道题做完(后端评分那一路已经记了账), // 专题里流程图作业拿到 A 或 S 就算这道题做完(后端评分那一路已经记了账),
// 和代码通过一样 1.5 秒后回题单(设计文档第 3 节决定 4、5)。只认这一页上刚评完的那一次 // 和代码通过一样 1.5 秒后回专题(设计文档第 3 节决定 4、5)。只认这一页上刚评完的那一次
const flowchartStore = useFlowchartStore() const flowchartStore = useFlowchartStore()
watch( watch(
() => flowchartStore.evaluatedSeq, () => flowchartStore.evaluatedSeq,
@@ -16,6 +16,7 @@ import RuntimeErrorExplain from './RuntimeErrorExplain.vue'
import WrongAnswerExplain from './WrongAnswerExplain.vue' import WrongAnswerExplain from './WrongAnswerExplain.vue'
import LessonNext from './LessonNext.vue' import LessonNext from './LessonNext.vue'
import ResultHeader from './ResultHeader.vue' import ResultHeader from './ResultHeader.vue'
import SectionHead from './SectionHead.vue'
import SimilarProblems from './SimilarProblems.vue' import SimilarProblems from './SimilarProblems.vue'
import ChallengeList from './ChallengeList.vue' import ChallengeList from './ChallengeList.vue'
import ChallengeOutcome from './ChallengeOutcome.vue' import ChallengeOutcome from './ChallengeOutcome.vue'
@@ -80,8 +81,8 @@ const sampleCheck = computed(() => {
}) })
/** /**
* 通过之后给「这节课的下一题」。比赛有自己的题目列表;从题单入口进来的,通过后 * 通过之后给「这节课的下一题」。比赛有自己的题目列表;从专题入口进来的,通过后
* 1.5 秒会自动跳回题单(SubmissionEffects),这两种都不给 * 1.5 秒会自动跳回专题(SubmissionEffects),这两种都不给
*/ */
const ctx = useProblemPageContext() const ctx = useProblemPageContext()
const showLessonNext = computed(() => !props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext) const showLessonNext = computed(() => !props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext)
@@ -357,26 +358,34 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
:language="submission.language" :language="submission.language"
:refresh-key="submission.id" :refresh-key="submission.id"
/> />
<SimilarProblems v-if="showSimilar" title="再练几道相似的" /> <SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
</div> </div>
</template> </template>
<div v-show="!waitingReveal"> <div v-show="!waitingReveal">
<n-flex vertical v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"> <n-flex
vertical
:size="20"
v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
>
<CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" /> <CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" />
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" /> <RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
<WrongAnswerExplain v-if="sampleCheck" full :check="sampleCheck" :code="submission.code" :language="submission.language" /> <WrongAnswerExplain v-if="sampleCheck" full section="stack" :check="sampleCheck" :code="submission.code" :language="submission.language" />
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded> <!-- 语法要求逐条对没对:和题面的「语法要求」同一个黄色小节标题 -->
<n-flex vertical :size="8"> <section v-if="submission.statisticInfo?.ast_results?.length">
<n-flex v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i" align="center" :size="6"> <SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor"> <ul class="ast-rules">
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" /> <li v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i">
</n-icon> <Icon
:icon="rule.passed ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
:width="17"
:color="rule.passed ? theme.successColor : theme.errorColor"
/>
<span>{{ rule.description }}</span> <span>{{ rule.description }}</span>
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 --> <!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
<span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span> <span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span>
</n-flex> </li>
</n-flex> </ul>
</n-card> </section>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card> <n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" /> <n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
</n-flex> </n-flex>
@@ -386,7 +395,10 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
问过之后,AI 的回答才放进卡片 问过之后,AI 的回答才放进卡片
--> -->
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions"> <div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
<n-button v-if="showAIHint && !hintTarget" type="primary" ghost :loading="hintLoading" @click="fetchHint(submission.id)"> 让 AI 分析我的代码 </n-button> <n-button v-if="showAIHint && !hintTarget" type="primary" secondary :loading="hintLoading" @click="fetchHint(submission.id)">
<template #icon><Icon icon="ph:sparkle-fill" /></template>
让 AI 分析我的代码
</n-button>
<n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')"> <n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')">
{{ customRunHint }} {{ customRunHint }}
</n-button> </n-button>
@@ -425,7 +437,24 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px 16px; gap: 8px 16px;
margin-top: 12px; margin-top: 16px;
}
.ast-rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.ast-rules li {
display: flex;
align-items: center;
gap: 8px;
} }
/* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */ /* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */
@@ -6,6 +6,7 @@ import { useProblemStore } from 'oj/store/problem'
import { markdownToText } from 'oj/problem/utils/plainText' import { markdownToText } from 'oj/problem/utils/plainText'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants' import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer' import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer'
import SectionHead from './SectionHead.vue'
/** /**
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。 * 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
@@ -31,6 +32,15 @@ const props = defineProps<{
* 提示句全是对学生说的第二人称,整句换掉人称就读得通 * 提示句全是对学生说的第二人称,整句换掉人称就读得通
*/ */
peer?: boolean peer?: boolean
/**
* 新的样子(设计稿「题目页观感」结果 X):黄色小节标题说哪里不对,说明用正文大小,
* 输入 / 正确输出 / 你的输出是灰、绿、红三格。
* - stack:三格竖着排,和题面的例子一个样子。题目页的结果页签用(左栏只有 500 多宽)
* - row:三格横着并排,正确输出和你的输出一行对一行,不一样的那行两边都垫一层浅黄。
* 提交详情页用(一栏 1200 宽,多行输出对照着看);放不下(窄于 600,比如手机)就退回竖排
* 不给的话是原来那张浅灰卡片
*/
section?: 'stack' | 'row'
}>() }>()
const theme = useThemeVars() const theme = useThemeVars()
@@ -114,39 +124,48 @@ function lines(text: string) {
</script> </script>
<template> <template>
<div class="explain-card"> <div :class="section ? ['explain-section', section] : 'explain-card'">
<p v-if="check.passed" class="explain">题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p> <template v-if="section">
<template v-else> <SectionHead icon="problem" tone="amber">
<p class="explain"> {{ check.passed ? '例子都对了,错在没给你看的测试点' : `例子 ${(check.index ?? 0) + 1} 没对上` }}
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b </SectionHead>
><template v-if="!plain">{{ hint }}</template> <p v-if="check.passed" class="explain">想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p>
</p> <p v-else-if="!plain" class="explain">{{ hint }}</p>
</template>
<p v-else-if="check.passed" class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</p>
<p v-else class="explain">
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b
><template v-if="!plain">{{ hint }}</template>
</p>
<template v-if="!check.passed">
<div class="blocks"> <div class="blocks">
<div class="block"> <div class="block in">
<div class="label">输入</div> <div class="label">输入</div>
<div class="text"> <div class="text">
<div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" /> <div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" />
</div> </div>
</div> </div>
<div class="block"> <div class="block ok">
<div class="label">正确输出</div> <div class="label">正确输出</div>
<div class="text"> <div class="text">
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i"> <template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
<div v-if="i === diffLine && diffParts?.expected" class="line"> <div v-if="i === diffLine && diffParts?.expected" class="line hit">
<span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" /> <span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" />
</div> </div>
<div v-else class="line" v-text="line" /> <div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template> </template>
</div> </div>
</div> </div>
<div class="block"> <div class="block bad">
<div class="label">{{ peer ? '学生' : '你' }}的输出</div> <div class="label">{{ peer ? '学生' : '你' }}的输出</div>
<div v-if="check.output" class="text"> <div v-if="check.output" class="text">
<template v-for="(line, i) in lines(check.output)" :key="i"> <template v-for="(line, i) in lines(check.output)" :key="i">
<div v-if="i === diffLine && diffParts?.output" class="line"> <div v-if="i === diffLine && diffParts?.output" class="line hit">
<span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" /> <span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" />
</div> </div>
<div v-else class="line" v-text="line" /> <div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template> </template>
</div> </div>
<div v-else class="text empty">(什么都没有输出)</div> <div v-else class="text empty">(什么都没有输出)</div>
@@ -241,4 +260,145 @@ function lines(text: string) {
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
line-height: 1.6; line-height: 1.6;
} }
/*
* 题目页结果页签(section):标签一列、格子一列,输入灰、正确输出绿、你的输出红。
* .block 用 display: contents 把「标签 + 格子」直接摊进这张两列的网格
*/
.explain-section {
display: flex;
flex-direction: column;
}
.explain-section .explain {
margin: 0 0 10px;
font-size: 16px;
line-height: 1.75;
}
.explain-section .blocks {
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
align-items: start;
}
.explain-section .block {
display: contents;
}
.explain-section .label {
margin: 0;
font-size: 13px;
line-height: 36px;
white-space: nowrap;
}
.explain-section .bad .label {
font-weight: 600;
color: v-bind('theme.errorColor');
}
.explain-section .text {
min-width: 0;
padding: 5px 10px;
border-radius: 6px;
font-size: 16px;
line-height: 24px;
max-height: 260px;
}
.explain-section .text.empty {
font-size: 14px;
}
.explain-section .in .text {
background-color: rgba(128, 128, 128, 0.07);
border: 1px solid rgba(128, 128, 128, 0.14);
}
.explain-section .ok .text {
background-color: rgba(24, 160, 88, 0.08);
border: 1px solid rgba(24, 160, 88, 0.26);
}
.explain-section .bad .text {
background-color: rgba(208, 48, 80, 0.06);
border: 1px solid rgba(208, 48, 80, 0.24);
}
.explain-section .line {
min-height: 24px;
}
.explain-section .cut {
grid-column: 2;
}
.explain-section .requirement {
margin-top: 10px;
font-size: 14px;
color: v-bind('theme.textColor3');
}
/*
* 横排(row,提交详情页):三格并排,标签在格子上面;行的左右留白挪到每一行上,
* 不一样的那行垫的浅黄才能铺满整格宽、两边对齐
*/
.explain-section.row {
container: explain-row / inline-size;
}
.explain-section.row .blocks {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.explain-section.row .block {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
}
.explain-section.row .label {
line-height: 1.5;
}
.explain-section.row .text {
padding: 6px 0;
line-height: 26px;
}
.explain-section.row .line {
min-height: 26px;
padding: 0 12px;
}
.explain-section.row .text.empty {
padding: 6px 12px;
}
.explain-section.row .line.hit {
background-color: rgba(227, 160, 8, 0.14);
}
/* 放不下三格(窄于 600:手机、课堂任务的弹窗)就退回竖排 */
@container explain-row (max-width: 600px) {
.explain-section.row .blocks {
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
}
.explain-section.row .block {
display: contents;
}
.explain-section.row .label {
line-height: 36px;
}
}
</style> </style>
@@ -1,5 +1,5 @@
/** /**
* 挑战的颜色:紫。课堂条是绿、题单条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。 * 挑战的颜色:紫。课堂条是绿、专题条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。
* Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清 * Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清
*/ */
export function useChallengeTone() { export function useChallengeTone() {
@@ -6,8 +6,8 @@ import { useProblemStore } from 'oj/store/problem'
* (删草稿)都用这一份 —— 原来键在编辑器里拼、再作为 prop 传给工具栏,手机上「重置」 * (删草稿)都用这一份 —— 原来键在编辑器里拼、再作为 prop 传给工具栏,手机上「重置」
* 挪进页签行的「⋯」之后就够不着了,再拼一份迟早和编辑器那份对不上。 * 挪进页签行的「⋯」之后就够不着了,再拼一份迟早和编辑器那份对不上。
* *
* 题单入口单独一份:原来题库和题单共用 `problem_题号_contest_null_语言`,加入题单之前 * 专题入口单独一份:原来题库和专题共用 `problem_题号_contest_null_语言`,加入专题之前
* 在题库里写对的代码,一进题单就摆在编辑器里(设计文档第 11 节 4)。 * 在题库里写对的代码,一进专题就摆在编辑器里(设计文档第 11 节 4)。
* **键的最后一段必须是语言**,编辑器换语言时靠它判断草稿是不是这门语言的。 * **键的最后一段必须是语言**,编辑器换语言时靠它判断草稿是不是这门语言的。
*/ */
export function useDraftKey() { export function useDraftKey() {
@@ -42,7 +42,7 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
const options = computed<DropdownOption[]>(() => { const options = computed<DropdownOption[]>(() => {
const items: DropdownOption[] = [] const items: DropdownOption[] = []
// 「本题提交」挪到了「我的提交」抽屉的底部;题单入口没有抽屉,给管理员角色留在这里 // 「本题提交」挪到了「我的提交」抽屉的底部;专题入口没有抽屉,给管理员角色留在这里
if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) { if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) {
items.push({ label: '本题提交', key: 'allSubmissions' }) items.push({ label: '本题提交', key: 'allSubmissions' })
} }
@@ -0,0 +1,273 @@
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { HINT_MIN_FAILURES, isFlowchartPass } from '@oj2/contract'
import { habitSituation, logMascotEvent, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
import { useChallengeStore } from 'oj/store/challenge'
import { useFlowchartStore } from 'oj/store/flowchart'
import type { MascotSituation } from 'shared/mascot/lines'
import { useCollabStore } from 'shared/store/collab'
import { useLessonStore } from 'shared/store/lesson'
import { useUserStore } from 'shared/store/user'
import { useCodeStore } from 'oj/store/code'
import { useProblemStore } from 'oj/store/problem'
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
import { SubmissionStatus } from 'utils/constants'
import type { ProblemDetail } from 'utils/types'
import { useProblemPageContext } from './problemPageContext'
import { useTeacherCollab } from './teacherCollab'
/** 同一道题错到这么多次,她就不怼了,劝去举手 */
const STUCK_FAILS = 5
/** 错过这么多次以后做对,算「终于」 */
const FINALLY_FAILS = 2
const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
[SubmissionStatus.wrong_answer]: 'problem.wrong',
[SubmissionStatus.partial_accepted]: 'problem.wrong',
[SubmissionStatus.ast_check_failed]: 'problem.wrong',
[SubmissionStatus.compile_error]: 'problem.compileError',
[SubmissionStatus.runtime_error]: 'problem.runtimeError',
[SubmissionStatus.memory_limit_exceeded]: 'problem.runtimeError',
[SubmissionStatus.cpu_time_limit_exceeded]: 'problem.timeout',
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
}
/** 一道题上这么久没交、没跑例子,她就来问一句(主动开口) */
const IDLE_MS = 15 * 60_000
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
const UNSAMPLED_SUBMITS = 3
/** 同班做对这么多人,就拿出来说「全班已经 N 个人做对了」 */
const CLASS_MANY = 5
/** 同班交过这么多人、做对的不超过 CLASS_FEW 个,算「全班都卡着」 */
const CLASS_TRIED = 5
const CLASS_FEW = 2
/** 全站交过这么多次,通过率才有意义 */
const RATE_MIN_SUBMISSIONS = 30
const HARD_RATE = 0.3
const EASY_RATE = 0.7
/** 几种「换个说法」各自的机会:不是每次都说同一件事 */
const CHANCE = { classMany: 0.3, classFew: 0.5, rate: 0.4, hint: 0.5, challenge: 0.4 } as const
const roll = (chance: number) => Math.random() < chance
const FINISHED = (result: number | undefined) =>
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
/**
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
*
* 另外几件事她会主动开口(只对学生,频率上限在 Mascot.vue):同一道题错到第 5 次、连着 3 次
* 没跑例子就交还错了、又犯了自己的老毛病(behavior.ts 的 habitSituation)、15 分钟没交没跑。
*
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
*/
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
const router = useRouter()
const userStore = useUserStore()
const problemStore = useProblemStore()
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const collabStore = useCollabStore()
const lessonStore = useLessonStore()
const ctx = useProblemPageContext()
const teacherCollab = useTeacherCollab()
const { isDesktop } = useBreakpoints()
const flowchartStore = useFlowchartStore()
const challengeStore = useChallengeStore()
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
function studentActions(problem: ProblemDetail) {
const language = codeStore.code.language
const entry = ctx.value
const runSamples: MascotAction | null =
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
const help: MascotAction | null =
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
: null
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
const whySamples: MascotAction = { label: '我错在哪', run: () => showResult('samples') }
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
const submit: MascotAction | null = busy
? null
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
return { runSamples, help, why, whySamples, submit }
}
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
situation,
actions: actions.filter((it): it is MascotAction => !!it),
})
useMascotGuide(() => {
const problem = problemStore.problem
if (!problem) return null
if (userStore.isTeacherOrAbove) {
// 协作中别把老师带离这一页:跳走协作就断了
if (teacherCollab.value) return null
const actions: MascotAction[] = [{ label: '全班这题的提交', run: () => router.push({ name: 'submissions', query: { problem: problem._id } }) }]
if (isDesktop.value && userStore.canEditProblem(problem)) {
actions.push({ label: '编辑这道题', run: () => window.open(router.resolve(`/admin/problem/edit/${problem.id}`).href, '_blank') })
}
return { situation: 'teacher.problem', actions }
}
const entry = ctx.value
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
if (codeStore.code.language === 'Flowchart') {
// 画过、评过分了:分低就说分低,拿了 S 夸一句
const grade = flowchartStore.latestRating.grade
if (grade === 'S') return guide('flowchart.good', help)
if (grade && !isFlowchartPass(grade)) return guide('flowchart.low', help)
return guide('problem.flowchart', help)
}
// 正在判、正在跑例子:没什么好说的,打个招呼
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
const stuck = problemStore.failCount >= STUCK_FAILS
if (submissionStore.syntaxErrorInfo) return stuck ? guide('problem.stuck', help, why) : guide('problem.compileError', why, help)
const runs = submissionStore.trial.sampleRuns.value
if (submissionStore.resultSegment === 'samples' && runs.length) {
return runs.every((run) => run.result === SubmissionStatus.accepted)
? guide('problem.samplesPassed', submit)
: guide('problem.samplesFailed', whySamples, help)
}
const result = submissionStore.submission?.result
const lesson = lessonStore.find(problem._id)
if (result === SubmissionStatus.accepted) {
const next = nextAction()
// 课堂条的数是拉的时候的,刚做对重拉回来可能已经把自己算进去了
if (lesson && lesson.acceptedCount <= (lesson.myStatus === 'accepted' ? 1 : 0)) return guide('class.firstSolver', next)
if (entry.challenges && challengeStore.current?.challenges.length && roll(CHANCE.challenge)) return guide('problem.challenge', next)
const fails = problemStore.failCount
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
}
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
if (failed) {
if (stuck) return guide('problem.stuck', help, why)
const actions = [why, failed === 'problem.wrong' ? runSamples : null, help]
// AI 提示开了还没要:提一句,按钮带去结果里(提示在那儿)
const hintOpen = !problem.contestId && problemStore.failCount >= HINT_MIN_FAILURES && !submissionStore.hint.hintContent.value
if (hintOpen && roll(CHANCE.hint)) return guide('problem.hintReady', ...actions)
const classGuide = classSituation(lesson)
if (classGuide) return { ...guide(classGuide.situation, ...actions), n: classGuide.n }
const rate = problem.submissionNumber >= RATE_MIN_SUBMISSIONS ? problem.acceptedNumber / problem.submissionNumber : null
if (rate !== null && rate < HARD_RATE && roll(CHANCE.rate)) return guide('problem.hardWrong', ...actions)
if (rate !== null && rate > EASY_RATE && failed === 'problem.wrong' && roll(CHANCE.rate)) return guide('problem.easyWrong', ...actions)
return guide(failed, ...actions)
}
// 这两种只说话、只留举手:上线第一天「做过了」的「下一道」没人点过,空着代码跑例子也没意义,
// 而空白页上「举手求助」是真有人点(不会下手)。没有按钮的气泡 5 秒自己收起,不一直挡着
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore')
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', help)
const classGuide = classSituation(lesson)
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
return guide('problem.unsubmitted', runSamples, help)
/** 这节课的题:同班多少人做对了(课堂条那份数),不在这节课里就没有 */
function classSituation(item: typeof lesson) {
if (!item || item.myStatus === 'accepted') return null
if (item.acceptedCount >= CLASS_MANY && roll(CHANCE.classMany)) return { situation: 'class.manyDone' as const, n: item.acceptedCount }
if (item.userCount >= CLASS_TRIED && item.acceptedCount <= CLASS_FEW && roll(CHANCE.classFew))
return { situation: 'class.fewDone' as const, n: item.acceptedCount }
return null
}
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
function nextAction(): MascotAction | null {
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
const next = lessonStore.nextAfter(problem!._id)?.next
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
}
})
// ---- 主动开口(只对学生;比赛里 Mascot.vue 不弹)----
const student = () => userStore.isAuthed && !userStore.isTeacherOrAbove && ctx.value.entry !== 'contest'
/** 这道题上连着几次没跑例子就交了。跑过一次例子就清零 */
let unsampledSubmits = 0
let sampledSinceSubmit = false
/** 上一次「动静」(进题、交、跑例子)的时刻,给「盯了好久」用 */
let lastActivity = Date.now()
watch(
() => problemStore.problem?.id,
() => {
unsampledSubmits = 0
sampledSinceSubmit = false
lastActivity = Date.now()
},
)
watch(
() => submissionStore.trial.samplesRunAt.value,
(at) => {
if (!at) return
sampledSinceSubmit = true
lastActivity = Date.now()
if (student()) logMascotEvent({ kind: 'trial', action: 'samples' })
},
)
// 「运行例子」「自己输入」「举手」别处都不存,记下来给她看一周的习惯(behavior.ts)
watch(
() => submissionStore.trial.customRunning.value,
(running) => {
if (!running) return
lastActivity = Date.now()
if (student()) logMascotEvent({ kind: 'trial', action: 'custom' })
},
)
watch(
() => collabStore.helpStatus,
(status, previous) => {
if (status === 'pending' && previous === 'idle' && student()) logMascotEvent({ kind: 'help' })
},
)
watch(
() => submissionStore.submission?.id,
(id) => {
if (!id) return
unsampledSubmits = sampledSinceSubmit ? 0 : unsampledSubmits + 1
sampledSinceSubmit = false
lastActivity = Date.now()
},
)
// 判完了:错了就看要不要说一句;不管对错都刷新行为数(最近的错、今天做对几道)
watch(
() => [submissionStore.submission?.id, submissionStore.submission?.result] as const,
([id, result]) => {
const problem = problemStore.problem
if (!id || !FINISHED(result) || !problem || !student()) return
refreshMascotProfile(true)
const failed = RESULT_SITUATION[result as SubmissionStatus]
if (!failed) return
const { runSamples, help, why } = studentActions(problem)
if (problemStore.failCount >= STUCK_FAILS) {
requestSpeech({ key: `stuck:${problem.id}`, ...guide('problem.stuck', help, why) })
} else if (unsampledSubmits >= UNSAMPLED_SUBMITS && runSamples) {
requestSpeech({ key: `unsampled:${problem.id}`, ...guide('habit.noSamples', runSamples) })
} else {
const habit = habitSituation(failed)
if (habit) requestSpeech({ key: `habit:${habit}`, ...guide(habit, why) })
}
},
)
// 盯着一道没做对的题 15 分钟,没交也没跑:问一句
useIntervalFn(() => {
const problem = problemStore.problem
if (!problem || !student() || Date.now() - lastActivity < IDLE_MS) return
if (problem.myStatus === SubmissionStatus.accepted || submissionStore.submission?.result === SubmissionStatus.accepted) return
if (codeStore.code.language === 'Flowchart') return
const { runSamples, help } = studentActions(problem)
requestSpeech({ key: `idle:${problem.id}`, ...guide('idle.stuck', help, runSamples) })
}, 30_000)
}
@@ -1,10 +1,10 @@
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
/** /**
* 题目页的三种入口:题库(含课堂)、题单、比赛。同一个 detail.vue 挂在三条路由上。 * 题目页的三种入口:题库(含课堂)、专题、比赛。同一个 detail.vue 挂在三条路由上。
* *
* 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事 * 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事
* 在题单里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍 * 在专题里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍
* (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。 * (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。
*/ */
export type ProblemEntry = 'problem' | 'problemset' | 'contest' export type ProblemEntry = 'problem' | 'problemset' | 'contest'
@@ -27,13 +27,13 @@ interface EntryRules {
/** 通过之后给「这节课的下一题」 */ /** 通过之后给「这节课的下一题」 */
lessonNext: boolean lessonNext: boolean
/** /**
* 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去, * 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。专题里通过后 1.5 秒就跳回去,
* 比赛提交不判挑战 * 比赛提交不判挑战
*/ */
challenges: boolean challenges: boolean
/** 通过之后 1.5 秒弹强制点评 */ /** 通过之后 1.5 秒弹强制点评 */
reviewAfterAccepted: boolean reviewAfterAccepted: boolean
/** 通过之后 1.5 秒回题单 */ /** 通过之后 1.5 秒回专题 */
backToProblemSet: boolean backToProblemSet: boolean
/** 举手求助 */ /** 举手求助 */
help: boolean help: boolean
@@ -45,13 +45,13 @@ interface EntryRules {
collab: boolean collab: boolean
/** /**
* 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师 * 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师
* 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的题单入口还要这个按钮。 * 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的专题入口还要这个按钮。
* 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404 * 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404
*/ */
classStats: boolean classStats: boolean
/** /**
* 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部, * 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部,
* 题单入口没有抽屉,老师就没地方点了 * 专题入口没有抽屉,老师就没地方点了
*/ */
allSubmissionsInMenu: boolean allSubmissionsInMenu: boolean
} }
@@ -70,15 +70,15 @@ const RULES: Record<ProblemEntry, EntryRules> = {
classStats: false, classStats: false,
allSubmissionsInMenu: false, allSubmissionsInMenu: false,
}, },
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案 // 专题里什么「以前的」都不给看:加入专题之前的提交、统计、点评都能拿来抄答案
problemset: { problemset: {
drawers: [], drawers: [],
statusTag: false, statusTag: false,
// 点进相似题就离开了题单 // 点进相似题就离开了专题
similar: false, similar: false,
lessonNext: false, lessonNext: false,
challenges: false, challenges: false,
// 1.5 秒后要跳回题单页,弹了也会被冲掉 // 1.5 秒后要跳回专题页,弹了也会被冲掉
reviewAfterAccepted: false, reviewAfterAccepted: false,
backToProblemSet: true, backToProblemSet: true,
help: true, help: true,
@@ -14,28 +14,36 @@ const EMOJI_SVGS = [
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用 // 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
/**
* 表情在屏幕上的边长(px)。位图就按这个大小画,放的时候 `scalar: 1`、矩阵不缩放。
*
* **别改回「位图画大、矩阵缩小、scalar 放大」**:canvas-confetti 画位图粒子是每帧
* `fillRect(位图边长 × scalar)` 再用图案填,填的矩形和表情实际多大无关。原来位图 64、
* scalar 5,表情显示 50px,每个粒子每帧却要填 320×320 —— 两团 32 个粒子一帧三百多万像素,
* 机房电脑(显卡弱、Chrome 常走软件渲染)放到这一种就卡(用户 2026-11 反馈)。
* 现在填的正好是 50×50,同样大小、同样轨迹,像素少到四十分之一。
*/
const EMOJI_PX = 50
function svgToBitmapShape(svg: string): Promise<confetti.Shape> { function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const blob = new Blob([svg], { type: 'image/svg+xml' }) const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const img = new Image() const img = new Image()
img.onload = async () => { img.onload = async () => {
const renderSize = 64
const baseUnit = 10
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = renderSize canvas.width = EMOJI_PX
canvas.height = renderSize canvas.height = EMOJI_PX
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, renderSize, renderSize) ctx.drawImage(img, 0, 0, EMOJI_PX, EMOJI_PX)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
try { try {
const bitmap = await createImageBitmap(canvas) const bitmap = await createImageBitmap(canvas)
const scale = baseUnit / renderSize
resolve({ resolve({
type: 'bitmap', type: 'bitmap',
bitmap, bitmap,
matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2], matrix: [1, 0, 0, 1, -EMOJI_PX / 2, -EMOJI_PX / 2],
} as unknown as confetti.Shape) } as unknown as confetti.Shape)
} catch (e) { } catch (e) {
reject(e) reject(e)
@@ -415,7 +423,8 @@ export function useFireworks() {
const options: confetti.Options = { const options: confetti.Options = {
shapes: emojiShapes, shapes: emojiShapes,
flat: true, flat: true,
scalar: 5, // 大小在位图里(EMOJI_PX),这里放大的话填的矩形跟着放大,见 EMOJI_PX
scalar: 1,
particleCount: 16, particleCount: 16,
spread: 360, spread: 360,
startVelocity: 40, startVelocity: 40,
+62 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { errorCode } from 'utils/api' import { errorCode } from 'utils/api'
import { getProblem } from 'oj/api' import { getProblem } from 'oj/api'
@@ -12,17 +13,18 @@ import { useFlowchartStore } from 'oj/store/flowchart'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import type { RouteLocationNormalized } from 'vue-router' import type { RouteLocationNormalized } from 'vue-router'
import NotFound from 'shared/components/NotFound.vue' import NotFound from 'shared/components/NotFound.vue'
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好 // 判完之后该发生的事(烟花、点评、回专题)。静态引入:它得在第一次判完之前就挂好
import SubmissionEffects from './components/SubmissionEffects.vue' import SubmissionEffects from './components/SubmissionEffects.vue'
import ResultTabLabel from './components/ResultTabLabel.vue' import ResultTabLabel from './components/ResultTabLabel.vue'
import ContextBar from './components/ContextBar.vue' import ContextBar from './components/ContextBar.vue'
import { useEditorMenu } from './composables/editorMenu' import { useEditorMenu } from './composables/editorMenu'
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext' import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab' import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
import { useProblemMascotGuide } from './composables/mascotGuide'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版, // 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
// 没有协作、也没有错误标记,于是题单里「求助」老师接不上、「把中文标点都换成英文」 // 没有协作、也没有错误标记,于是专题里「求助」老师接不上、「把中文标点都换成英文」
// 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节) // 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节)
const loadProblemEditor = () => import('./components/ProblemEditor.vue') const loadProblemEditor = () => import('./components/ProblemEditor.vue')
const ProblemEditor = defineAsyncComponent(loadProblemEditor) const ProblemEditor = defineAsyncComponent(loadProblemEditor)
@@ -95,7 +97,7 @@ const leftPane = useTemplateRef<HTMLElement>('leftPane')
const theme = useThemeVars() const theme = useThemeVars()
/** /**
* 分隔条两头按像素卡,不按比例:左栏窄于 360 时题单条的「下一题」箭头被裁掉、三个抽屉按钮 * 分隔条两头按像素卡,不按比例:左栏窄于 360 时专题条的「下一题」箭头被裁掉、三个抽屉按钮
* 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256) * 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256)
*/ */
const { width: windowWidth } = useWindowSize() const { width: windowWidth } = useWindowSize()
@@ -120,6 +122,13 @@ watch(
) )
const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key]) const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key])
/** 抽屉按钮前的小图标:页签行右边三个不带框的按钮,和左边的页签分得开(原来是三个圆角胶囊,看着像另一组页签) */
const DRAWER_ICON: Record<DrawerKey, string> = {
info: 'ph:chart-bar',
reaction: 'ph:chat-circle',
submission: 'ph:clock-counter-clockwise',
}
function toggleDrawer(key: DrawerKey) { function toggleDrawer(key: DrawerKey) {
drawer.value = drawer.value === key ? null : key drawer.value = drawer.value === key ? null : key
} }
@@ -208,6 +217,13 @@ watch(
}, },
) )
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
useProblemMascotGuide((segment) => {
drawer.value = null
currentTab.value = 'result'
submissionStore.resultSegment = segment
})
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数 // 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' }) const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
@@ -234,12 +250,12 @@ async function init() {
} }
onMounted(init) onMounted(init)
/** 题不存在时回哪:比赛里回比赛,题单里回题单,否则回题目列表 */ /** 题不存在时回哪:比赛里回比赛,专题里回专题,否则回题目列表 */
const backTo = computed(() => const backTo = computed(() =>
contestID contestID
? { to: `/contest/${contestID}`, toLabel: '回到比赛' } ? { to: `/contest/${contestID}`, toLabel: '回到比赛' }
: problemSetId : problemSetId
? { to: `/problemset/${problemSetId}`, toLabel: '回到题单' } ? { to: `/problemset/${problemSetId}`, toLabel: '回到专题' }
: { to: '/problem', toLabel: '去题目列表' }, : { to: '/problem', toLabel: '去题目列表' },
) )
watch(() => problemID, init) watch(() => problemID, init)
@@ -306,7 +322,7 @@ onBeforeUnmount(() => {
</div> </div>
<div v-if="ctx.drawers.length" class="drawer-buttons"> <div v-if="ctx.drawers.length" class="drawer-buttons">
<button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)"> <button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)">
{{ drawerLabel(key) }} <Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
</button> </button>
</div> </div>
</div> </div>
@@ -409,26 +425,46 @@ onBeforeUnmount(() => {
cursor: col-resize; cursor: col-resize;
} }
/*
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
*/
.left-pane { .left-pane {
position: relative; position: relative;
height: 100%; height: 100%;
display: flex; display: grid;
flex-direction: column; grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden; overflow: hidden;
} }
.left-pane > :deep(.lesson-rail) {
grid-column: 1;
grid-row: 1 / -1;
}
.left-pane > :deep(.context-bar),
.left-pane > .tab-row {
grid-column: 2;
}
.left-pane > .pane-body {
grid-column: 2;
grid-row: 3;
}
.right-pane { .right-pane {
height: 100%; height: 100%;
} }
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮。 /* 「题目 / 结果」:下划线式页签,右边是抽屉的三个按钮(图标 + 字,不带框)。
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排, 高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
差 6px 的话两条下边线在分隔线处错成一个台阶 */ 差 6px 的话两条下边线在分隔线处错成一个台阶 */
.tab-row { .tab-row {
flex: none; flex: none;
height: 48px; height: 48px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 20px 0 12px; padding: 0 10px 0 8px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
@@ -460,24 +496,32 @@ onBeforeUnmount(() => {
.drawer-buttons { .drawer-buttons {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
gap: 4px; gap: 2px;
} }
.drawer-button { .drawer-button {
height: 32px; height: 32px;
padding: 0 11px; padding: 0 9px;
border: 1px solid v-bind('theme.borderColor'); border: 0;
border-radius: 16px; border-radius: 6px;
background: transparent; background: transparent;
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit; font: inherit;
font-size: 13px; font-size: 14px;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.drawer-button svg {
color: v-bind('theme.textColor3');
}
.drawer-button:hover { .drawer-button:hover {
border-color: v-bind('theme.primaryColorHover'); background-color: rgba(128, 128, 128, 0.1);
color: v-bind('theme.primaryColor'); color: v-bind('theme.textColor1');
} }
.pane-body { .pane-body {
+13
View File
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import ProblemStatus from './components/ProblemStatus.vue' import ProblemStatus from './components/ProblemStatus.vue'
import ProblemTypeTag from './components/ProblemTypeTag.vue' import ProblemTypeTag from './components/ProblemTypeTag.vue'
import ProgressRow from './components/ProgressRow.vue' import ProgressRow from './components/ProgressRow.vue'
@@ -272,6 +273,18 @@ async function pickRandom() {
} }
} }
// 看板娘:学生给找题的几条路(「随便来一道」按这页选着的知识点和类型挑),老师给新建题目
useMascotGuide(() => {
if (teacher.value) {
return {
situation: 'teacher.problems',
actions: userStore.hasProblemPermission ? [{ label: '新建题目', run: () => router.push('/admin/problem/create') }] : [],
}
}
const undone: MascotAction | null = userStore.isAuthed && !undoneOn.value ? { label: '只看没做完', run: () => (undoneOn.value = true) } : null
return { situation: 'problems', actions: [focusJumpAction(), ...(undone ? [undone] : []), { label: '随便来一道', run: pickRandom }] }
})
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ---- // ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
const nextTag = computed(() => { const nextTag = computed(() => {
const p = mine.value const p = mine.value
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff. Show more