Compare commits

..
40 Commits
Author SHA1 Message Date
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
xuyueandClaude Opus 5.5 48590eee60 refactor(目录): 目录名对上界面上的名字,自学的「一课」不再也叫 lesson
Deploy / deploy (push) Canceled after 0s
- 后台课堂任务 admin/lesson → admin/task(/admin/task),和前台 oj/task 对上
- 后台首页 admin/setting/home.vue → admin/home/,设置 → admin/config/
- 卡点分析、年度趋势从 admin/problem 搬到 admin/analytics(/admin/analytics/stuck|trend),和菜单的「学情分析」、后端 analytics.ts 对上
- 首页 dashboard.vue / TeacherHome.vue → student.vue / teacher.vue;自学首页 home.vue → index.vue,看一课 index.vue → tutorial.vue
- lesson 只表示课堂上的一节:自学那边改叫 tutorial(oj/learn/lesson.ts → utils.ts、isLessonDone → isTutorialDone、Lesson*.vue → Tutorial*.vue、自学情况的 lessons 字段 → tutorials)
- 旧地址不留跳转

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:59:43 +08:00
xuyueandClaude Opus 5.5 29c31640b5 refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:07 +08:00
314 changed files with 19128 additions and 2853 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 翻译成「请改为禁用账号」,这是有意的。
+1 -1
View File
@@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000
/** /**
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。 * 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
* *
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着, * 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。 * 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。 * 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
*/ */
+2 -2
View File
@@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) {
/** /**
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找), * 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
* 「帮完了」要回到哪个班的看板 * 「帮完了」要回到哪个班的任务页
*/ */
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) { function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
const frame = (peer: Record<string, unknown>) => const frame = (peer: Record<string, unknown>) =>
@@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown)
} }
/** /**
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。 * 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。
* *
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」; * 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。 * 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
+3 -3
View File
@@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
} }
/** /**
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状, * 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状,
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码 * 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
*/ */
export interface CollabLatest { export interface CollabLatest {
@@ -111,7 +111,7 @@ export interface Room {
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */ /** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
studentId: number studentId: number
/** /**
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。 * help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清 * 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
*/ */
mode: 'help' | 'peek' mode: 'help' | 'peek'
@@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) {
} }
/** /**
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、 * 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报, * 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
* 连接断了就清掉。同一账号开了两个标签页时,后报的算 * 连接断了就清掉。同一账号开了两个标签页时,后报的算
*/ */
@@ -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
} }
] ]
} }
+57 -14
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],
@@ -1195,7 +1196,7 @@ export const problemsetBadge = pgTable(
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。 * 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
* *
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`), * **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。 * 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isTutorialDone`)。
* *
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户 * 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。 * 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
@@ -1293,10 +1294,10 @@ export const exerciseAttempt = pgTable(
) )
/** /**
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节 * 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。 * 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
* *
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。 * 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。 * 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
* *
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列: * - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
@@ -1322,7 +1323,7 @@ export const classLesson = pgTable(
problemIds: jsonb('problem_ids').notNull().$type<number[]>(), problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
/** /**
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的 * 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过 * 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过
*/ */
language: text().$type<LessonLanguage>(), language: text().$type<LessonLanguage>(),
/** /**
@@ -1330,11 +1331,11 @@ export const classLesson = pgTable(
* 都是写代码。写入侧按 lessonModeSchema 校验过 * 都是写代码。写入侧按 lessonModeSchema 校验过
*/ */
mode: text().$type<LessonMode>().notNull().default('code'), mode: text().$type<LessonMode>().notNull().default('code'),
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */ /** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(), createdBy: integer('created_by').notNull(),
/** /**
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 * 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。 * 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。
* 加这一列之前布置的用 updated_at 回填(0034) * 加这一列之前布置的用 updated_at 回填(0034)
*/ */
createdAt: timestamp('created_at', { createdAt: timestamp('created_at', {
@@ -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())
} }
+6 -2
View File
@@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth'
import { accountRoutes } from './routes/account' import { accountRoutes } from './routes/account'
import { contestRoutes } from './routes/contest' import { contestRoutes } from './routes/contest'
import { contentRoutes } from './routes/content' import { contentRoutes } from './routes/content'
import { classroomRoutes } from './routes/classroom' import { homeRoutes } from './routes/home'
import { taskRoutes } from './routes/task'
import { rankingRoutes } from './routes/ranking' import { rankingRoutes } from './routes/ranking'
import { problemsetRoutes } from './routes/problemset' import { problemsetRoutes } from './routes/problemset'
import { achievementRoutes } from './routes/achievement' import { achievementRoutes } from './routes/achievement'
@@ -20,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'
@@ -32,7 +34,8 @@ app.route('/api', accountRoutes)
app.route('/api', siteRoutes) app.route('/api', siteRoutes)
app.route('/api', contestRoutes) app.route('/api', contestRoutes)
app.route('/api', contentRoutes) app.route('/api', contentRoutes)
app.route('/api', classroomRoutes) app.route('/api', homeRoutes)
app.route('/api', taskRoutes)
app.route('/api', rankingRoutes) app.route('/api', rankingRoutes)
app.route('/api', problemsetRoutes) app.route('/api', problemsetRoutes)
app.route('/api', achievementRoutes) app.route('/api', achievementRoutes)
@@ -42,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)
+83 -46
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)
@@ -446,7 +483,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
return users.length return users.length
}) })
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它, // 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它,
// 原来要再去设置页手动加,经常忘 // 原来要再去设置页手动加,经常忘
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))] const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
const options = await getWebsiteOptions() const options = await getWebsiteOptions()
@@ -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)),
) )
}) })
+14 -14
View File
@@ -1,7 +1,7 @@
import { import {
FLOWCHART_PASS_GRADES, FLOWCHART_PASS_GRADES,
STUDENT_ROLES, STUDENT_ROLES,
isLessonDone, isTutorialDone,
type LearnBoard, type LearnBoard,
type LearnBoardStudent, type LearnBoardStudent,
type LearnExerciseAttemptList, type LearnExerciseAttemptList,
@@ -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) {
@@ -64,7 +64,7 @@ function studentCondition(value: string | null) {
/** /**
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数, * 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、 * 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前 * 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。 * 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
*/ */
async function problemActivity(userIds: number[], problemIds: number[]) { async function problemActivity(userIds: number[], problemIds: number[]) {
@@ -198,7 +198,7 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
students: students.map((student) => { students: students.map((student) => {
const opened: number[] = [] const opened: number[] = []
const done: number[] = [] const done: number[] = []
const lessons: LearnBoardStudent['lessons'] = [] const attempts: LearnBoardStudent['tutorials'] = []
let exerciseSolved = 0 let exerciseSolved = 0
let totalSeconds = 0 let totalSeconds = 0
let lastViewedAt: string | null = null let lastViewedAt: string | null = null
@@ -210,26 +210,26 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
exerciseSolved += solvedHere exerciseSolved += solvedHere
if (exercises) { if (exercises) {
const { tried, solved, wrongAttempts } = exercises const { tried, solved, wrongAttempts } = exercises
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts }) attempts.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
} }
if (row) { if (row) {
opened.push(tutorial.id) opened.push(tutorial.id)
totalSeconds += row.totalSeconds totalSeconds += row.totalSeconds
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
} }
const lesson = { const here = {
viewCount: row?.viewCount ?? 0, viewCount: row?.viewCount ?? 0,
exerciseTotal: tutorial.exerciseTotal, exerciseTotal: tutorial.exerciseTotal,
exerciseSolved: solvedHere, exerciseSolved: solvedHere,
} }
if (isLessonDone(lesson)) done.push(tutorial.id) if (isTutorialDone(here)) done.push(tutorial.id)
} }
return { return {
...student, ...student,
opened, opened,
done, done,
exerciseSolved, exerciseSolved,
lessons, tutorials: attempts,
problems: problemsOf.get(student.userId) ?? [], problems: problemsOf.get(student.userId) ?? [],
totalSeconds, totalSeconds,
lastViewedAt, lastViewedAt,
@@ -394,27 +394,27 @@ adminLearnRoutes.get('/learn-analytics/students/:id', requireTeacher, async (c)
).map((row) => [row.problemId, row]), ).map((row) => [row.problemId, row]),
) )
const byLesson = new Map<number, LearnStudentDetail['lessons'][number]['exercises']>() const byTutorial = new Map<number, LearnStudentDetail['tutorials'][number]['exercises']>()
for (const { tutorialId, ...row } of exercises) { for (const { tutorialId, ...row } of exercises) {
const list = byLesson.get(tutorialId) ?? [] const list = byTutorial.get(tutorialId) ?? []
list.push({ list.push({
...row, ...row,
attempts: row.attempts ?? 0, attempts: row.attempts ?? 0,
solved: row.solved ?? false, solved: row.solved ?? false,
}) })
byLesson.set(tutorialId, list) byTutorial.set(tutorialId, list)
} }
return success(c, { return success(c, {
...student, ...student,
lessons: tutorials tutorials: tutorials
.map((row) => ({ .map((row) => ({
tutorialId: row.tutorialId, tutorialId: row.tutorialId,
title: row.title, title: row.title,
viewCount: row.viewCount ?? 0, viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0, totalSeconds: row.totalSeconds ?? 0,
lastViewedAt: row.lastViewedAt, lastViewedAt: row.lastViewedAt,
exercises: byLesson.get(row.tutorialId) ?? [], exercises: byTutorial.get(row.tutorialId) ?? [],
problems: related problems: related
.filter((problem) => problem.tutorialId === row.tutorialId) .filter((problem) => problem.tutorialId === row.tutorialId)
.map(({ id: problemId, displayId, title }) => { .map(({ id: problemId, displayId, title }) => {
+17 -8
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'
@@ -10,10 +10,10 @@ import { localDateTime } from '../../time'
import { queryInteger } from '../helpers' import { queryInteger } from '../helpers'
/** /**
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改, * 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改,
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。 * 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
* *
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。 * 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。
*/ */
export const adminLessonRoutes = new Hono<AppEnv>() export const adminLessonRoutes = new Hono<AppEnv>()
@@ -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
@@ -116,7 +121,7 @@ async function lessonValues(body: AdminLessonRequest): Promise<
> { > {
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1) const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1)
if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` } if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
// 只画流程图不用语言,存成 null(和看板布置一样) // 只画流程图不用语言,存成 null(和任务页布置一样)
const language = body.mode === 'flowchart' ? null : body.language const language = body.mode === 'flowchart' ? null : body.language
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode) const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
if ('code' in resolved) return resolved if ('code' in resolved) return resolved
@@ -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]`}`)
} }
/** /**
+216
View File
@@ -0,0 +1,216 @@
import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware'
import { getLoginWindow } from '../auth/session'
import { db, schema } from '../db'
import { success } from '../http'
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
import { accepted } from '../services/learning-stats'
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
/**
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
* 「这节课是哪几道题」的取法在 services/lesson.ts(课堂任务、后台、统计共用)
*/
export const homeRoutes = new Hono<AppEnv>()
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
const LAST_VISIT_MAX_DAYS = 30
/**
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
*/
homeRoutes.get('/me/last-visit', requireAuth, async (c) => {
const user = c.get('user')!
const window = await getLoginWindow(c)
const previousLogin = window?.previousLogin ?? null
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
return success(c, { previousLogin, summary: null } satisfies LastVisit)
}
const rows = await db
.select({
problemId: schema.submission.problemId,
problemDisplayId: schema.problem.displayId,
title: schema.problem.title,
result: schema.submission.result,
createTime: schema.submission.createTime,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
gte(schema.submission.createTime, previousLogin),
lt(schema.submission.createTime, window.loginAt),
),
)
.orderBy(asc(schema.submission.createTime))
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
const byProblem = new Map<
number,
{
problemDisplayId: string
title: string
attempts: number
lastResult: (typeof rows)[number]['result']
lastTime: string
solved: boolean
}
>()
const solved: { problemDisplayId: string; title: string }[] = []
for (const row of rows) {
const item = byProblem.get(row.problemId) ?? {
problemDisplayId: row.problemDisplayId,
title: row.title,
attempts: 0,
lastResult: row.result,
lastTime: row.createTime,
solved: false,
}
item.attempts++
item.lastResult = row.result
item.lastTime = row.createTime
if (!item.solved && accepted.includes(row.result)) {
item.solved = true
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
}
byProblem.set(row.problemId, item)
}
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
const everSolved = tried.length
? new Set(
(
await db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
inArray(schema.submission.problemId, tried),
inArray(schema.submission.result, accepted),
),
)
).map((row) => row.problemId),
)
: new Set<number>()
const unsolved = tried
.filter((id) => !everSolved.has(id))
.map((id) => byProblem.get(id)!)
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
.slice(0, 3)
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
problemDisplayId,
title,
attempts,
lastResult,
}))
return success(c, {
previousLogin,
summary: {
submissionCount: rows.length,
solvedCount: solved.length,
lastSubmitTime: rows.at(-1)!.createTime,
unsolved,
solved: solved.slice(0, 12),
},
} satisfies LastVisit)
})
/**
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
*/
homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
const user = c.get('user')!
if (!user.className) {
return success(c, {
className: null,
day: null,
source: null,
language: null,
mode: null,
problems: [],
} satisfies ClassActivity)
}
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.steps
const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([
ids.length
? db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lessonLanguageFilter(codeLanguage),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length
? db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
})
.from(schema.flowchartSubmission)
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
type Step = 'accepted' | 'tried' | 'none'
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
return success(c, {
className: user.className,
day: lesson.day,
source: lesson.source,
language: codeLanguage,
mode: lesson.mode,
problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needsOf(problem.id)
let myStatus: Step
if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
} else {
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
}
return {
problemDisplayId: problem.displayId,
title: problem.title,
userCount: problem.userCount,
acceptedCount: problem.acceptedCount,
myStatus,
draw: needs?.draw ? draw : null,
code: needs?.code ? code : null,
} satisfies ClassActivityProblem
}),
} satisfies ClassActivity)
})
+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)
})
+27 -6
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'
@@ -73,7 +74,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
} }
/** /**
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。 * 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
* 学生的题目列表不带这个参数 * 学生的题目列表不带这个参数
*/ */
function lessonLanguageFilter(raw: string | undefined) { function lessonLanguageFilter(raw: string | undefined) {
@@ -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)),
+16 -10
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,10 +527,15 @@ 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 }
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */ /** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
async function lessonPlans(range: StatisticsRange) { async function lessonPlans(range: StatisticsRange) {
return db return db
.select({ .select({
@@ -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)
@@ -1,440 +1,39 @@
import { import {
STUDENT_ROLES, STUDENT_ROLES,
classLessonRequestSchema, classLessonRequestSchema,
type ClassActivity,
type ClassToday, type ClassToday,
type ClassBoard, type TaskBoard,
type ClassBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
type ClassActivityProblem,
type LastVisit,
type LessonLanguage,
type LessonMode,
lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm' import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware' import { requireTeacher, type AppEnv } from '../auth/middleware'
import { seenToday } from '../auth/presence' import { seenToday } from '../auth/presence'
import { getLoginWindow } from '../auth/session'
import { presenceOf } from '../collab/state' import { presenceOf } from '../collab/state'
import { db, schema } from '../db' import { db, schema } from '../db'
import { challengeSuccessesOf } from '../services/challenge' import { challengeSuccessesOf } from '../services/challenge'
import { failure, parseBody, success } from '../http' import { failure, parseBody, success } from '../http'
import { isAccepted } from '../judge/status' import { isAccepted } from '../judge/status'
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity' import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import {
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson' announceLessonChange,
CLASS_ACTIVITY_MIN_USERS,
classDayGroups,
classLessonProblems,
lessonDoneCounts,
resolveLessonProblems,
todayLessonSnapshot,
} from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time' import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
/** /**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: * 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), * 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts)
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
*/ */
const CLASS_ACTIVITY_MIN_USERS = 5 export const taskRoutes = new Hono<AppEnv>()
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
const CLASS_ACTIVITY_LIMIT = 8
interface ClassDayGroup {
day: string
problemId: number
firstAt: string
userCount: number
acceptedCount: number
}
/**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
*/
async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
): Promise<ClassDayGroup[]> {
const { mode } = options
const activity = activityRows({
since,
users: classMemberIds(className),
problemIds: options.problemIds,
language: options.language,
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
})
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const perUser = db
.select({
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
})
.from(perUser)
.groupBy(perUser.day, perUser.problemId)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
}
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
async function visibleProblems(ids: number[]) {
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
})
.from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
return new Map(rows.map((row) => [row.id, row]))
}
/**
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
*/
async function lessonPlan(className: string, day: string) {
const [row] = await db
.select({
id: schema.classLesson.id,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
})
.from(schema.classLesson)
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
.limit(1)
return row?.problemIds.length ? row : null
}
/**
* 班里「这节课」的题。老师在课堂看板布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
*
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
* 学生最需要它的时候。
*/
async function classLessonProblems(className: string, lookbackDays: number) {
const today = calendarDay()
const plan = await lessonPlan(className, today)
if (plan) {
const planned = plan.problemIds
const [problems, groups] = await Promise.all([
visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }),
])
const groupById = new Map(groups.map((row) => [row.problemId, row]))
return {
source: 'teacher' as const,
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
const problem = problems.get(id)
if (!problem) return []
const group = groupById.get(id)
return [
{
...problem,
userCount: group?.userCount ?? 0,
acceptedCount: group?.acceptedCount ?? 0,
},
]
}),
}
}
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
const picked = groups
.filter((row) => row.day === latest)
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
.slice(0, CLASS_ACTIVITY_LIMIT)
const problems = await visibleProblems(picked.map((row) => row.problemId))
return {
source: picked.length ? ('inferred' as const) : null,
lessonId: null,
day: picked.length ? latest : null,
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null,
mode: null,
assignedAt: null,
assignedBy: null,
problems: picked.flatMap((row) => {
const problem = problems.get(row.problemId)
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
}),
}
}
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
const LAST_VISIT_MAX_DAYS = 30
/**
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
*/
classroomRoutes.get('/me/last-visit', requireAuth, async (c) => {
const user = c.get('user')!
const window = await getLoginWindow(c)
const previousLogin = window?.previousLogin ?? null
if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) {
return success(c, { previousLogin, summary: null } satisfies LastVisit)
}
const rows = await db
.select({
problemId: schema.submission.problemId,
problemDisplayId: schema.problem.displayId,
title: schema.problem.title,
result: schema.submission.result,
createTime: schema.submission.createTime,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
gte(schema.submission.createTime, previousLogin),
lt(schema.submission.createTime, window.loginAt),
),
)
.orderBy(asc(schema.submission.createTime))
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
const byProblem = new Map<
number,
{
problemDisplayId: string
title: string
attempts: number
lastResult: (typeof rows)[number]['result']
lastTime: string
solved: boolean
}
>()
const solved: { problemDisplayId: string; title: string }[] = []
for (const row of rows) {
const item = byProblem.get(row.problemId) ?? {
problemDisplayId: row.problemDisplayId,
title: row.title,
attempts: 0,
lastResult: row.result,
lastTime: row.createTime,
solved: false,
}
item.attempts++
item.lastResult = row.result
item.lastTime = row.createTime
if (!item.solved && accepted.includes(row.result)) {
item.solved = true
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
}
byProblem.set(row.problemId, item)
}
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
const everSolved = tried.length
? new Set(
(
await db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
isNull(schema.submission.contestId),
inArray(schema.submission.problemId, tried),
inArray(schema.submission.result, accepted),
),
)
).map((row) => row.problemId),
)
: new Set<number>()
const unsolved = tried
.filter((id) => !everSolved.has(id))
.map((id) => byProblem.get(id)!)
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
.slice(0, 3)
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
problemDisplayId,
title,
attempts,
lastResult,
}))
return success(c, {
previousLogin,
summary: {
submissionCount: rows.length,
solvedCount: solved.length,
lastSubmitTime: rows.at(-1)!.createTime,
unsolved,
solved: solved.slice(0, 12),
},
} satisfies LastVisit)
})
/**
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
*/
classroomRoutes.get('/me/class-activity', requireAuth, async (c) => {
const user = c.get('user')!
if (!user.className) {
return success(c, {
className: null,
day: null,
source: null,
language: null,
mode: null,
problems: [],
} satisfies ClassActivity)
}
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null
const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([
ids.length
? db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, ids),
isNull(schema.submission.contestId),
// 布置的是 C:以前用 Python 做对过不算这份作业做完
lessonLanguageFilter(codeLanguage),
),
)
.groupBy(schema.submission.problemId)
: [],
// 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径
ids.length
? db
.select({
problemId: schema.flowchartSubmission.problemId,
passed: sql<boolean>`coalesce(bool_or(${FLOWCHART_PASSED}), false)`,
})
.from(schema.flowchartSubmission)
.where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids)))
.groupBy(schema.flowchartSubmission.problemId)
: [],
])
type Step = 'accepted' | 'tried' | 'none'
const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried')
const codeById = new Map(mine.map((row) => [row.problemId, row.accepted]))
const drawById = new Map(drawn.map((row) => [row.problemId, row.passed]))
return success(c, {
className: user.className,
day: lesson.day,
source: lesson.source,
language: codeLanguage,
mode: lesson.mode,
problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id)
let myStatus: Step
if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完
const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null)
myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none'
} else {
// 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」)
myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none'
}
return {
problemDisplayId: problem.displayId,
title: problem.title,
userCount: problem.userCount,
acceptedCount: problem.acceptedCount,
myStatus,
draw: needs?.draw ? draw : null,
code: needs?.code ? code : null,
} satisfies ClassActivityProblem
}),
} 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 }
}
/** /**
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2, * 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
@@ -450,7 +49,7 @@ function flowchartCaseCounts() {
} }
/** /**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过 * 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」 * 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。 * —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
*/ */
@@ -459,11 +58,11 @@ const RECENT_LESSONS = 5
const RECENT_LESSONS_WINDOW_DAYS = 45 const RECENT_LESSONS_WINDOW_DAYS = 45
/** /**
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、 * 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一), * 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。 * 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
*/ */
classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { taskRoutes.get('/task/board', requireTeacher, async (c) => {
const day = calendarDay() const day = calendarDay()
const className = c.req.query('className')?.trim() || (await suggestActiveClass()) const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) { if (!className) {
@@ -480,10 +79,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
problems: [], problems: [],
students: [], students: [],
recentLessons: 0, recentLessons: 0,
} satisfies ClassBoard) } satisfies TaskBoard)
} }
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。 // 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种) // lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
const [lesson, [last]] = await Promise.all([ const [lesson, [last]] = await Promise.all([
classLessonProblems(className, 1), classLessonProblems(className, 1),
@@ -527,9 +126,14 @@ classroomRoutes.get('/classroom/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
@@ -545,7 +149,7 @@ classroomRoutes.get('/classroom/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)
@@ -562,7 +166,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
.where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start))) .where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
.groupBy(schema.submission.userId) .groupBy(schema.submission.userId)
: [], : [],
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。 // 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。
// 和上面代码那条同一个范围:今天、所有题、不论评没评完 // 和上面代码那条同一个范围:今天、所有题、不论评没评完
userIds.length userIds.length
? db ? db
@@ -586,7 +190,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
: [], : [],
/** /**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。 * 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。 * 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算 * 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
*/ */
ids.length && userIds.length ids.length && userIds.length
@@ -623,7 +227,7 @@ classroomRoutes.get('/classroom/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)
@@ -634,7 +238,7 @@ classroomRoutes.get('/classroom/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,
@@ -681,6 +285,7 @@ classroomRoutes.get('/classroom/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) =>
@@ -689,6 +294,7 @@ classroomRoutes.get('/classroom/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)
@@ -705,11 +311,11 @@ classroomRoutes.get('/classroom/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)
} }
// 下面几样都是代码那一步的:只画流程图的课没有 // 下面几样都是代码那一步的:只画流程图的课没有
@@ -752,8 +358,9 @@ classroomRoutes.get('/classroom/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'
@@ -769,18 +376,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
editing: presenceOf(student.userId)?.problemId ?? null, editing: presenceOf(student.userId)?.problemId ?? null,
online: seen.get(student.userId)?.online ?? false, online: seen.get(student.userId)?.online ?? false,
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null, seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
}) satisfies ClassBoardStudent, }) satisfies TaskBoardStudent,
), ),
recentLessons: recentDays.length, recentLessons: recentDays.length,
} satisfies ClassBoard) } satisfies TaskBoard)
}) })
/** /**
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」 * 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数 * 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天 * 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
*/ */
classroomRoutes.get('/classroom/today', requireTeacher, async (c) => { taskRoutes.get('/task/today', requireTeacher, async (c) => {
const start = dayStart() const start = dayStart()
const today = calendarDay() const today = calendarDay()
const activity = activityRows({ since: start }) const activity = activityRows({ since: start })
@@ -810,13 +417,21 @@ classroomRoutes.get('/classroom/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) {
// 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算 // 推断出来的:做没做对不限今天(和任务页的格子一样:以前做对的也算做完),代码或流程图都算
const done = activityRows({ const done = activityRows({
since: new Date(0).toISOString(), since: new Date(0).toISOString(),
users: classMemberIds(className), users: classMemberIds(className),
@@ -857,10 +472,10 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => {
}) })
/** /**
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先, * 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂任务(老师布置的优先,
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班 * 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
*/ */
classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => { taskRoutes.get('/task/lesson', requireTeacher, async (c) => {
const className = c.req.query('className')?.trim() || (await suggestActiveClass()) const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) if (!className)
return success(c, { return success(c, {
@@ -869,7 +484,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
language: null, language: null,
problems: [], problems: [],
} satisfies ClassLesson) } satisfies ClassLesson)
// 和看板一样只回看今天:「这节课」不该冒出昨天的题 // 和任务页一样只回看今天:「这节课」不该冒出昨天的题
const lesson = await classLessonProblems(className, 1) const lesson = await classLessonProblems(className, 1)
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language) // C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL')) const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
@@ -889,12 +504,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节, * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。 * 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
*/ */
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { taskRoutes.put('/task/lesson', requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对') const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get('user')! const user = c.get('user')!
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」 // 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」
const language = mode === 'flowchart' ? null : parsed.data.language const language = mode === 'flowchart' ? null : parsed.data.language
const day = calendarDay() const day = calendarDay()
+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')`
+1 -1
View File
@@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null
}) })
} }
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */ /** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) { export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
if (userIds.length === 0 || problemIds.length === 0) return [] if (userIds.length === 0 || problemIds.length === 0) return []
return db return db
+4 -4
View File
@@ -5,8 +5,8 @@ import { db, schema } from '../db'
import { JudgeStatus } from '../judge/status' import { JudgeStatus } from '../judge/status'
/** /**
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」 * 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班, * 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班,
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。 * 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
*/ */
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ /** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
@@ -30,7 +30,7 @@ export function classMemberIds(className: string) {
/** /**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计 * 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数, * (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致: * 口径和课堂条 / 任务页的「做完」一致:
* *
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败; * - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。 * - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
@@ -99,7 +99,7 @@ export function activityRows(options: {
return code.unionAll(flowchart).as('activity') return code.unionAll(flowchart).as('activity')
} }
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ /** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/** /**
+4 -4
View File
@@ -3,10 +3,10 @@
* *
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」, * 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。 * 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts): * 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems):
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。 * 老师在任务页布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。
* *
* 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算 * 做对的口径也同任务页:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算
* (比赛是课堂测验,题目也不在题库里)。 * (比赛是课堂测验,题目也不在题库里)。
*/ */
import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract' import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract'
@@ -19,7 +19,7 @@ import { calendarDay, monthStart, termStart, weekStart } from '../time'
export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */ /** 同 services/lesson.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */
const LESSON_MIN_USERS = 5 const LESSON_MIN_USERS = 5
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */ /** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
+233 -8
View File
@@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db' import { db, schema } from '../db'
import { publishLessonUpdate } from '../events' import { publishLessonUpdate } from '../events'
import { calendarDay, dayNumber, dayStart } from '../time' import { calendarDay, dayNumber, dayStart, localTime } from '../time'
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity' import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/** /**
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、 * 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。 * 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。
*/ */
/** /**
@@ -55,11 +55,20 @@ export function lessonDayEnd(day: string) {
/** /**
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。 * 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言 * 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。 * (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。
* 人按花名册(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),
})), })),
) )
@@ -119,7 +128,7 @@ interface LessonSnapshot {
} }
/** /**
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法), * 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法),
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比 * 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
*/ */
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> { export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
@@ -175,3 +184,219 @@ export async function announceLessonChange(className: string, before: LessonSnap
console.error('Failed to announce lesson change', error) console.error('Failed to announce lesson change', error)
} }
} }
/**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
*/
export const CLASS_ACTIVITY_MIN_USERS = 5
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
export const CLASS_ACTIVITY_LOOKBACK_DAYS = 7
const CLASS_ACTIVITY_LIMIT = 8
interface ClassDayGroup {
day: string
problemId: number
firstAt: string
userCount: number
acceptedCount: number
}
/**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛,
* 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*
* 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数,
* 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数
*/
export async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {},
): Promise<ClassDayGroup[]> {
const { mode } = options
const activity = activityRows({
since,
users: classMemberIds(className),
problemIds: options.problemIds,
language: options.language,
only: mode === 'code' || mode === 'flowchart' ? mode : undefined,
})
const day = sql<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const perUser = db
.select({
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(count(*) filter (where ${perUser.solved}))::int`,
})
.from(perUser)
.groupBy(perUser.day, perUser.problemId)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
}
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
async function visibleProblems(ids: number[]) {
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string; isSql: boolean }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
isSql: sql<boolean>`${schema.problem.languages} @> '["SQL"]'::jsonb`,
})
.from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
return new Map(rows.map((row) => [row.id, row]))
}
/**
* 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师;
* 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁
*/
async function lessonPlan(className: string, day: string) {
const [row] = await db
.select({
id: schema.classLesson.id,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
createdBy: sql<string>`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`,
})
.from(schema.classLesson)
.innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy))
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
.limit(1)
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 = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
*
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
* 学生最需要它的时候。
*/
export async function classLessonProblems(className: string, lookbackDays: number) {
const today = calendarDay()
const plan = await lessonPlan(className, today)
if (plan) {
const planned = plan.problemIds
const steps = await todayNeeds(className, planned, plan)
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
const [problems, ...groupLists] = await Promise.all([
visibleProblems(planned),
...[...byMode].map(([mode, ids]) =>
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
),
])
const groupById = new Map(groupLists.flat().map((row) => [row.problemId, row]))
return {
source: 'teacher' as const,
lessonId: plan.id,
day: today,
language: plan.language ?? null,
mode: plan.mode,
steps,
assignedAt: plan.createdAt,
assignedBy: plan.createdBy,
problems: planned.flatMap((id) => {
const problem = problems.get(id)
if (!problem) return []
const group = groupById.get(id)
return [
{
...problem,
userCount: group?.userCount ?? 0,
acceptedCount: group?.acceptedCount ?? 0,
},
]
}),
}
}
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
const latest = groups.reduce<string | null>((acc, row) => (!acc || row.day > acc ? row.day : acc), null)
const picked = groups
.filter((row) => row.day === latest)
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
.slice(0, CLASS_ACTIVITY_LIMIT)
const problems = await visibleProblems(picked.map((row) => row.problemId))
return {
source: picked.length ? ('inferred' as const) : null,
lessonId: null,
day: picked.length ? latest : null,
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null,
mode: null,
steps: null,
assignedAt: null,
assignedBy: null,
problems: picked.flatMap((row) => {
const problem = problems.get(row.problemId)
return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : []
}),
}
}
+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
@@ -52,7 +52,7 @@ export function dayNumber(day: string): number {
return Date.UTC(year!, month! - 1, date!) / DAY_MS return Date.UTC(year!, month! - 1, date!) / DAY_MS
} }
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */ /** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂任务用 */
export function localDateTime(day: string, time: string): string { export function localDateTime(day: string, time: string): string {
const [hour, minute] = time.split(':').map(Number) const [hour, minute] = time.split(':').map(Number)
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString() return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
+1 -1
View File
@@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`: Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码) - `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity` - `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂任务的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity`
- `layout/` — `default.vue` and `admin.vue` layout wrappers - `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha) - `api.ts` — shared API calls (auth, profile, tags, captcha)
+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])
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
/** /**
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、 * 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。 * 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
*/ */
const props = defineProps<{ const props = defineProps<{
detail: StuckProblemDetail detail: StuckProblemDetail
@@ -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;
@@ -1,4 +1,4 @@
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */ /** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
/** 百分比,取整;分母是 0 时给 0 */ /** 百分比,取整;分母是 0 时给 0 */
export function rate(part: number, whole: number) { export function rate(part: number, whole: number) {
File renamed without changes.
File renamed without changes.
+12 -8
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`)
} }
@@ -780,11 +784,11 @@ export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`) return api.delete(`admin/achievements/${id}`)
} }
// ---------- 课堂作业(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 },
}) })
} }
File renamed without changes.
@@ -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' },
] ]
}) })
@@ -94,7 +95,7 @@ const shortcuts = [
{ label: '新建题目', to: '/admin/problem/create' }, { label: '新建题目', to: '/admin/problem/create' },
{ label: '新建比赛', to: '/admin/contest/create' }, { label: '新建比赛', to: '/admin/contest/create' },
{ label: '批量建号', to: '/admin/user/generate' }, { label: '批量建号', to: '/admin/user/generate' },
{ label: '课堂看板', to: '/classroom' }, { label: '课堂任务', to: '/task' },
] ]
// ---------------------------------------------------------------- 抽一个人 // ---------------------------------------------------------------- 抽一个人
@@ -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">
@@ -170,7 +171,7 @@ onUnmounted(stopRolling)
<div class="tile"> <div class="tile">
<span class="label">正在上课</span> <span class="label">正在上课</span>
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b> <b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
<router-link v-if="info.activeClass" class="link" :to="{ name: 'classroom board', query: { className: info.activeClass } }"> 去课堂看板 → </router-link> <router-link v-if="info.activeClass" class="link" :to="{ name: 'task', query: { className: info.activeClass } }"> 去课堂任务 → </router-link>
<span v-else class="note">最近两小时没有哪个班在交题</span> <span v-else class="note">最近两小时没有哪个班在交题</span>
</div> </div>
</div> </div>
@@ -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 },
+45 -43
View File
@@ -7,7 +7,7 @@ import type { LearnStudentDetail } from 'utils/types'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose' import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { splitTitle, type TutorialType } from 'oj/learn/lesson' import { splitTitle, type TutorialType } from 'oj/learn/utils'
import { EXERCISE_KIND } from './kinds' import { EXERCISE_KIND } from './kinds'
/** /**
@@ -30,31 +30,31 @@ const theme = useThemeVars()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
useBackClose(show, () => !isDesktop.value) useBackClose(show, () => !isDesktop.value)
type Lesson = LearnStudentDetail['lessons'][number] type Tutorial = LearnStudentDetail['tutorials'][number]
type Exercise = Lesson['exercises'][number] type Exercise = Tutorial['exercises'][number]
type Problem = Lesson['problems'][number] type Problem = Tutorial['problems'][number]
const detail = ref<LearnStudentDetail | null>(null) const detail = ref<LearnStudentDetail | null>(null)
const loading = ref(false) const loading = ref(false)
const expanded = ref(new Set<number>()) const expanded = ref(new Set<number>())
const summary = computed(() => { const summary = computed(() => {
const lessons = detail.value?.lessons ?? [] const tutorials = detail.value?.tutorials ?? []
const exercises = lessons.flatMap((lesson) => lesson.exercises) const exercises = tutorials.flatMap((tutorial) => tutorial.exercises)
// 一道题挂在两课只算一次 // 一道题挂在两课只算一次
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p])) const problems = new Map(tutorials.flatMap((tutorial) => tutorial.problems).map((p) => [p.id, p]))
return { return {
problemSolved: [...problems.values()].filter((p) => p.solved).length, problemSolved: [...problems.values()].filter((p) => p.solved).length,
problemTotal: problems.size, problemTotal: problems.size,
opened: lessons.filter((lesson) => lesson.viewCount > 0).length, opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0), seconds: tutorials.reduce((n, tutorial) => n + tutorial.totalSeconds, 0),
solved: exercises.filter((ex) => ex.solved).length, solved: exercises.filter((ex) => ex.solved).length,
total: exercises.length, total: exercises.length,
} }
}) })
function solvedOf(lesson: Lesson) { function solvedOf(tutorial: Tutorial) {
return lesson.exercises.filter((ex) => ex.solved).length return tutorial.exercises.filter((ex) => ex.solved).length
} }
function stateOf(ex: Exercise) { function stateOf(ex: Exercise) {
@@ -92,13 +92,13 @@ watch(
const data = await getLearnStudent(userId, { type }) const data = await getLearnStudent(userId, { type })
detail.value = data detail.value = data
expanded.value = new Set( expanded.value = new Set(
data.lessons data.tutorials
.filter( .filter(
(lesson) => (tutorial) =>
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) || (tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
lesson.problems.some((p) => p.submissions > 0 && !p.solved), tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
) )
.map((lesson) => lesson.tutorialId), .map((tutorial) => tutorial.tutorialId),
) )
} catch { } catch {
detail.value = null detail.value = null
@@ -133,40 +133,42 @@ watch(
'--bad': theme.errorColor, '--bad': theme.errorColor,
}" }"
> >
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div> <div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson"> <div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)"> <button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
<span class="lesson-line"> <span class="tutorial-line">
<span class="lesson-title"> <span class="tutorial-title">
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span> <span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
{{ splitTitle(lesson.title).kp }} {{ splitTitle(tutorial.title).kp }}
</span> </span>
<span class="grow" /> <span class="grow" />
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span> <span v-if="tutorial.exercises.length" class="small num"> {{ solvedOf(tutorial) }}/{{ tutorial.exercises.length }} </span>
<span class="squares"> <span class="squares">
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" /> <span v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
</span> </span>
<Icon <Icon
v-if="lesson.exercises.length || lesson.problems.length" v-if="tutorial.exercises.length || tutorial.problems.length"
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="12" :width="12"
class="muted" class="muted"
/> />
</span> </span>
<span class="lesson-line small muted num"> <span class="tutorial-line small muted num">
<template v-if="lesson.viewCount"> <template v-if="tutorial.viewCount">
打开 {{ lesson.viewCount }} 次 打开 {{ tutorial.viewCount }} 次
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template> <template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template> <template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.lastViewedAt, 'M月D日 HH:mm') }}</template>
</template> </template>
<template v-else>没打开过,直接做的练一练</template> <template v-else>没打开过,直接做的练一练</template>
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template> <template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template> <template v-if="tutorial.problems.length">
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.problems.length }}</template
>
</span> </span>
</button> </button>
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list"> <div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex"> <div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
<span class="square" :class="stateOf(ex)" /> <span class="square" :class="stateOf(ex)" />
<div class="ex-body"> <div class="ex-body">
<div class="ex-question"> <div class="ex-question">
@@ -183,8 +185,8 @@ watch(
</div> </div>
</div> </div>
</div> </div>
<div v-if="lesson.problems.length" class="sub-head small muted">配套题</div> <div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
<div v-for="problem in lesson.problems" :key="problem.id" class="ex"> <div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
<span class="square" :class="problemStateOf(problem)" /> <span class="square" :class="problemStateOf(problem)" />
<div class="ex-body"> <div class="ex-body">
<div class="ex-question"> <div class="ex-question">
@@ -235,13 +237,13 @@ watch(
padding: 24px 0; padding: 24px 0;
text-align: center; text-align: center;
} }
.lesson { .tutorial {
border-top: 1px solid rgba(128, 128, 128, 0.15); border-top: 1px solid rgba(128, 128, 128, 0.15);
} }
.lesson:first-child { .tutorial:first-child {
border-top: 0; border-top: 0;
} }
.lesson-head { .tutorial-head {
width: 100%; width: 100%;
padding: 12px 0; padding: 12px 0;
border: 0; border: 0;
@@ -254,13 +256,13 @@ watch(
gap: 4px; gap: 4px;
cursor: pointer; cursor: pointer;
} }
.lesson-line { .tutorial-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
} }
.lesson-title { .tutorial-title {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
min-width: 0; min-width: 0;
+47 -46
View File
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson' import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
import 'oj/learn/learn.css' import 'oj/learn/learn.css'
import ExerciseAttempts from './ExerciseAttempts.vue' import ExerciseAttempts from './ExerciseAttempts.vue'
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue' import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
@@ -30,7 +30,7 @@ const type = ref<TutorialType>('python')
const className = ref<string | null>(null) const className = ref<string | null>(null)
const board = ref<LearnBoard | null>(null) const board = ref<LearnBoard | null>(null)
const exercises = ref<LearnExerciseProgress[]>([]) const exercises = ref<LearnExerciseProgress[]>([])
const lessonId = ref<number | null>(null) const selectedTutorialId = ref<number | null>(null)
const expanded = ref<number | null>(null) const expanded = ref<number | null>(null)
const side = ref<'exercise' | 'problem'>('exercise') const side = ref<'exercise' | 'problem'>('exercise')
const problemOpen = ref<number | null>(null) const problemOpen = ref<number | null>(null)
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
} }
return counts return counts
}) })
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems]))) const tutorialProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
/** 学生 → 题 → 交过的情况 */ /** 学生 → 题 → 交过的情况 */
const problemStates = computed( const problemStates = computed(
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])), () => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
) )
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal]))) const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
const steps = computed(() => new Map(groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step])))) const steps = computed(() => new Map(groups.value.flatMap((group) => group.tutorials.map((tutorial) => [tutorial.id, tutorial.step]))))
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values())) const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
const lessonOptions = computed(() => const tutorialOptions = computed(() =>
groups.value.flatMap((group) => groups.value.flatMap((group) =>
group.lessons.map((lesson) => ({ group.tutorials.map((tutorial) => ({
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`, label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
value: lesson.id, value: tutorial.id,
})), })),
), ),
) )
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value)) const tutorialExercises = computed(() => exercises.value.filter((row) => row.tutorialId === selectedTutorialId.value))
/** 读过(或做过练一练)这一课的人 */ /** 读过(或做过练一练)这一课的人 */
function readersOf(id: number) { function readersOf(id: number) {
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id)) return started.value.filter((row) => row.opened.includes(id) || row.tutorials.some((tutorial) => tutorial.tutorialId === id))
} }
const sideProblems = computed(() => { const sideProblems = computed(() => {
const id = lessonId.value const id = selectedTutorialId.value
if (id === null) return [] if (id === null) return []
const readers = readersOf(id) const readers = readersOf(id)
return (lessonProblems.value.get(id) ?? []).map((problem) => { return (tutorialProblems.value.get(id) ?? []).map((problem) => {
const people: ProblemReader[] = readers.map((row) => ({ const people: ProblemReader[] = readers.map((row) => ({
userId: row.userId, userId: row.userId,
username: row.username, username: row.username,
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
}) })
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value)) const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
function lessonOf(row: LearnBoardStudent, id: number) { function tutorialOf(row: LearnBoardStudent, id: number) {
return row.lessons.find((lesson) => lesson.tutorialId === id) return row.tutorials.find((tutorial) => tutorial.tutorialId === id)
} }
function cellOf(row: LearnBoardStudent, id: number) { function cellOf(row: LearnBoardStudent, id: number) {
if (row.done.includes(id)) return 'done' if (row.done.includes(id)) return 'done'
const lesson = lessonOf(row, id) const tutorial = tutorialOf(row, id)
if (lesson) return lesson.solved ? 'partial' : 'partial stuck' if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
if (row.opened.includes(id)) return 'opened' if (row.opened.includes(id)) return 'opened'
return '' return ''
} }
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */ /** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
function cellStyle(row: LearnBoardStudent, id: number) { function cellStyle(row: LearnBoardStudent, id: number) {
const lesson = lessonOf(row, id) const tutorial = tutorialOf(row, id)
const total = exerciseTotal.value.get(id) ?? 0 const total = exerciseTotal.value.get(id) ?? 0
if (!lesson || !total || row.done.includes(id)) return undefined if (!tutorial || !total || row.done.includes(id)) return undefined
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` } return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
} }
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) { function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
const head = `第 ${step} 课 ${splitTitle(title).kp}` const head = `第 ${step} 课 ${splitTitle(title).kp}`
const total = exerciseTotal.value.get(id) ?? 0 const total = exerciseTotal.value.get(id) ?? 0
const lesson = lessonOf(row, id) const tutorial = tutorialOf(row, id)
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : '' const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
const problems = lessonProblems.value.get(id) ?? [] const problems = tutorialProblems.value.get(id) ?? []
const states = problemStates.value.get(row.userId) const states = problemStates.value.get(row.userId)
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : '' const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}` if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}` if (tutorial) return `${head}:练一练 ${tutorial.solved}/${total}${wrong}${tail}`
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}` if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
return `${head}:没打开` return `${head}:没打开`
} }
/** 他打开过或做过练一练的那些课 */ /** 他打开过或做过练一练的那些课 */
function touched(row: LearnBoardStudent) { function touched(row: LearnBoardStudent) {
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)]) return new Set([...row.opened, ...row.tutorials.map((tutorial) => tutorial.tutorialId)])
} }
/** 应做 = 他碰过的那些课,练一练加起来几道 */ /** 应做 = 他碰过的那些课,练一练加起来几道 */
@@ -148,7 +148,7 @@ function exerciseDue(row: LearnBoardStudent) {
function problemCount(row: LearnBoardStudent) { function problemCount(row: LearnBoardStudent) {
const ids = new Set<number>() const ids = new Set<number>()
for (const id of touched(row)) { for (const id of touched(row)) {
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id) for (const problem of tutorialProblems.value.get(id) ?? []) ids.add(problem.id)
} }
const states = problemStates.value.get(row.userId) const states = problemStates.value.get(row.userId)
let solved = 0 let solved = 0
@@ -180,7 +180,7 @@ async function load() {
exercises.value = ex.results exercises.value = ex.results
// 练一练默认看大家打开得最多的那课 // 练一练默认看大家打开得最多的那课
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0] const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null selectedTutorialId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -235,11 +235,11 @@ onMounted(load)
<div class="line head"> <div class="line head">
<span class="c-name strong">学到哪了</span> <span class="c-name strong">学到哪了</span>
<div class="cells-head"> <div class="cells-head">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-head">
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span> <span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
<span class="group-steps"> <span class="group-steps">
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp"> <span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
{{ lesson.step }} {{ tutorial.step }}
</span> </span>
</span> </span>
</div> </div>
@@ -254,14 +254,14 @@ onMounted(load)
<UserName :username="row.username" /> <UserName :username="row.username" />
</button> </button>
<div class="cells"> <div class="cells">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
<span <span
v-for="lesson in group.lessons" v-for="tutorial in group.tutorials"
:key="lesson.id" :key="tutorial.id"
class="cell" class="cell"
:class="cellOf(row, lesson.id)" :class="cellOf(row, tutorial.id)"
:style="cellStyle(row, lesson.id)" :style="cellStyle(row, tutorial.id)"
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)" :title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
/> />
</div> </div>
</div> </div>
@@ -284,14 +284,14 @@ onMounted(load)
<div v-else class="line foot"> <div v-else class="line foot">
<span class="c-name muted small">每课打开过</span> <span class="c-name muted small">每课打开过</span>
<div class="cells bars"> <div class="cells bars">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col"> <span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span> <span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
<span <span
class="bar" class="bar"
:class="{ zero: !openedCount.get(lesson.id) }" :class="{ zero: !openedCount.get(tutorial.id) }"
:style="{ :style="{
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`, height: `${Math.max(2, ((openedCount.get(tutorial.id) ?? 0) / maxOpened) * 40)}px`,
}" }"
/> />
</span> </span>
@@ -316,7 +316,7 @@ onMounted(load)
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button> <button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button> <button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
</div> </div>
<n-select v-model:value="lessonId" :options="lessonOptions" size="small" class="lesson-select" /> <n-select v-model:value="selectedTutorialId" :options="tutorialOptions" size="small" class="tutorial-select" />
</div> </div>
<template v-if="side === 'problem'"> <template v-if="side === 'problem'">
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div> <div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
@@ -333,8 +333,8 @@ onMounted(load)
</button> </button>
</div> </div>
</template> </template>
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div> <div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex"> <div v-for="row in side === 'exercise' ? tutorialExercises : []" :key="row.exerciseId" class="ex">
<button type="button" class="ex-main" @click="expanded = row.exerciseId"> <button type="button" class="ex-main" @click="expanded = row.exerciseId">
<span class="ex-line"> <span class="ex-line">
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span> <span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
@@ -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;
@@ -711,7 +712,7 @@ onMounted(load)
.class-select { .class-select {
width: 170px; width: 170px;
} }
.lesson-select { .tutorial-select {
flex: 1 0 100%; flex: 1 0 100%;
min-width: 0; min-width: 0;
} }
@@ -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,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants' import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
import { createZipBlob, getTagColor, unique } from 'utils/functions' import { createZipBlob, getTagColor, unique } from 'utils/functions'
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types' import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
import PreviewStrip from './components/PreviewStrip.vue' import PreviewStrip from './PreviewStrip.vue'
import { usePreviewCollapsed } from './components/previewCollapse' import { usePreviewCollapsed } from './previewCollapse'
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api' import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm' import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript' import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue')) const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */ /** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
+1 -1
View File
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
import type { AdminProblem } from 'utils/types' import type { AdminProblem } from 'utils/types'
import { getProblem } from '../api' import { getProblem } from '../api'
import ProblemDetail from './detail.vue' import ProblemDetail from './detail.vue'
import SqlDetail from './SqlDetail.vue' import SqlDetail from './components/SqlDetail.vue'
/** /**
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。 * 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
+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" />
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract' import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue' import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
@@ -10,8 +10,8 @@ import { parseTime } from 'utils/functions'
import { createAdminLesson, updateAdminLesson } from '../../api' import { createAdminLesson, updateAdminLesson } from '../../api'
/** /**
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置, * 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑 * 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
*/ */
const props = defineProps<{ const props = defineProps<{
/** null = 补一节 */ /** null = 补一节 */
@@ -54,7 +54,7 @@ function inputIds() {
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean) return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
} }
// ---- 选题弹框,和看板上那个是同一个 ---- // ---- 选题弹框,和任务页上那个是同一个 ----
const picking = ref(false) const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([]) const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() { function openPicker() {
@@ -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'
@@ -17,8 +17,8 @@ import { deleteAdminLesson, getAdminLessons } from '../api'
import LessonEditModal from './components/LessonEditModal.vue' import LessonEditModal from './components/LessonEditModal.vue'
/** /**
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里, * 后台「课堂任务」:布置过的每一节课一行,新的在前。课堂任务上当场布置的都在这里,
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到 * 布置错了能改、删;任务页外上的课能补一节 —— 数据统计按布置切课,补了才认得到
*/ */
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
@@ -26,14 +26,17 @@ const theme = useThemeVars()
const configStore = useConfigStore() 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 },
@@ -163,7 +174,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
</script> </script>
<template> <template>
<PageHeader title="课堂作业"> <PageHeader title="课堂任务">
<template #extra> <template #extra>
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text> <n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
</template> </template>
@@ -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;
+6 -6
View File
@@ -4,9 +4,9 @@ import type { AdminTutorialProblem } from '@oj2/contract'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import MarkdownEditor from 'shared/components/MarkdownEditor.vue' import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types' import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
import { parseLesson } from 'oj/learn/composables/useLessonParse' import { parseTutorial } from 'oj/learn/composables/useTutorialParse'
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson' import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
import LessonBody from 'oj/learn/components/LessonBody.vue' import TutorialBody from 'oj/learn/components/TutorialBody.vue'
import 'oj/learn/learn.css' import 'oj/learn/learn.css'
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api' import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
import ExerciseManager from './components/ExerciseManager.vue' import ExerciseManager from './components/ExerciseManager.vue'
@@ -69,7 +69,7 @@ const heading = computed(() => {
const label = tutorial.type === 'c' ? 'C 语言' : 'Python' const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开` return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
}) })
const preview = computed(() => parseLesson(tutorial.content, exercises.value)) const preview = computed(() => parseTutorial(tutorial.content, exercises.value))
async function init() { async function init() {
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] })) allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
} }
function openStudentView() { function openStudentView() {
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank') if (step.value) window.open(router.resolve(tutorialPath(tutorial.type, step.value)).href, '_blank')
} }
watch(() => props.tutorialID, init) watch(() => props.tutorialID, init)
@@ -199,7 +199,7 @@ onMounted(init)
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" /> <MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
</div> </div>
<div class="preview"> <div class="preview">
<LessonBody <TutorialBody
:segments="preview.segments" :segments="preview.segments"
:lang="tutorial.type" :lang="tutorial.type"
:tutorial-id="tutorial.id" :tutorial-id="tutorial.id"
+6 -6
View File
@@ -2,7 +2,7 @@
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import type { TutorialListItem } from 'utils/types' import type { TutorialListItem } from 'utils/types'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson' import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
import ProjectTile from 'oj/learn/components/ProjectTile.vue' import ProjectTile from 'oj/learn/components/ProjectTile.vue'
import 'oj/learn/learn.css' import 'oj/learn/learn.css'
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api' import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
@@ -28,11 +28,11 @@ const list = computed(() => all.value[type.value] ?? [])
const groups = computed(() => const groups = computed(() =>
groupByProject(list.value) groupByProject(list.value)
.map((group) => { .map((group) => {
const items = group.lessons.map((lesson) => list.value.find((item) => item.id === lesson.id)!) const items = group.tutorials.map((entry) => list.value.find((item) => item.id === entry.id)!)
return { return {
...group, ...group,
items, items,
key: `${group.project}:${group.lessons[0]!.id}`, key: `${group.project}:${group.tutorials[0]!.id}`,
hidden: items.every((item) => !item.isPublic), hidden: items.every((item) => !item.isPublic),
} }
}) })
@@ -189,7 +189,7 @@ onMounted(load)
<div <div
v-for="item in group.items" v-for="item in group.items"
:key="item.id" :key="item.id"
class="row lesson" class="row tutorial"
:class="{ off: !item.isPublic, dragging: dragId === item.id }" :class="{ off: !item.isPublic, dragging: dragId === item.id }"
draggable="true" draggable="true"
@dragstart="dragId = item.id" @dragstart="dragId = item.id"
@@ -330,10 +330,10 @@ onMounted(load)
.group-name { .group-name {
font-weight: 700; font-weight: 700;
} }
.lesson.off { .tutorial.off {
opacity: 0.6; opacity: 0.6;
} }
.lesson.dragging { .tutorial.dragging {
opacity: 0.35; opacity: 0.35;
} }
.handle { .handle {
+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
+18 -15
View File
@@ -26,7 +26,7 @@ import {
type Tutorial, type Tutorial,
type ClassToday, type ClassToday,
type LastVisit, type LastVisit,
type ClassBoard, type TaskBoard,
type ClassLesson, type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassBattleItem, type ClassBattleItem,
@@ -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', {
@@ -251,14 +252,14 @@ export function getWeeklyRank(scope: 'global' | 'class') {
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } }) return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
} }
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */ /** 课堂任务(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getClassBoard(className?: string) { export function getTaskBoard(className?: string) {
return api.get<ClassBoard>('classroom/board', { params: { className } }) return api.get<TaskBoard>('task/board', { params: { className } })
} }
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */ /** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和任务页一样 */
export function getClassLesson(className?: string) { export function getClassLesson(className?: string) {
return api.get<ClassLesson>('classroom/lesson', { params: { className } }) return api.get<ClassLesson>('task/lesson', { params: { className } })
} }
/** /**
@@ -266,7 +267,7 @@ export function getClassLesson(className?: string) {
* (一个班一天可以上几节内容不同的课),否则改那一节 * (一个班一天可以上几节内容不同的课),否则改那一节
*/ */
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) { export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
return api.put<null>('classroom/lesson', { return api.put<null>('task/lesson', {
className, className,
problemDisplayIds, problemDisplayIds,
language, language,
@@ -282,7 +283,7 @@ export function getKnowledgeMap() {
/** 老师首页「今天的课」:今天在上课的每个班 */ /** 老师首页「今天的课」:今天在上课的每个班 */
export function getClassToday() { export function getClassToday() {
return api.get<ClassToday>('classroom/today') return api.get<ClassToday>('task/today')
} }
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */ /** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
@@ -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 ''
+20 -10
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'
@@ -18,7 +19,7 @@ import { useVisibleInterval } from 'shared/composables/visibleInterval'
* *
* 名字和排行榜一样用用户名(ks241XXX),不用真名。 * 名字和排行榜一样用用户名(ks241XXX),不用真名。
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面, * 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
* 没进来的人在最下面(看板里标了请假的写「请假」)。 * 没进来的人在最下面(课堂任务里标了请假的写「请假」)。
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。 * 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView) * 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
*/ */
@@ -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>
@@ -357,7 +367,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
<span v-for="p in problems" :key="p.id"></span> <span v-for="p in problems" :key="p.id"></span>
<span></span> <span></span>
<span class="flag-wrap"> <span class="flag-wrap">
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span> <span class="flag grey">{{ user.leave ? '请假(课堂任务里标的)' : '没进来' }}</span>
</span> </span>
</div> </div>
</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>
+5 -5
View File
@@ -4,23 +4,23 @@ import storage from 'utils/storage'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份 // 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
const Dashboard = defineAsyncComponent(() => import('./dashboard.vue')) const StudentHome = defineAsyncComponent(() => import('./student.vue'))
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课 // 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
const TeacherHome = defineAsyncComponent(() => import('./TeacherHome.vue')) const TeacherHome = defineAsyncComponent(() => import('./teacher.vue'))
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue')) const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
const userStore = useUserStore() const userStore = useUserStore()
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是 // profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表 // 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
const showDashboard = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED))) const showHome = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED)))
</script> </script>
<template> <template>
<template v-if="showDashboard"> <template v-if="showHome">
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 --> <!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" /> <TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
<Dashboard v-else-if="userStore.isFinished" /> <StudentHome v-else-if="userStore.isFinished" />
</template> </template>
<ProblemList v-else /> <ProblemList v-else />
</template> </template>
@@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from 'oj/learn/lesson' import { displayName } from '@oj2/contract'
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)
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */ /** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
@@ -74,7 +79,7 @@ const weeklyText = computed(() => {
}) })
/** /**
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的: * 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/utils.ts),不是本机记的:
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。 * 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/ */
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> { async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
@@ -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() {
@@ -187,10 +193,10 @@ const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${i
const classActivityTitle = computed(() => { const classActivityTitle = computed(() => {
const day = classActivity.value?.day const day = classActivity.value?.day
if (!day) return '' if (!day) return ''
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
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,8 +248,88 @@ 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 lessonPath(track.type, track.step) return tutorialPath(track.type, track.step)
} }
function openSubmission(row: SubmissionListItem) { function openSubmission(row: SubmissionListItem) {
@@ -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>
@@ -1,16 +1,18 @@
<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'
/** /**
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交), * 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、 * 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。 * 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前) * 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
*/ */
const theme = useThemeVars() const theme = useThemeVars()
@@ -61,13 +63,39 @@ function metaOf(row: ClassRow) {
return parts.join(' · ') return parts.join(' · ')
} }
function openBoard(row: ClassRow) { function openTask(row: ClassRow) {
router.push({ name: 'classroom board', 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: '/classroom', show: true }, { label: '课堂任务', to: '/task', show: true },
{ label: '数据统计', to: '/statistics', show: true }, { label: '数据统计', to: '/statistics', show: true },
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission }, { label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true }, { label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', 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>
@@ -92,7 +120,7 @@ const shortcuts = computed(() =>
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span> <span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
<span class="muted">{{ metaOf(row) }}</span> <span class="muted">{{ metaOf(row) }}</span>
<span class="spacer"></span> <span class="spacer"></span>
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openBoard(row)"> 课堂看板 </n-button> <n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openTask(row)"> 课堂任务 </n-button>
</div> </div>
<div v-if="row.problems.length" class="minis"> <div v-if="row.problems.length" class="minis">
<router-link <router-link
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { ProjectGroup } from '../lesson' import type { ProjectGroup } from '../utils'
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */ /** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>() defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { TutorialProgress } from '@oj2/contract' import type { TutorialProgress } from '@oj2/contract'
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from '../lesson' import { tutorialPath, tutorialState, type ProjectGroup, type TutorialType } from '../utils'
import LessonRow from './LessonRow.vue' import TutorialRow from './TutorialRow.vue'
import ProjectTile from './ProjectTile.vue' import ProjectTile from './ProjectTile.vue'
/** /**
@@ -21,14 +21,14 @@ const emit = defineEmits<{ go: [] }>()
const open = ref(new Set<string>()) const open = ref(new Set<string>())
function keyOf(group: ProjectGroup) { function keyOf(group: ProjectGroup) {
return `${group.project}:${group.lessons[0]!.id}` return `${group.project}:${group.tutorials[0]!.id}`
} }
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收) // 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
watch( watch(
() => [props.groups, props.currentStep] as const, () => [props.groups, props.currentStep] as const,
([groups, step]) => { ([groups, step]) => {
const group = groups.find((item) => item.lessons.some((lesson) => lesson.step === step)) const group = groups.find((item) => item.tutorials.some((tutorial) => tutorial.step === step))
if (group) open.value = new Set([...open.value, keyOf(group)]) if (group) open.value = new Set([...open.value, keyOf(group)])
}, },
{ immediate: true }, { immediate: true },
@@ -43,7 +43,7 @@ function toggle(group: ProjectGroup) {
} }
function doneIn(group: ProjectGroup) { function doneIn(group: ProjectGroup) {
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === 'done').length return group.tutorials.filter((tutorial) => tutorialState(props.progress[tutorial.id]) === 'done').length
} }
</script> </script>
@@ -62,23 +62,23 @@ function doneIn(group: ProjectGroup) {
{{ group.name }} {{ group.name }}
</span> </span>
</span> </span>
<span v-if="doneIn(group) === group.lessons.length" class="count all"> <span v-if="doneIn(group) === group.tutorials.length" class="count all">
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }} <Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.tutorials.length }}
</span> </span>
<span v-else class="count">{{ doneIn(group) }}/{{ group.lessons.length }}</span> <span v-else class="count">{{ doneIn(group) }}/{{ group.tutorials.length }}</span>
<span v-if="big" class="chev"> <span v-if="big" class="chev">
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" /> <Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
</span> </span>
</button> </button>
<div v-if="open.has(keyOf(group))" class="lessons"> <div v-if="open.has(keyOf(group))" class="tutorials">
<LessonRow <TutorialRow
v-for="lesson in group.lessons" v-for="tutorial in group.tutorials"
:key="lesson.id" :key="tutorial.id"
:title="lesson.title" :title="tutorial.title"
:step="lesson.step" :step="tutorial.step"
:to="lessonPath(type, lesson.step)" :to="tutorialPath(type, tutorial.step)"
:progress="progress[lesson.id]" :progress="progress[tutorial.id]"
:active="lesson.step === currentStep" :active="tutorial.step === currentStep"
:height="big ? 50 : 46" :height="big ? 50 : 46"
@go="emit('go')" @go="emit('go')"
/> />
@@ -160,13 +160,13 @@ function doneIn(group: ProjectGroup) {
color: var(--learn-green-text); color: var(--learn-green-text);
font-weight: 600; font-weight: 600;
} }
.lessons { .tutorials {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding-left: 6px; padding-left: 6px;
} }
.big .lessons { .big .tutorials {
padding: 0 0 8px; padding: 0 0 8px;
} }
</style> </style>
+6 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LessonState, ProjectGroup } from '../lesson' import type { TutorialState, ProjectGroup } from '../utils'
/** /**
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始, * 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
@@ -7,7 +7,7 @@ import type { LessonState, ProjectGroup } from '../lesson'
*/ */
defineProps<{ defineProps<{
groups: ProjectGroup[] groups: ProjectGroup[]
stateOf: (id: number) => LessonState stateOf: (id: number) => TutorialState
current?: number current?: number
/** 每段的宽、高 */ /** 每段的宽、高 */
width?: number width?: number
@@ -19,12 +19,12 @@ defineProps<{
<template> <template>
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true"> <div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }"> <div v-for="group in groups" :key="group.tutorials[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }">
<span <span
v-for="lesson in group.lessons" v-for="tutorial in group.tutorials"
:key="lesson.id" :key="tutorial.id"
class="cell" class="cell"
:class="[stateOf(lesson.id), { current: lesson.id === current }]" :class="[stateOf(tutorial.id), { current: tutorial.id === current }]"
:style="{ :style="{
width: `${width ?? 20}px`, width: `${width ?? 20}px`,
height: `${height ?? 6}px`, height: `${height ?? 6}px`,
@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import Markdown from 'shared/components/Markdown.vue' import Markdown from 'shared/components/Markdown.vue'
import type { LessonSegment } from '../composables/useLessonParse' import type { TutorialSegment } from '../composables/useTutorialParse'
import CodeRunBlock from './CodeRunBlock.vue' import CodeRunBlock from './CodeRunBlock.vue'
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue')) const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */ /** 一课的正文:按 useTutorialParse 拆好的段落逐段渲染 */
defineProps<{ defineProps<{
segments: LessonSegment[] segments: TutorialSegment[]
lang: 'python' | 'c' lang: 'python' | 'c'
tutorialId: number tutorialId: number
exerciseCount: number exerciseCount: number
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { LessonState } from '../lesson' import type { TutorialState } from '../utils'
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */ /** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
defineProps<{ step: number; state: LessonState; current?: boolean }>() defineProps<{ step: number; state: TutorialState; current?: boolean }>()
</script> </script>
<template> <template>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { TutorialProblem } from '@oj2/contract' import type { TutorialProblem } from '@oj2/contract'
import { splitTitle, type LessonState, type ProjectGroup } from '../lesson' import { splitTitle, type TutorialState, type ProjectGroup } from '../utils'
import SegBar from './SegBar.vue' import SegBar from './SegBar.vue'
/** /**
@@ -19,7 +19,7 @@ const props = defineProps<{
problems: TutorialProblem[] problems: TutorialProblem[]
next: { title: string; to: string } | null next: { title: string; to: string } | null
groups: ProjectGroup[] groups: ProjectGroup[]
stateOf: (id: number) => LessonState stateOf: (id: number) => TutorialState
doneCount: number doneCount: number
/** 窄屏:进度条短一点、题号挪到题目名下面 */ /** 窄屏:进度条短一点、题号挪到题目名下面 */
compact?: boolean compact?: boolean
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import type { OutlineItem } from '../composables/useLessonParse' import type { OutlineItem } from '../composables/useTutorialParse'
/** /**
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾), * 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TutorialProgress } from '@oj2/contract' import type { TutorialProgress } from '@oj2/contract'
import { lessonState, splitTitle } from '../lesson' import { tutorialState, splitTitle } from '../utils'
import LessonDot from './LessonDot.vue' import TutorialDot from './TutorialDot.vue'
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */ /** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
const props = defineProps<{ const props = defineProps<{
@@ -16,12 +16,12 @@ const props = defineProps<{
const emit = defineEmits<{ go: [] }>() const emit = defineEmits<{ go: [] }>()
const parts = computed(() => splitTitle(props.title)) const parts = computed(() => splitTitle(props.title))
const state = computed(() => lessonState(props.progress)) const state = computed(() => tutorialState(props.progress))
</script> </script>
<template> <template>
<router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')"> <router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')">
<LessonDot :step="step" :state="state" :current="active" /> <TutorialDot :step="step" :state="state" :current="active" />
<span class="text"> <span class="text">
<span class="kp">{{ parts.kp }}</span> <span class="kp">{{ parts.kp }}</span>
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span> <span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
@@ -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 里照常显示;
@@ -14,7 +14,7 @@ import type { Exercise } from 'utils/types'
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方 * 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。 * 已经有了,每课都重复一张同样的项目图只是把正文往下推。
*/ */
export type LessonSegment = export type TutorialSegment =
| { type: 'md'; content: string } | { type: 'md'; content: string }
| { type: 'heading'; id: string; text: string } | { type: 'heading'; id: string; text: string }
| { type: 'goals'; id: string; items: string[] } | { type: 'goals'; id: string; items: string[] }
@@ -57,9 +57,9 @@ function stripPreamble(lines: string[]) {
return lines.slice(i) return lines.slice(i)
} }
export function parseLesson(content: string, exercises: Exercise[]) { export function parseTutorial(content: string, exercises: Exercise[]) {
const byId = new Map(exercises.map((item) => [item.id, item])) const byId = new Map(exercises.map((item) => [item.id, item]))
const segments: LessonSegment[] = [] const segments: TutorialSegment[] = []
const outline: OutlineItem[] = [] const outline: OutlineItem[] = []
let md: string[] = [] let md: string[] = []
let codeIndex = 0 let codeIndex = 0
-516
View File
@@ -1,516 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { TutorialProgress, TutorialSummary } from '@oj2/contract'
import { getLearnProgress, getTutorials } from '../api'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { COURSES, continueStep, doneCount, groupByProject, lessonPath, lessonState, splitProject, splitTitle, type TutorialType } from './lesson'
import LessonRow from './components/LessonRow.vue'
import ProjectTile from './components/ProjectTile.vue'
import ProjectTree from './components/ProjectTree.vue'
import SegBar from './components/SegBar.vue'
import './learn.css'
/**
* 自学首页 /learn(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
*
* 「接着学」看的是**服务端留痕**里最近打开的那一课,不是本机记的:机房一台电脑
* 轮着好几个班用,本地记的是上一个人学到哪。
*/
const userStore = useUserStore()
const authModal = useAuthModalStore()
const { isDesktop } = useBreakpoints()
const route = useRoute()
const router = useRouter()
interface Course {
type: TutorialType
label: string
titles: TutorialSummary[]
progress: Record<number, TutorialProgress>
}
const courses = ref<Course[]>([])
const loaded = ref(false)
const tab = ref<TutorialType>(route.query.course === 'c' ? 'c' : 'python')
async function load() {
const authed = userStore.isAuthed
courses.value = await Promise.all(
COURSES.map(async ({ type, label }) => {
const [titles, rows] = await Promise.all([getTutorials(type), authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([])])
return {
type,
label,
titles,
progress: Object.fromEntries(rows.map((row) => [row.tutorialId, row])),
}
}),
)
// 没指定看哪门课时,打开最近在学的那门
if (!route.query.course && resume.value) tab.value = resume.value.course.type
loaded.value = true
}
function latestViewed(course: Course) {
return Object.values(course.progress).reduce<string>((acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc), '')
}
/** 接着学:最近打开过的那门课里该接着学的那课 */
const resume = computed(() => {
const started = courses.value.filter((course) => course.titles.length && latestViewed(course)).sort((a, b) => latestViewed(b).localeCompare(latestViewed(a)))
const course = started[0]
if (!course) return null
const step = continueStep(course.titles, course.progress)
if (!step) return null
const item = course.titles[step - 1]!
return { course, step, item, parts: splitTitle(item.title), project: splitProject(item.project) }
})
const others = computed(() => courses.value.filter((course) => course.titles.length && course.type !== resume.value?.course.type))
const current = computed(() => courses.value.find((course) => course.type === tab.value))
const groups = computed(() => groupByProject(current.value?.titles ?? []))
function stateOf(course: Course) {
return (id: number) => lessonState(course.progress[id])
}
function firstLesson(course: Course) {
return course.titles[0] ? splitTitle(course.titles[0].title) : null
}
function courseStep(course: Course) {
return continueStep(course.titles, course.progress)
}
function pickTab(type: TutorialType) {
tab.value = type
router.replace({ query: { ...route.query, course: type } })
}
function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => lessonState(course.progress[id]) === 'done').length
}
watch(() => userStore.isAuthed, load)
onMounted(load)
</script>
<template>
<div class="home">
<template v-if="loaded">
<!-- 第一排:接着学 + 另一门课;一课没学过就是两张「开始学」 -->
<div class="top" :class="{ two: !resume }">
<section v-if="resume" class="resume">
<div class="resume-main">
<div class="resume-meta">
<span class="resume-label">接着学</span>
<span class="muted">
{{ resume.course.label }}
<template v-if="resume.item.project"> · {{ resume.item.project }}</template>
· 第 {{ resume.step }} 课
</span>
</div>
<div class="resume-title">
<span class="resume-kp">{{ resume.parts.kp }}</span>
<span v-if="resume.parts.scene" class="resume-scene">{{ resume.parts.scene }}</span>
</div>
<div class="resume-progress">
<SegBar
:groups="groupByProject(resume.course.titles)"
:state-of="stateOf(resume.course)"
:current="resume.item.id"
:width="isDesktop ? 20 : 11"
:gap="isDesktop ? 3 : 2"
:group-gap="isDesktop ? 10 : 6"
/>
<span class="num sec"> 学完 {{ doneCount(resume.course.titles, resume.course.progress) }} / {{ resume.course.titles.length }} 课 </span>
</div>
</div>
<n-button type="primary" size="large" class="resume-go" icon-placement="right" @click="router.push(lessonPath(resume.course.type, resume.step))">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
接着学
</n-button>
</section>
<!-- 手机上有了「接着学」就不再摆另一门课的卡,下面的页签切得到 -->
<section v-for="course in resume ? (isDesktop ? others : []) : courses.filter((c) => c.titles.length)" :key="course.type" class="course card">
<div class="course-text">
<div class="course-head">
<span class="course-name">{{ course.label }}</span>
<span class="muted small">
{{ course.titles.length }} 课
<template v-if="resume">
·
{{ courseStep(course) ? `学完 ${doneCount(course.titles, course.progress)} 课` : '还没开始' }}</template
>
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
</span>
</div>
<div v-if="!resume && firstLesson(course)" class="first">
<span class="muted">第 1 课</span>
<span class="first-kp">{{ firstLesson(course)!.kp }}</span>
<span class="sec">{{ firstLesson(course)!.scene }}</span>
</div>
<SegBar :groups="groupByProject(course.titles)" :state-of="stateOf(course)" :width="resume ? 12 : 14" />
<router-link v-if="resume" :to="lessonPath(course.type, courseStep(course) ?? 1)" class="course-link">
{{ courseStep(course) ? `接着学第 ${courseStep(course)} 课` : '从第 1 课开始' }}
<Icon icon="ph:caret-right-bold" :width="13" />
</router-link>
</div>
<n-button v-if="!resume" type="primary" icon-placement="right" @click="router.push(lessonPath(course.type, 1))">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
开始学
</n-button>
</section>
</div>
<div class="tabs" role="tablist">
<button
v-for="course in courses.filter((c) => c.titles.length)"
:key="course.type"
type="button"
role="tab"
class="tab"
:class="{ on: tab === course.type }"
:aria-selected="tab === course.type"
@click="pickTab(course.type)"
>
{{ course.label }}
<span class="tab-sub">
{{
userStore.isAuthed && doneCount(course.titles, course.progress)
? `学完 ${doneCount(course.titles, course.progress)}/${course.titles.length}`
: `${course.titles.length} 课`
}}
</span>
</button>
<span class="grow" />
<button v-if="!userStore.isAuthed" type="button" class="login" @click="authModal.openLoginModal()">登录后记下学到哪</button>
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
<AdminLink v-if="userStore.isSuperAdmin" :to="{ path: '/admin/tutorial/list', query: { type: tab } }" size="small"> 管理教程 </AdminLink>
</div>
</div>
<div v-if="current && isDesktop" class="map">
<section v-for="group in groups" :key="group.lessons[0]!.id" class="project card">
<div class="project-head">
<ProjectTile :icon="group.icon" :tone="group.tone" />
<span class="project-name">
<span v-if="group.no" class="muted small">{{ group.no }}</span>
<span class="project-title">{{ group.name }}</span>
</span>
<span
v-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
) === group.lessons.length
"
class="project-count all"
>
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{ group.lessons.length }}
</span>
<span
v-else-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
)
"
class="project-count"
>
{{
doneIn(
current,
group.lessons.map((l) => l.id),
)
}}/{{ group.lessons.length }}
</span>
<span v-else class="project-count">{{ group.lessons.length }} 课</span>
</div>
<LessonRow
v-for="lesson in group.lessons"
:key="lesson.id"
:title="lesson.title"
:step="lesson.step"
:to="lessonPath(current.type, lesson.step)"
:progress="current.progress[lesson.id]"
:active="resume?.course.type === current.type && resume.step === lesson.step"
:height="48"
/>
</section>
</div>
<ProjectTree
v-else-if="current"
:groups="groups"
:progress="current.progress"
:type="current.type"
:current-step="resume?.course.type === current.type ? resume.step : 0"
big
/>
<n-empty v-if="!courses.some((c) => c.titles.length)" description="还没有公开的教程" style="margin-top: 80px" />
</template>
<n-spin v-else class="spin" />
</div>
</template>
<style scoped>
.home {
max-width: 1286px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.muted {
opacity: 0.6;
}
.sec {
opacity: 0.8;
}
.small {
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
.card {
border: 1px solid var(--learn-line);
border-radius: 8px;
}
.top {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 20px;
}
.top.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resume {
border: 1px solid var(--learn-green-line);
background: var(--learn-green-bg);
border-radius: 10px;
padding: 20px 26px;
display: flex;
align-items: center;
gap: 24px;
}
.resume-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.resume-meta {
display: flex;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.resume-label {
color: var(--learn-green-text);
font-weight: 700;
}
.resume-title {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.resume-kp {
font-size: 26px;
font-weight: 700;
}
.resume-scene {
font-size: 15px;
opacity: 0.8;
}
.resume-progress {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 13px;
}
.course {
padding: 18px 22px;
display: flex;
align-items: center;
gap: 20px;
}
.course-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.course-name {
font-size: 18px;
font-weight: 700;
}
.first {
display: flex;
gap: 6px;
font-size: 14px;
flex-wrap: wrap;
}
.first-kp {
font-weight: 600;
}
.course-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: var(--learn-green-text);
text-decoration: none;
}
.tabs {
display: flex;
align-items: stretch;
gap: 24px;
height: 40px;
border-bottom: 1px solid var(--learn-line);
}
.tab {
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: inherit;
font: inherit;
font-size: 15px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 0;
}
.tab.on {
color: var(--learn-green-text);
font-weight: 700;
border-bottom-color: var(--learn-green);
}
.tab-sub {
font-size: 13px;
font-weight: 400;
opacity: 0.6;
}
.admin-links {
display: flex;
align-items: center;
gap: 8px;
}
.login {
border: 0;
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.map {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.map {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.top {
grid-template-columns: minmax(0, 1fr);
}
}
.project {
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.project-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 10px;
}
.project-name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.project-title {
font-size: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-count {
font-size: 13px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 3px;
}
.project-count.all {
opacity: 1;
color: var(--learn-green-text);
font-weight: 600;
}
@media (max-width: 768px) {
.home {
gap: 14px;
}
.top,
.top.two {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.resume {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
}
.resume-kp {
font-size: 22px;
}
.resume-go {
width: 100%;
}
.course {
padding: 14px 16px;
}
.tabs {
gap: 20px;
}
}
</style>
File diff suppressed because it is too large. Load diff
+619
View File
@@ -0,0 +1,619 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import type { Tutorial, TutorialProgress, TutorialSummary } from '@oj2/contract'
import type { Exercise } from 'utils/types'
import { getExercises, getLearnProgress, getTutorial, getTutorials } from '../api'
import { parseTutorial } from './composables/useTutorialParse'
import { useLearnTrace } from './composables/useLearnTrace'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue'
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 TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue'
import TutorialOutline from './components/TutorialOutline.vue'
import ProjectTree from './components/ProjectTree.vue'
import SegBar from './components/SegBar.vue'
import './learn.css'
/**
* 读一课。设计稿「自学重设计」页的阅读页 A(选定):左边目录按项目折叠、中间正文、
* 右边这一课的小节和练一练;手机上是顶部课程条 + 底部弹出的目录 + 底部上一课/下一课。
*/
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const userStore = useUserStore()
const theme = useThemeVars()
// 未登录也能看教程,只是不留痕、不记进度
const traced = computed(() => userStore.isAuthed)
const type = computed<TutorialType>(() => (route.params.type === 'c' ? 'c' : 'python'))
const courseLabel = computed(() => COURSES.find((item) => item.type === type.value)!.label)
const step = computed(() => {
const value = parseInt((route.params.step as string | undefined) ?? '1')
return Number.isFinite(value) && value > 0 ? value : 1
})
const titles = ref<TutorialSummary[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const tutorial = ref<Tutorial | null>(null)
const exercises = ref<Exercise[]>([])
const solvedIds = ref(new Set<number>())
const loading = ref(true)
const isEmpty = ref(false)
const menuOpen = ref(false)
// 手机上的目录是大半屏的底部抽屉:按返回键收起它,不是退出这一课
useBackClose(menuOpen, () => !isDesktop.value)
const groups = computed(() => groupByProject(titles.value))
const parsed = computed(() => (tutorial.value ? parseTutorial(tutorial.value.content, exercises.value) : { segments: [], outline: [], exerciseCount: 0 }))
const parts = computed(() => splitTitle(tutorial.value?.title ?? ''))
const solvedCount = computed(() => exercises.value.filter((item) => solvedIds.value.has(item.id)).length)
const firstUnsolved = computed(() => {
for (const seg of parsed.value.segments) {
if (seg.type === 'exercise' && !solvedIds.value.has(seg.exercise.id)) {
return { id: seg.exercise.id, index: seg.index + 1 }
}
}
return null
})
const prev = computed(() => tutorialAt(step.value - 1))
const next = computed(() => tutorialAt(step.value + 1))
const done = computed(() => doneCount(titles.value, progress.value))
function tutorialAt(at: number) {
const item = titles.value[at - 1]
return item ? { title: item.title, to: tutorialPath(type.value, at) } : null
}
function stateOf(id: number) {
return tutorialState(progress.value[id])
}
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value?.id ?? 0),
traced,
)
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响学,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
async function loadProgress() {
if (!traced.value) {
progress.value = {}
return
}
try {
const rows = await getLearnProgress(type.value)
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
} catch {
progress.value = {}
}
}
let generation = 0
async function init() {
const mine = ++generation
loading.value = true
menuOpen.value = false
const list = await getTutorials(type.value)
if (mine !== generation) return
titles.value = list
isEmpty.value = list.length === 0
if (isEmpty.value) {
loading.value = false
return
}
if (step.value > list.length) {
router.replace(tutorialPath(type.value, list.length))
return
}
const id = list[step.value - 1]!.id
const [loaded, exs] = await Promise.allSettled([getTutorial(id), getExercises(id), loadProgress()])
if (mine !== generation) return
tutorial.value = loaded.status === 'fulfilled' ? loaded.value : null
exercises.value = exs.status === 'fulfilled' ? exs.value : []
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
loading.value = false
activeId.value = ''
await nextTick()
reader.value?.scrollTo({ top: 0 })
// 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
// 点「下一课」落在下一课的末尾
if (!isDesktop.value) scroller()?.scrollTo({ top: 0 })
pagerHidden.value = false
atEnd.value = false
}
watch(
() => [route.params.type, route.params.step],
() => {
if (route.name === 'learn tutorial') init()
},
{ immediate: true },
)
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
watch(traced, async () => {
await loadProgress()
if (!tutorial.value) return
const exs = await getExercises(tutorial.value.id).catch(() => exercises.value)
exercises.value = exs
solvedIds.value = new Set([...solvedIds.value, ...exs.filter((item) => item.solved).map((item) => item.id)])
})
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
function onSolved(exerciseId: number) {
if (solvedIds.value.has(exerciseId) || !tutorial.value) return
solvedIds.value = new Set([...solvedIds.value, exerciseId])
const id = tutorial.value.id
const row = progress.value[id] ?? {
tutorialId: id,
viewCount: 1,
totalSeconds: 0,
firstViewedAt: null,
lastViewedAt: null,
exerciseTotal: exercises.value.length,
exerciseSolved: 0,
}
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
}
// ------------------------------------------------------------------ 手机底栏
// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
// 课末卡片里就有「下一课」,两个摆在一起是重复的
const mobileRoot = ref<HTMLElement | null>(null)
const endRef = ref<HTMLElement | null>(null)
const pagerHidden = ref(false)
const atEnd = ref(false)
/** 往上找真正在滚的那层。n-layout-content 自己也带 .n-layout-scroll-container 类却不滚,按类名找会找错 */
function scroller(): HTMLElement | null {
for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY
if ((overflow === 'auto' || overflow === 'scroll') && el.scrollHeight > el.clientHeight) {
return el
}
}
return null
}
let lastTop = 0
function onMobileScroll(event: Event) {
const box = event.target as HTMLElement
const top = box.scrollTop
if (top < 60) pagerHidden.value = false
else if (top > lastTop + 6) pagerHidden.value = true
else if (top < lastTop - 6) pagerHidden.value = false
lastTop = top
const end = endRef.value?.getBoundingClientRect().top
atEnd.value = end !== undefined && end < box.getBoundingClientRect().bottom
}
// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
useEventListener(
document,
'scroll',
(event: Event) => {
const box = event.target
if (isDesktop.value || !(box instanceof HTMLElement)) return
// 只认包着本页的那层(目录抽屉、代码块里横着滚的不算)
if (!mobileRoot.value || !box.contains(mobileRoot.value)) return
onMobileScroll(event)
},
{ capture: true, passive: true },
)
// ------------------------------------------------------------------ 读到哪一节
const reader = ref<HTMLElement | null>(null)
const activeId = ref('')
function onScroll() {
const box = reader.value
if (!box) return
const top = box.getBoundingClientRect().top + 160
let current = parsed.value.outline[0]?.id ?? ''
for (const item of parsed.value.outline) {
const el = document.getElementById(item.id)
if (el && el.getBoundingClientRect().top <= top) current = item.id
}
activeId.value = current
}
function jump(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function jumpExercise(id: number) {
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>
<template>
<div class="learn">
<n-empty v-if="isEmpty" description="这门课还没有公开的课" style="margin-top: 80px" />
<!-- 桌面:目录 | 正文 | 这一课 -->
<div v-else-if="isDesktop" class="layout">
<aside class="rail">
<router-link to="/learn" class="back"> <Icon icon="ph:caret-left-bold" :width="13" />自学首页 </router-link>
<div class="course">
<div class="course-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
</div>
<SegBar :groups="groups" :state-of="stateOf" :current="tutorial?.id" :width="9" :height="5" :gap="2" :group-gap="6" />
</div>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" />
<div v-if="!traced" class="login-tip muted">登录后记下学到哪</div>
</aside>
<main class="main">
<div ref="reader" class="reader" @scroll.passive="onScroll">
<!-- 按课重建:复用的话正文里的编辑器是就地换内容,CodeMirror 会把光标滚进视野,
刚滚回顶上的页面又被拽到某个代码块那儿 -->
<article v-if="tutorial" :key="tutorial.id" class="article">
<div class="crumb muted">
<span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span>
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
<template v-if="userStore.isSuperAdmin">
<span class="grow" />
<AdminLink :to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }" size="tiny"> 编辑这一课 </AdminLink>
</template>
</div>
<h1 class="h1">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<TutorialBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<TutorialEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
@jump="jumpExercise"
/>
</article>
<n-spin v-else-if="loading" class="spin" />
</div>
<nav class="pager">
<div class="pager-inner">
<n-button v-if="prev" tag="a" :href="prev.to" @click.prevent="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课<span class="pager-name muted">{{ splitTitle(prev.title).kp }}</span>
</n-button>
<span class="grow" />
<n-button v-if="next" type="primary" icon-placement="right" tag="a" :href="next.to" @click.prevent="router.push(next.to)">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课<span class="pager-name next-name">{{ splitTitle(next.title).kp }}</span>
</n-button>
</div>
</nav>
</main>
<aside class="side">
<TutorialOutline
v-if="tutorial"
:items="parsed.outline"
:active-id="activeId || parsed.outline[0]?.id || ''"
:solved-ids="solvedIds"
:exercise-count="parsed.exerciseCount"
@jump="jump"
/>
</aside>
</div>
<!-- 手机 -->
<div v-else ref="mobileRoot" class="mobile">
<div class="mbar">
<n-button @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="num muted-strong">第 {{ step }} / {{ titles.length }} 课</span>
<span class="grow" />
<span v-if="parsed.exerciseCount" class="mpractice">
<span>练一练</span>
<span class="mboxes">
<span v-for="n in parsed.exerciseCount" :key="n" class="mbox" :class="{ on: n <= solvedCount }" />
</span>
</span>
</div>
<article v-if="tutorial" :key="tutorial.id" class="marticle">
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
<h1 class="h1 small">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
<TutorialBody
:segments="parsed.segments"
:lang="type"
:tutorial-id="tutorial.id"
:exercise-count="parsed.exerciseCount"
:solved-ids="solvedIds"
@solved="onSolved"
/>
<div ref="endRef">
<TutorialEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
compact
@jump="jumpExercise"
/>
</div>
</article>
<n-spin v-else-if="loading" class="spin" />
<nav class="mpager" :class="{ hidden: pagerHidden || atEnd }">
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课
</n-button>
<span class="grow" />
<n-button size="large" quaternary @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
目录
</n-button>
<span class="grow" />
<n-button v-if="next" size="large" type="primary" icon-placement="right" @click="router.push(next.to)">
<template #icon><Icon icon="ph:caret-right-bold" /></template>
下一课
</n-button>
</nav>
<!-- 不自动聚焦:一打开焦点框套在「自学首页」上,手机上看着像出了错 -->
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet" :auto-focus="false">
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
<span class="sheet-head">
<span class="course-name">{{ courseLabel }}</span>
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
<router-link to="/learn" class="sheet-home">自学首页</router-link>
</span>
</template>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" big @go="menuOpen = false" />
</n-drawer-content>
</n-drawer>
</div>
</div>
</template>
<style scoped>
.muted {
opacity: 0.6;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
/* 桌面端固定高度,三栏各自滚动。72 = 顶栏 56 + 外框上内边距 16;
底下的 16 内边距用负外边距吃掉,上一课 / 下一课那条贴着屏幕底 */
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 220px;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 72px);
margin-bottom: -16px;
}
@media (max-width: 1180px) {
.layout {
grid-template-columns: 240px minmax(0, 1fr);
}
.side {
display: none;
}
}
.rail {
box-sizing: border-box;
border-right: 1px solid var(--learn-line);
padding: 0 12px 16px 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 13px;
padding: 0 8px;
color: var(--learn-green-text);
text-decoration: none;
}
.course {
padding: 10px 8px 12px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.course-name {
font-size: 17px;
font-weight: 700;
}
.login-tip {
font-size: 13px;
padding: 8px;
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
}
.reader {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 40px 0;
}
.article {
max-width: 720px;
margin: 0 auto;
}
.crumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
margin-bottom: 6px;
}
.h1 {
margin: 0;
font-size: 28px;
line-height: 1.3;
}
.h1.small {
font-size: 24px;
}
.scene {
font-size: 15px;
opacity: 0.8;
margin: 4px 0 18px;
}
.pager {
flex-shrink: 0;
border-top: 1px solid var(--learn-line);
padding: 12px 40px;
}
.pager-inner {
max-width: 720px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.pager-name {
margin-left: 6px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.next-name {
opacity: 0.85;
}
.side {
box-sizing: border-box;
padding: 24px 0 0 16px;
overflow-y: auto;
}
/* 手机 */
/* 外层 n-layout 是 overflow: hidden,sticky 粘不住:顶部的条跟着滚走,
上一课 / 下一课(带目录按钮)固定在屏幕底部 */
.mobile {
padding-bottom: 72px;
}
.mbar {
height: 48px;
margin: 0 -12px;
padding: 0 12px;
box-sizing: border-box;
border-bottom: 1px solid var(--learn-line);
background: v-bind('theme.bodyColor');
display: flex;
align-items: center;
gap: 8px;
}
.muted-strong {
font-size: 13px;
opacity: 0.8;
}
.mpractice {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.mboxes {
display: flex;
gap: 3px;
}
.mbox {
width: 14px;
height: 6px;
border-radius: 3px;
background: var(--learn-todo);
}
.mbox.on {
background: var(--learn-green);
}
.marticle {
padding-top: 16px;
}
.mpager {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 12px;
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
background: v-bind('theme.bodyColor');
transition: transform 0.2s ease;
}
.mpager.hidden {
transform: translateY(100%);
}
.sheet-head {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.sheet-home {
margin-left: 12px;
color: var(--learn-green-text);
text-decoration: none;
}
</style>
@@ -1,4 +1,4 @@
import { isLessonDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract' import { isTutorialDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
export type TutorialType = 'python' | 'c' export type TutorialType = 'python' | 'c'
@@ -32,7 +32,7 @@ export interface ProjectGroup {
icon: string icon: string
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray' tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */ /** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
lessons: { id: number; title: string; step: number }[] tutorials: { id: number; title: string; step: number }[]
} }
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */ /** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
@@ -57,7 +57,7 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
titles.forEach((item, index) => { titles.forEach((item, index) => {
const last = groups[groups.length - 1] const last = groups[groups.length - 1]
if (last && last.project === item.project) { if (last && last.project === item.project) {
last.lessons.push({ id: item.id, title: item.title, step: index + 1 }) last.tutorials.push({ id: item.id, title: item.title, step: index + 1 })
return return
} }
const { no, name } = splitProject(item.project) const { no, name } = splitProject(item.project)
@@ -66,21 +66,21 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
no, no,
name, name,
...projectLook(name), ...projectLook(name),
lessons: [{ id: item.id, title: item.title, step: index + 1 }], tutorials: [{ id: item.id, title: item.title, step: index + 1 }],
}) })
}) })
return groups return groups
} }
export type LessonState = 'done' | 'started' | 'todo' export type TutorialState = 'done' | 'started' | 'todo'
/** /**
* 目录上的三态。学完 = 练一练全对(契约的 `isLessonDone`);打开过或者做过练一练但没 * 目录上的三态。学完 = 练一练全对(契约的 `isTutorialDone`);打开过或者做过练一练但没
* 全对是「在学」;别的是没开始。 * 全对是「在学」;别的是没开始。
*/ */
export function lessonState(progress: TutorialProgress | undefined): LessonState { export function tutorialState(progress: TutorialProgress | undefined): TutorialState {
if (!progress) return 'todo' if (!progress) return 'todo'
if (isLessonDone(progress)) return 'done' if (isTutorialDone(progress)) return 'done'
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started' if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
return 'todo' return 'todo'
} }
@@ -98,17 +98,17 @@ export function continueStep(titles: TutorialSummary[], progress: Record<number,
if (!latest) return null if (!latest) return null
const index = titles.findIndex((item) => item.id === latest!.tutorialId) const index = titles.findIndex((item) => item.id === latest!.tutorialId)
if (index < 0) return null if (index < 0) return null
if (lessonState(latest) !== 'done') return index + 1 if (tutorialState(latest) !== 'done') return index + 1
for (let i = index + 1; i < titles.length; i++) { for (let i = index + 1; i < titles.length; i++) {
if (lessonState(progress[titles[i]!.id]) !== 'done') return i + 1 if (tutorialState(progress[titles[i]!.id]) !== 'done') return i + 1
} }
return index + 1 return index + 1
} }
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) { export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
return titles.filter((item) => lessonState(progress[item.id]) === 'done').length return titles.filter((item) => tutorialState(progress[item.id]) === 'done').length
} }
export function lessonPath(type: TutorialType, step: number) { export function tutorialPath(type: TutorialType, step: number) {
return `/learn/${type}/${step.toString().padStart(2, '0')}` return `/learn/${type}/${step.toString().padStart(2, '0')}`
} }
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more