Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:05:23 +08:00
191 changed files with 13722 additions and 935 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"
+39 -22
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 \
--compress --compress-choice=zstd --compress-level=19 \
--timeout=60 --stats --human-readable \
-e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
--exclude node_modules \ --exclude node_modules \
--exclude .git \ --exclude .git \
--exclude data \ --exclude data \
--exclude 'docker/.env*' \ --exclude 'docker/.env*' \
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" ./ "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(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
+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
+14
View File
@@ -302,6 +302,20 @@
"when": 1793632543220, "when": 1793632543220,
"tag": "0042_class_name_cleanup", "tag": "0042_class_name_cleanup",
"breakpoints": true "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
} }
] ]
} }
+43
View File
@@ -46,6 +46,7 @@ import type {
JudgeStatus, JudgeStatus,
LessonLanguage, LessonLanguage,
LessonMode, LessonMode,
MascotEventKind,
ProblemChallenges, ProblemChallenges,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
@@ -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
View File
@@ -21,6 +21,7 @@ import { problemRoutes } from './routes/problem'
import { problemStatsRoutes } from './routes/problem-stats' import { problemStatsRoutes } from './routes/problem-stats'
import { submissionRoutes } from './routes/submission' import { submissionRoutes } from './routes/submission'
import { trialRunRoutes } from './routes/trial-run' import { trialRunRoutes } from './routes/trial-run'
import { mascotRoutes } from './routes/mascot'
import { siteRoutes } from './routes/site' import { siteRoutes } from './routes/site'
import { scanPendingChallenges } from './services/challenge' import { scanPendingChallenges } from './services/challenge'
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket' import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
@@ -44,6 +45,7 @@ app.route('/api', problemRoutes)
app.route('/api', problemStatsRoutes) app.route('/api', problemStatsRoutes)
app.route('/api', submissionRoutes) app.route('/api', submissionRoutes)
app.route('/api', trialRunRoutes) app.route('/api', trialRunRoutes)
app.route('/api', mascotRoutes)
app.route('/api/admin', adminRoutes) app.route('/api/admin', adminRoutes)
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出 // 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
+99 -17
View File
@@ -63,15 +63,74 @@ async function loadLanguage(language: string) {
return loaded return loaded
} }
function collectNodes(root: Node, type: string, result: Node[] = []) { type Match = (node: Node) => boolean
if (root.type === type) result.push(root)
for (const child of root.children) collectNodes(child, type, result) function collectNodes(root: Node, match: Match, result: Node[] = []) {
if (match(root)) result.push(root)
for (const child of root.children) collectNodes(child, match, result)
return result return result
} }
function hasNode(root: Node, type: string): boolean { function hasNode(root: Node, match: Match): boolean {
if (root.type === type) return true if (match(root)) return true
return root.children.some((child) => hasNode(child, type)) return root.children.some((child) => hasNode(child, match))
}
const ofType =
(type: string): Match =>
(node) =>
node.type === type
/** C / C++ 函数定义的函数名:`int *f()` 的名字在 pointer_declarator 里面一层,沿着 declarator 往里找 */
function functionName(definition: Node) {
let declarator = definition.childForFieldName('declarator')
while (declarator && declarator.type !== 'function_declarator') declarator = declarator.childForFieldName('declarator')
return declarator?.childForFieldName('declarator')?.text
}
/**
* 语法 target 认哪些节点。多数就是比节点类型,例外:
*
* C / C++ 的「函数定义」不算 `main` —— 每个 C 程序都有 main,算进去的话「必须使用函数定义」
* 永远通过、「不能使用」永远失败,老师选它要的是「自己写了函数」。所以 C 那边叫「自定义函数」。
*
* Python 的「赋值语句」连 `s += 1` 一起算 —— 和 C 一个口径(C 的 assignment_expression
* 本来就包括 `+=`),学生也是这么理解的。tree-sitter-python 里 `+=` 是单独的 augmented_assignment。
*/
function nodeMatch(target: string, language: string): Match {
const type = astTargetNodeType(target, language)
if (target === 'function_definition' && language !== 'Python') {
return (node) => node.type === type && functionName(node) !== 'main'
}
if (target === 'assignment' && language === 'Python') {
return (node) => node.type === type || node.type === 'augmented_assignment'
}
return ofType(type)
}
/**
* 运算符只认真在做运算的那一个 token。光比 token 的话,下面这些全都算「用了」:
* C 的 `scanf("%d", &n)` 算 `&`、`int *p` 算 `*`,C++ 的 `vector<int>` 算 `>`,
* Python 的 `*args` / `from m import *` 算 `*`、`**kw` 算 `**`、`not in` / `is not` 算 `not`、
* `case 1 | 2` 算 `|`。所以看它的父节点:二元运算、复合赋值、自增自减。
*
* 一元的只认取反(`!x` / `not x`)。负号 `-x` 不算「用了减号」,同理 `*p` 不算乘、`&n` 不算按位与。
*/
const OPERATOR_PARENTS: Record<string, string[]> = {
C: ['binary_expression', 'assignment_expression', 'update_expression'],
'C++': ['binary_expression', 'assignment_expression', 'update_expression'],
Python: ['binary_operator', 'boolean_operator', 'comparison_operator', 'augmented_assignment'],
}
const NOT_PARENTS: Record<string, string[]> = {
C: ['unary_expression'],
'C++': ['unary_expression'],
Python: ['not_operator'],
}
function operatorMatch(target: string, language: string): Match {
const type = astTargetNodeType(target, language)
const parents = (target === 'not' ? NOT_PARENTS : OPERATOR_PARENTS)[language] ?? []
return (node) => node.type === type && node.parent !== null && parents.includes(node.parent.type)
} }
function targetName(rule: AstRule, language?: string) { function targetName(rule: AstRule, language?: string) {
@@ -201,6 +260,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
if (!rule.keyword?.trim()) return `${at}:参数名不能为空` if (!rule.keyword?.trim()) return `${at}:参数名不能为空`
} }
if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字` if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字`
if ([rule.exact, rule.min, rule.max].some((n) => n !== undefined && n < 0)) return `${at}:次数不能是负数`
if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) {
return `${at}:至少 ${rule.min} 次比至多 ${rule.max} 次还多,怎么写都过不了`
}
} }
} }
return null return null
@@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record<string, string> = {
Python: 'call', Python: 'call',
} }
/** `import math` / `import numpy as np` 引进来的模块名。`import os.path` 绑定的是 `os` */
function importedModules(root: Node) {
const names = new Set<string>()
for (const statement of collectNodes(root, ofType('import_statement'))) {
for (const item of statement.namedChildren) {
const name = item.type === 'aliased_import' ? item.childForFieldName('alias') : item.type === 'dotted_name' ? item.namedChildren[0] : null
if (name) names.add(name.text)
}
}
return names
}
function functionCalls(root: Node, target: string, language: string) { function functionCalls(root: Node, target: string, language: string) {
const callType = CALL_NODE_TYPES[language] ?? 'call' const callType = CALL_NODE_TYPES[language] ?? 'call'
return collectNodes(root, callType).filter((call) => { // Python 的 `math.sqrt(4)` 也算调用了 sqrt() —— 和 `from math import sqrt` 后写 `sqrt(4)`
// 是一回事,老师配「必须调用 sqrt()」时想的就是它。只认 import 进来的模块,
// `s.count()` 这种对象上的方法还是归「调用方法」
const modules = language === 'Python' ? importedModules(root) : null
return collectNodes(root, ofType(callType)).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
if (!fn) return false if (!fn) return false
if (fn.type === 'identifier') return fn.text === target if (fn.type === 'identifier') return fn.text === target
if (modules && fn.type === 'attribute') {
const object = fn.childForFieldName('object')
return object?.type === 'identifier' && modules.has(object.text) && fn.childForFieldName('attribute')?.text === target
}
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于 // `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
// 有没有 using namespace std,两种都得认,所以末段也比一次 // 有没有 using namespace std,两种都得认,所以末段也比一次
if (language === 'C++' && fn.type === 'qualified_identifier') { if (language === 'C++' && fn.type === 'qualified_identifier') {
@@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) {
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」 // 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
// 会静默地永远失败 // 会静默地永远失败
if (language === 'C++') { if (language === 'C++') {
return collectNodes(root, 'call_expression').filter((call) => { return collectNodes(root, ofType('call_expression')).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
}) })
} }
if (language !== 'Python') return [] if (language !== 'Python') return []
return collectNodes(root, 'call').filter((call) => { return collectNodes(root, ofType('call')).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target
}) })
@@ -279,20 +362,20 @@ function methodCalls(root: Node, target: string, language: string) {
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null { function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
const target = rule.target ?? '' const target = rule.target ?? ''
const nodeType = astTargetNodeType(target, language) const match = rule.engine === 'must_use_operator' ? operatorMatch(target, language) : nodeMatch(target, language)
switch (rule.engine) { switch (rule.engine) {
case 'must_exist_node': case 'must_exist_node':
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, match),
} }
case 'must_not_exist_node': { case 'must_not_exist_node': {
const nodes = collectNodes(root, nodeType) const nodes = collectNodes(root, match)
return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes) return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes)
} }
case 'count_node': { case 'count_node': {
const nodes = collectNodes(root, nodeType) const nodes = collectNodes(root, match)
return withLines( return withLines(
{ {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
@@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_use_operator': case 'must_use_operator':
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, match),
} }
case 'must_use_keyword': case 'must_use_keyword':
return { return {
@@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_have_nesting': { case 'must_have_nesting': {
const outer = rule.outer ?? '' const outer = rule.outer ?? ''
const inner = rule.inner ?? '' const inner = rule.inner ?? ''
const outerType = astTargetNodeType(outer, language) const innerMatch = nodeMatch(inner, language)
const innerType = astTargetNodeType(inner, language) const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch)))
const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)))
return { description: describeAstRule(rule, language), passed } return { description: describeAstRule(rule, language), passed }
} }
default: default:
+12 -3
View File
@@ -1,4 +1,4 @@
import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract' import { adminLessonRequestSchema, lessonModeSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract'
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm' import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
@@ -24,7 +24,12 @@ adminLessonRoutes.get('/lessons', requireTeacher, async (c) => {
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 }) const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
const className = c.req.query('className')?.trim() const className = c.req.query('className')?.trim()
const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 }) const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 })
const where = and(className ? eq(schema.classLesson.className, className) : undefined, teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined) const mode = lessonModeSchema.safeParse(c.req.query('mode')).data
const where = and(
className ? eq(schema.classLesson.className, className) : undefined,
teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined,
mode ? eq(schema.classLesson.mode, mode) : undefined,
)
const [rows, [totalRow], teachers] = await Promise.all([ const [rows, [totalRow], teachers] = await Promise.all([
db db
@@ -156,10 +161,14 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次 // 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
const classes = await affectedClasses(id, result.values.className) const classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className))) const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 时刻只填到分:没改时刻就留着原来带秒的那个。不然只改了做什么,这一节也被挪到这一分钟的开头,
// 排到同一分钟里后布置的那节前面去 —— 「今天最后一节」就换了人(先画流程图、紧接着再开一节写代码,常在同一分钟里)
const [current] = await db.select({ createdAt: schema.classLesson.createdAt }).from(schema.classLesson).where(eq(schema.classLesson.id, id))
const sameMinute = current && Math.floor(Date.parse(current.createdAt) / 60_000) * 60_000 === Date.parse(result.values.createdAt)
// 布置的老师不换,和任务页上「改」一样认第一次 // 布置的老师不换,和任务页上「改」一样认第一次
const [row] = await db const [row] = await db
.update(schema.classLesson) .update(schema.classLesson)
.set({ ...result.values, updatedAt: new Date().toISOString() }) .set({ ...result.values, createdAt: sameMinute ? current.createdAt : result.values.createdAt, updatedAt: new Date().toISOString() })
.where(eq(schema.classLesson.id, id)) .where(eq(schema.classLesson.id, id))
.returning({ id: schema.classLesson.id }) .returning({ id: schema.classLesson.id })
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了') if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
+4 -41
View File
@@ -1,5 +1,5 @@
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract' import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm' import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware' import { requireAuth, type AppEnv } from '../auth/middleware'
@@ -9,7 +9,6 @@ import { success } from '../http'
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity' import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import { accepted } from '../services/learning-stats'
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson' import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
import { calendarDay } from '../time'
/** /**
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。 * 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
@@ -145,7 +144,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS) const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null const steps = lesson.steps
const codeLanguage = steps ? steps.language : lesson.language const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查 // 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([ const [mine, drawn] = await Promise.all([
@@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
problems: lesson.problems.map((problem) => { problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id)) const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id)) const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id) const needs = steps?.needsOf(problem.id)
let myStatus: Step let myStatus: Step
if (needs) { if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完 // 老师布置的:要哪几步就看哪几步,全做到才算做完
@@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
}), }),
} satisfies ClassActivity) } satisfies ClassActivity)
}) })
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
+176
View File
@@ -0,0 +1,176 @@
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware'
import { db, schema } from '../db'
import { parseBody, success } from '../http'
import { JudgeStatus } from '../judge/status'
import { calendarDay, dayNumber, dayStart, weekStart } from '../time'
export const mascotRoutes = new Hono<AppEnv>()
/**
* 每人每分钟最多记这么多条。正常点她一分钟到不了十条(连点只记开头、彩蛋和嫌烦那几下),
* 拿脚本刷的才会撞上;撞上了就静默丢掉,不报错 —— 前台本来也不等这个结果
*/
const EVENTS_PER_MINUTE = 30
const windows = new Map<number, { start: number; count: number }>()
function allow(userId: number) {
const now = Date.now()
const entry = windows.get(userId)
if (!entry || now - entry.start >= 60_000) {
windows.set(userId, { start: now, count: 1 })
// 顺手清掉过期的,免得 Map 跟着在线人数一直涨
if (windows.size > 2000) for (const [id, it] of windows) if (now - it.start >= 60_000) windows.delete(id)
return true
}
entry.count++
return entry.count <= EVENTS_PER_MINUTE
}
/** 记一条看板娘互动(种类见契约 mascotEventSchema) */
mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
const parsed = await parseBody(c, mascotEventSchema, '看板娘记录的格式不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
if (allow(user.id)) {
const { kind, situation, line, action, path } = parsed.data
await db.insert(schema.mascotEvent).values({ userId: user.id, kind, situation, line, action, path })
}
return success(c, null)
})
/** 跨天记得的事只翻这么多天以内的 */
const MEMORY_DAYS = 7
/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */
function daysAgo(at: string | null, saidAt: string | null) {
// 按时刻比,别按字符串:库里出来的带微秒、位数不一定一样
if (!at || (saidAt && Date.parse(saidAt) > Date.parse(at))) return null
const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at))
return days >= 1 && days <= MEMORY_DAYS ? days : null
}
/** 跨天记得的事(契约 mascotProfileSchema.memory),一条语句查完 */
async function memoryOf(userId: number) {
const today = dayStart()
const since = new Date(Date.parse(today) - MEMORY_DAYS * 86_400_000).toISOString()
const e = schema.mascotEvent
/** 7 天内最后一次。`beforeToday`:今天的事不算「记得」,只取今天以前的 */
const last = (kind: string, situation: string | null, beforeToday: boolean) => sql`(
select max(${e.createdAt}) from ${e}
where ${e.userId} = ${userId} and ${e.kind} = ${kind}
${situation ? sql`and ${e.situation} = ${situation}` : sql``}
and ${e.createdAt} >= ${since}
${beforeToday ? sql`and ${e.createdAt} < ${today}` : sql``}
)`
const s = schema.submission
const [row] = await db.execute<{
sulk_at: string | null
grudge_at: string | null
pat_at: string | null
pat_said_at: string | null
closer_at: string | null
solved_before: number
}>(sql`
select
${last('open', 'sulk', true)} as sulk_at,
${last('open', 'memory.grudge', false)} as grudge_at,
${last('pat', null, true)} as pat_at,
${last('open', 'memory.pat', false)} as pat_said_at,
${last('open', 'memory.closer', false)} as closer_at,
(
select count(distinct ${s.problemId})::int from ${s}
where ${s.userId} = ${userId} and ${s.result} in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})
and ${s.createTime} < ${today}
) as solved_before
`)
return {
grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null),
patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null),
solvedBeforeToday: row?.solved_before ?? 0,
closerSaidToday: !!row?.closer_at && Date.parse(row.closer_at) >= Date.parse(today),
}
}
/** 最近多少次题库提交拿来看学生常犯哪类错 */
const RECENT_SUBMISSIONS = 20
/** 看板娘按学生的行为挑话用的那几个数(契约 mascotProfileSchema) */
mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
const userId = c.get('user')!.id
const s = schema.submission
const week = weekStart()
const e = schema.mascotEvent
const [pokes, solved, memory, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
db
.select({ value: count() })
.from(schema.mascotEvent)
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
// 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数
db
.select({ value: sql<number>`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` })
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId)),
memoryOf(userId),
db
.select({ value: countDistinct(s.problemId) })
.from(s)
.where(and(eq(s.userId, userId), eq(s.result, 0), gte(s.createTime, dayStart()))),
db
.select({ result: s.result, errorType: sql<string | null>`${s.statisticInfo}->'runtime_error'->>'type'` })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId)))
.orderBy(desc(s.createTime))
.limit(RECENT_SUBMISSIONS),
db
.select({ value: count() })
.from(schema.reaction)
.where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))),
db
.select({ value: count() })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))),
db
.select({ kind: e.kind, value: count() })
.from(e)
.where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week)))
.groupBy(e.kind),
db
.select({ value: count() })
.from(schema.aiHint)
.innerJoin(s, eq(s.id, schema.aiHint.submissionId))
.where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))),
])
const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0
const types = new Map<string, number>()
for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1)
const topType = [...types.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
const tally = (...results: number[]) => recent.filter((row) => results.includes(row.result)).length
return success(c, {
pokes: pokes[0]?.value ?? 0,
solved: solved[0]?.value ?? 0,
memory,
todaySolved: today[0]?.value ?? 0,
tooHard: tooHard[0]?.value ?? 0,
week: {
submits: weekSubmits[0]?.value ?? 0,
trials: eventCount('trial'),
helps: eventCount('help'),
aiHints: weekHints[0]?.value ?? 0,
},
recent: {
total: recent.length,
wrongAnswer: tally(-1),
compileError: tally(-2),
runtimeError: tally(4, 3),
timeout: tally(1, 2),
runtimeErrorType: topType,
},
} satisfies MascotProfile)
})
+26 -5
View File
@@ -12,6 +12,7 @@ import {
type ProblemListItem, type ProblemListItem,
type ProblemProgress, type ProblemProgress,
type ProblemTypeFilter, type ProblemTypeFilter,
type SimilarProblem,
type Tag, type Tag,
} from '@oj2/contract' } from '@oj2/contract'
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm' import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
@@ -327,13 +328,25 @@ problemRoutes.get('/challenges', requireAuth, async (c) => {
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => { problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const [target] = await db const [target] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id, difficulty: schema.problem.difficulty })
.from(schema.problem) .from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId))) .where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
.limit(1) .limit(1)
if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found') if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found')
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id)) // 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面
if (targetTags.length === 0) return success(c, []) // 只是为了「共同的标签」那行先写练的是什么
const targetTags = await db
.select({ id: schema.problemTags.problemtagId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTags.problemId, target.id))
// 同一类里大的在前,和题目列表「知识点」那列一样
.orderBy(
sql`${schema.problemTag.category} = 'knowledge' desc`,
sql`(select count(*) from ${schema.problemTags} pt where pt.problemtag_id = ${schema.problemTag.id}) desc`,
asc(schema.problemTag.name),
)
if (targetTags.length === 0) return success(c, [] satisfies SimilarProblem[])
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的 // 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)—— // 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
@@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const solvedIds = states ? [...states.solved] : [] const solvedIds = states ? [...states.solved] : []
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。 // High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
// 共同标签一样多时先给和原题难度最接近的:原来一律简单的在前,做对一道困难题,
// 五条推荐里三条多是简单题(本地库 35 道困难题:119 / 175)。再并列才先简单的,
// 照顾连错三次的人
const rankOf = (difficulty: string) => (difficulty === 'Low' ? 0 : difficulty === 'Mid' ? 1 : 2)
const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end` const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end`
const difficultyGap = sql`abs(${difficultyRank} - ${sql.raw(String(rankOf(target.difficulty)))})`
const sharedTags = count(schema.problemTags.problemtagId) const sharedTags = count(schema.problemTags.problemtagId)
const rows = await db const rows = await db
.select({ .select({
@@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
) )
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) .groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
// 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条 // 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
.orderBy(desc(sharedTags), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id)) .orderBy(desc(sharedTags), asc(difficultyGap), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id))
.limit(5) .limit(5)
const ids = rows.map((row) => row.problem.id) const ids = rows.map((row) => row.problem.id)
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)]) const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
return success( return success(
c, c,
rows.map((row) => listItem(row, tags, statuses, states, counts)), rows.map((row) => {
const own = new Set(tags.get(row.problem.id))
return { ...listItem(row, tags, statuses, states, counts), sharedTags: targetTags.map((tag) => tag.name).filter((name) => own.has(name)) }
}) satisfies SimilarProblem[],
) )
}) })
+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({
+9 -4
View File
@@ -527,6 +527,11 @@ const LESSON_LIVE_MINUTES = 15
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */ /** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
const LESSONS_DEFAULT = 50 const LESSONS_DEFAULT = 50
const LESSONS_MAX = 500 const LESSONS_MAX = 500
/** 课只算学生(STUDENT_ROLES,学生管理员也算),老师试题的提交不算 */
const studentRoles = sql.join(
STUDENT_ROLES.map((role) => sql`${role}`),
sql`, `,
)
type StatisticsRange = { start: string | null; end: string } type StatisticsRange = { start: string | null; end: string }
@@ -599,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}`),
@@ -612,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,
@@ -721,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 *,
+27 -12
View File
@@ -5,7 +5,6 @@ import {
type TaskBoard, type TaskBoard,
type TaskBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } from '@oj2/contract'
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
@@ -127,9 +126,14 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
const challengeDoneKeys = new Set( const challengeDoneKeys = new Set(
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`), challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
) )
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样) // 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)。
// 格子按每道题合起来的要求算(todayNeeds):先画流程图一节、再写代码一节,没画图的照样标「缺图」,
// 和学生那边的「做完」对得上。代码按的语言也是合起来那份:最后一节只画流程图时,用前面写代码那节的
const mode = lesson.source === 'teacher' ? lesson.mode : null const mode = lesson.source === 'teacher' ? lesson.mode : null
const needs = mode ? lessonNeeds(mode) : null const steps = lesson.steps
const needsOf = (problemId: number) => steps?.needsOf(problemId) ?? null
const anyDraw = ids.some((id) => needsOf(id)?.draw)
const codeLanguage = steps ? steps.language : lesson.language
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([ const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
@@ -223,7 +227,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
gte(schema.submission.createTime, start), gte(schema.submission.createTime, start),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.orderBy(schema.submission.createTime) .orderBy(schema.submission.createTime)
@@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数, * 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null * 老评法和还没评完的为 null
*/ */
needs?.draw && ids.length && userIds.length anyDraw && ids.length && userIds.length
? db ? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], { .selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
@@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
title: problem.title, title: problem.title,
isSql: problem.isSql, isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0, hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
mode: steps?.modeOf(problem.id) ?? null,
})), })),
students: roster.map( students: roster.map(
(student) => (student) =>
@@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
username: student.username, username: student.username,
realName: student.realName ?? null, realName: student.realName ?? null,
cells: ids.map((id) => { cells: ids.map((id) => {
const needs = needsOf(id)
const key = `${student.userId}:${id}` const key = `${student.userId}:${id}`
const cell = cellByKey.get(key) const cell = cellByKey.get(key)
const flow = drawnByKey.get(key) const flow = drawnByKey.get(key)
@@ -305,11 +311,11 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0) attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
} else { } else {
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步 // 要哪几步看哪几步:两步都做到的时刻是晚的那一步
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter( const wanted = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
(step) => step !== null, (step) => step !== null,
) )
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none' status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0) attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
} }
// 下面几样都是代码那一步的:只画流程图的课没有 // 下面几样都是代码那一步的:只画流程图的课没有
@@ -352,8 +358,9 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
})) }))
: [], : [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null, acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
challenge: challenge:
!codeWanted || !codeDone || !liveChallenges.get(id)?.size !codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
? null ? null
: challengeDoneKeys.has(key) : challengeDoneKeys.has(key)
? 'done' ? 'done'
@@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0 let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) { if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码) // 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
const counts = await lessonDoneCounts([ const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode }, {
id: lesson.lessonId,
className,
day: calendarDay(),
problemIds: ids,
language: lesson.steps?.language ?? lesson.language,
mode: lesson.mode,
modeOf: lesson.steps?.modeOf,
},
]) ])
allDoneCount = counts.get(lesson.lessonId) ?? 0 allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) { } else if (ids.length) {
+12 -7
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,13 +45,13 @@ 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: '获得的专题奖章数量' },
{ {
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
name: '做完的专题', name: '做完的专题',
helpText: '完成的专题数量', helpText: '完成的专题数量',
}, },
{
key: 'challenge_count',
name: '挑战成功',
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
},
{ {
key: 'first_try_ac_count', key: 'first_try_ac_count',
name: '一次就对的题', name: '一次就对的题',
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'max_code_lines', key: 'max_code_lines',
name: '最长的代码行数', name: '最长的代码行数',
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)', helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
}, },
{ {
key: 'achievement_unlocked_count', key: 'achievement_unlocked_count',
+20 -10
View File
@@ -1,4 +1,4 @@
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm' import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
import { db, schema, type DbOrTx } from '../db' import { db, schema, type DbOrTx } from '../db'
import { publishAchievementNotification } from '../events' import { publishAchievementNotification } from '../events'
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
/** /**
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。 * 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
* 参加过几场比赛、挑战成功几次也在这里一起数。
* *
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题 * 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条 * 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。 * 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx, * 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
* 一个人的提交也就几百条。 * 一个人的提交也就几百条。
*
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
*/ */
async function solvedCounts(tx: DbOrTx, userId: number) { async function solvedCounts(tx: DbOrTx, userId: number) {
const [row] = await tx.execute<{ const [row] = await tx.execute<{
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
mid: number mid: number
hard: number hard: number
first_try: number first_try: number
contests: number
challenges: number
}>( }>(
sql` sql`
with mine as ( with mine as (
select s.problem_id, s.result, s.create_time, s.id, p.difficulty select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
from ${schema.submission} s from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id join ${schema.problem} p on p.id = s.problem_id
where s.user_id = ${userId} and s.contest_id is null where s.user_id = ${userId}
) )
select select
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted, count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
select count(*)::int from ( select count(*)::int from (
select distinct on (problem_id) result from mine order by problem_id, create_time, id select distinct on (problem_id) result from mine order by problem_id, create_time, id
) first where result in (${ACCEPTED_RESULTS}) ) first where result in (${ACCEPTED_RESULTS})
) as first_try ) as first_try,
count(distinct contest_id)::int as contests,
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
from mine from mine
`, `,
) )
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 } return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
} }
/** /**
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)) .where(eq(schema.submission.id, submissionId))
.limit(1) .limit(1)
if (!row || row.submission.contestId !== null) return [] if (!row) return []
const rejudged = countedAs !== null const rejudged = countedAs !== null
const accepted = isAccepted(row.submission.result) const accepted = isAccepted(row.submission.result)
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
value.mid_ac_count = solved.mid value.mid_ac_count = solved.mid
value.hard_ac_count = solved.hard value.hard_ac_count = solved.hard
value.first_try_ac_count = solved.first_try value.first_try_ac_count = solved.first_try
value.contest_joined = solved.contests
value.challenge_count = solved.challenges
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1 if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
if (firstAc) { if (firstAc) {
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」 // 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
and( and(
eq(schema.submission.userId, row.submission.userId), eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId), eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
lt(schema.submission.createTime, row.submission.createTime), lt(schema.submission.createTime, row.submission.createTime),
), ),
) )
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
const metric = findMetric(achievement.metric) const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 } if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) // contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll() if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算 // max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll() if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并, * `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
* 其余指标不动,写法同 refreshContestJoinedForAll)。 * 其余指标不动,写法同 refreshContestJoinedForAll)。
* *
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径; * 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。 * 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
*/ */
async function refreshAcWeekStreakForAll() { async function refreshAcWeekStreakForAll() {
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有 // 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败 // 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))) .where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
.as('first_accepted') .as('first_accepted')
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')` const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
+60 -5
View File
@@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) {
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/ */
export async function lessonDoneCounts( export async function lessonDoneCounts(
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[], lessons: {
id: number
className: string
day: string
problemIds: number[]
language: LessonLanguage | null
mode: LessonMode
/** 每道题各自做什么(老师首页按今天几节合起来的要求算,见 todayNeeds);不给就都按 mode */
modeOf?: (problemId: number) => LessonMode
}[],
) { ) {
const rows = lessons.flatMap((lesson) => const rows = lessons.flatMap((lesson) =>
lesson.problemIds.map((problemId) => ({ lesson.problemIds.map((problemId) => ({
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
class_name: lesson.className, class_name: lesson.className,
problem_id: problemId, problem_id: problemId,
language: lesson.language, language: lesson.language,
mode: lesson.mode, mode: lesson.modeOf?.(problemId) ?? lesson.mode,
until: lessonDayEnd(lesson.day), until: lessonDayEnd(lesson.day),
})), })),
) )
@@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) {
return row?.problemIds.length ? row : null return row?.problemIds.length ? row : null
} }
/**
* 一道题今天要画还是要写:这个班今天布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再「用这几道题写代码」开一节时,还没画完的照样要画 ——
* 只看今天最后布置的那一节的话,一布置写代码,画图那一步就没了。学生的课堂条、任务页的格子、
* 老师首页的「全做对」都按它算,三处说的「做完」才一样(后台和数据统计还是一节一行、各数各的)。
*
* `language`:代码按什么语言数 —— 今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
export async function todayNeeds(className: string, problemIds: number[], latest: { id: number; language: LessonLanguage | null; mode: LessonMode }) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.id ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
const latestNeeds = lessonNeeds(latest.mode)
const needsOf = (id: number) => needs.get(id) ?? latestNeeds
const modeOf = (id: number): LessonMode => {
const { draw, code } = needsOf(id)
return draw && code ? 'both' : draw ? 'flowchart' : 'code'
}
return { needsOf, modeOf, language }
}
/** /**
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher), * 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 * 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
@@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
const plan = await lessonPlan(className, today) const plan = await lessonPlan(className, today)
if (plan) { if (plan) {
const planned = plan.problemIds const planned = plan.problemIds
const [problems, groups] = await Promise.all([ const steps = await todayNeeds(className, planned, plan)
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
const [problems, ...groupLists] = await Promise.all([
visibleProblems(planned), visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }), ...[...byMode].map(([mode, ids]) =>
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
),
]) ])
const groupById = new Map(groups.map((row) => [row.problemId, row])) const groupById = new Map(groupLists.flat().map((row) => [row.problemId, row]))
return { return {
source: 'teacher' as const, source: 'teacher' as const,
lessonId: plan.id, lessonId: plan.id,
day: today, day: today,
language: plan.language ?? null, language: plan.language ?? null,
mode: plan.mode, mode: plan.mode,
steps,
assignedAt: plan.createdAt, assignedAt: plan.createdAt,
assignedBy: plan.createdBy, assignedBy: plan.createdBy,
problems: planned.flatMap((id) => { problems: planned.flatMap((id) => {
@@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null, language: null,
mode: null, mode: null,
steps: null,
assignedAt: null, assignedAt: null,
assignedBy: null, assignedBy: null,
problems: picked.flatMap((row) => { problems: picked.flatMap((row) => {
+26 -24
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
const list = byUser.get(row.userId)
if (list) list.push(row.at)
else byUser.set(row.userId, [row.at])
}
return people.map((person) => {
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, id: person.id,
username: person.username, username: person.username,
className: person.className, className: person.className,
avatar: avatarOf(person.avatar), avatar: avatarOf(person.avatar),
mood: person.mood?.trim() || null, mood: person.mood?.trim() || null,
times: pairs.map(([time]) => time), times: byUser.get(person.id) ?? [],
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)
+2 -2
View File
@@ -786,9 +786,9 @@ export function deleteAchievement(id: number) {
// ---------- 课堂任务(class_lesson) ---------- // ---------- 课堂任务(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null; mode: string }) {
return api.get<AdminLessonList>('admin/lessons', { return api.get<AdminLessonList>('admin/lessons', {
params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' }, params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '', mode: filters.mode },
}) })
} }
+30 -11
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList } from '@oj2/contract' import { LESSON_MODE_LABEL, type AdminLesson, type AdminLessonList, type LessonMode } from '@oj2/contract'
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import PageHeader from 'admin/components/PageHeader.vue' import PageHeader from 'admin/components/PageHeader.vue'
import { NButton, NPopconfirm, NTooltip, useThemeVars } from 'naive-ui' import { NButton, NPopconfirm, NTag, NTooltip, useThemeVars } from 'naive-ui'
import { dayStartOfDate } from 'oj/statistics/period' import { dayStartOfDate } from 'oj/statistics/period'
import Pagination from 'shared/components/Pagination.vue' import Pagination from 'shared/components/Pagination.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
@@ -27,13 +27,16 @@ const configStore = useConfigStore()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 从任务页「布置过的课」进来带着 ?className= // 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({ const { query } = usePagination<{ className: string; teacher: string; mode: string }>({
className: useRouteQuery('className', '').value, className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value, teacher: useRouteQuery('teacher', '').value,
mode: useRouteQuery('mode', '').value,
}) })
const data = ref<AdminLessonList | null>(null) const data = ref<AdminLessonList | null>(null)
const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])]) const classOptions = computed(() => [{ label: '全部班级', value: '' }, ...groupClassOptions(configStore.config?.classList ?? [])])
const MODES: LessonMode[] = ['code', 'both', 'flowchart']
const modeOptions = [{ label: '全部做法', value: '' }, ...MODES.map((value) => ({ label: LESSON_MODE_LABEL[value], value }))]
const teacherOptions = computed(() => [ const teacherOptions = computed(() => [
{ label: '全部老师', value: '' }, { label: '全部老师', value: '' },
...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })), ...(data.value?.teachers ?? []).map((teacher) => ({ label: teacher.name, value: String(teacher.id) })),
@@ -46,13 +49,14 @@ async function list() {
getAdminLessons((query.page - 1) * query.limit, query.limit, { getAdminLessons((query.page - 1) * query.limit, query.limit, {
className: query.className, className: query.className,
teacher: query.teacher ? Number(query.teacher) : null, teacher: query.teacher ? Number(query.teacher) : null,
mode: query.mode,
}), }),
) )
if (res) data.value = res if (res) data.value = res
} }
onMounted(list) onMounted(list)
// 换了班级、老师 usePagination 会把页码拨回 1,同一拍里只拉一次 // 换了班级、老师、做法 usePagination 会把页码拨回 1,同一拍里只拉一次
watch(() => [query.page, query.limit, query.className, query.teacher], list) watch(() => [query.page, query.limit, query.className, query.teacher, query.mode], list)
// ---- 改 / 补 / 删 ---- // ---- 改 / 补 / 删 ----
const editing = ref(false) const editing = ref(false)
@@ -133,17 +137,24 @@ function renderActions(lesson: AdminLesson) {
]) ])
} }
/** 「画流程图」「先画再写 · C语言」「C语言」:写代码是默认的,不写出来 */ /** 三种做法各一个颜色,一眼分得开;写代码也写出来,不当默认值省掉 */
function wayText(lesson: AdminLesson) { const MODE_TAG_TYPE: Record<LessonMode, 'default' | 'info' | 'warning'> = { code: 'default', both: 'info', flowchart: 'warning' }
return [lesson.mode !== 'code' ? LESSON_MODE_LABEL[lesson.mode] : '', lesson.language ? LANGUAGE_SHOW_VALUE[lesson.language] : ''].filter(Boolean).join(' · ')
/** 「写代码 C语言」「先画再写 Python」「画流程图」:画流程图不用语言 */
function renderWay(lesson: AdminLesson) {
return h('div', { class: 'way' }, [
h(NTag, { size: 'small', bordered: false, type: MODE_TAG_TYPE[lesson.mode] }, { default: () => LESSON_MODE_LABEL[lesson.mode] }),
lesson.language ? h('span', { class: 'way-lang' }, LANGUAGE_SHOW_VALUE[lesson.language]) : null,
])
} }
/** 手机上一节课一张卡:日子 · 时刻 · 班级一行,题号一行,老师 · 语言 · 做完一行小字,操作一行 */ /** 手机上一节课一张卡:日子 · 时刻 · 班级一行,做什么一行,题号一行,老师 · 做完一行小字,操作一行 */
function renderMobileCard(lesson: AdminLesson) { function renderMobileCard(lesson: AdminLesson) {
return h('div', { class: 'm-card' }, [ return h('div', { class: 'm-card' }, [
h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`), h('div', { class: 'm-head' }, `${dayText(lesson.day)} ${parseTime(lesson.createdAt, 'HH:mm')} · ${classLabel(lesson.className)}`),
renderWay(lesson),
renderProblems(lesson), renderProblems(lesson),
h('div', { class: 'm-meta' }, [lesson.createdBy, wayText(lesson), `做完 ${renderDone(lesson)}`].filter(Boolean).join(' · ')), h('div', { class: 'm-meta' }, `${lesson.createdBy} · 做完 ${renderDone(lesson)}`),
renderActions(lesson), renderActions(lesson),
]) ])
} }
@@ -153,7 +164,7 @@ const desktopColumns: DataTableColumn<AdminLesson>[] = [
{ title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') }, { title: '布置', key: 'createdAt', width: 70, render: (row) => parseTime(row.createdAt, 'HH:mm') },
{ title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) }, { title: '班级', key: 'className', width: 130, render: (row) => classLabel(row.className) },
{ title: '老师', key: 'createdBy', width: 90 }, { title: '老师', key: 'createdBy', width: 90 },
{ title: '做什么', key: 'language', width: 120, render: (row) => wayText(row) || '—' }, { title: '做什么', key: 'mode', width: 150, render: renderWay },
{ title: '题目', key: 'problems', width: 300, render: renderProblems }, { title: '题目', key: 'problems', width: 300, render: renderProblems },
{ title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone }, { title: '做完 / 全班', key: 'doneCount', width: 100, render: renderDone },
{ title: '', key: 'actions', render: renderActions }, { title: '', key: 'actions', render: renderActions },
@@ -173,6 +184,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
<template #filters> <template #filters>
<n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" /> <n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" :style="{ width: isMobile ? '100%' : '160px' }" />
<n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" /> <n-select v-model:value="query.teacher" :options="teacherOptions" aria-label="老师" :style="{ width: isMobile ? '100%' : '140px' }" />
<n-select v-model:value="query.mode" :options="modeOptions" aria-label="做什么" :style="{ width: isMobile ? '100%' : '120px' }" />
</template> </template>
</PageHeader> </PageHeader>
<n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" /> <n-data-table :loading="loading" striped class="lesson-table" :columns="columns" :data="data?.results ?? []" :row-key="(row: AdminLesson) => row.id" />
@@ -205,6 +217,13 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
text-decoration: line-through; text-decoration: line-through;
} }
.lesson-table :deep(.way) {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.lesson-table :deep(.actions) { .lesson-table :deep(.actions) {
display: flex; display: flex;
gap: 8px; gap: 8px;
+10
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'
@@ -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>
+6
View File
@@ -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: '连着几周有做对',
+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
+4 -1
View File
@@ -41,6 +41,7 @@ import {
type ProblemRank, type ProblemRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemListItem, type ProblemListItem,
type SimilarProblem,
type ProblemStats, type ProblemStats,
type ProblemProgress, type ProblemProgress,
type CreateFlowchartResponse, type CreateFlowchartResponse,
@@ -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 })))
} }
// ==================== 挑战 ==================== // ==================== 挑战 ====================
+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 ''
+89 -4
View File
@@ -5,11 +5,13 @@ 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
@@ -35,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)
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */ /** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
@@ -122,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() {
@@ -191,7 +196,7 @@ const classActivityTitle = computed(() => {
// 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') { if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language const language = classActivity.value.language
const mode = classActivity.value.mode const mode = activityMode(classActivity.value)
if (mode === 'flowchart') return '这节课 · 画流程图' if (mode === 'flowchart') return '这节课 · 画流程图'
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码` if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}` return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
@@ -243,6 +248,86 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 看板娘:今天有课(和「这节课」那张卡同一个条件)就催今天的题,没课就随便做一道
let greetedBack = false
useMascotGuide(() => {
if (!loaded.value) return null
const random = randomProblemAction(router)
const learn = goAction(router, '自学', '/learn')
const lesson = classToday.value && !!classActivity.value?.problems.length
const next = nextProblem.value
if (lesson && next)
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
const base: MascotGuide = lesson
? { situation: 'home.done', actions: [random, learn] }
: { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
// 好久没来:只在这次进来后第一次点她时说,之后照常
if (awayDays.value >= 14 && !greetedBack) {
greetedBack = true
return { ...base, situation: 'home.back', n: awayDays.value }
}
// 这节课做完了或者没课:从别的数据里挑能说的,每点一次随机说一件,点几下听到的不一样
const extras = extraGuides(random)
return extras.length ? extras[Math.floor(Math.random() * extras.length)]! : base
})
const DAY_MS = 864e5
function extraGuides(random: MascotAction): MascotGuide[] {
const list: MascotGuide[] = []
// 比赛:进行中,或者 24 小时内开始(那就只可能是今天或明天)
const now = Date.now()
const contest = contests.value.find((it) => it.status === ContestStatus.underway || Date.parse(it.startTime) - now < DAY_MS)
if (contest) {
const when = contest.status === ContestStatus.underway ? '现在' : dayKey(contest.startTime) === dayKey(new Date()) ? '今天' : '明天'
list.push({ situation: 'home.contestSoon', vars: { when }, actions: [goAction(router, '去看看', `/contest/${contest.id}`), random] })
}
// 公告:三天内发的,挑最新那条
const fresh = announcements.value.filter((it) => now - Date.parse(it.createTime) < 3 * DAY_MS).sort((a, b) => b.createTime.localeCompare(a.createTime))[0]
if (fresh) {
list.push({
situation: 'home.newAnnouncement',
actions: [{ label: '去看看', run: () => router.push({ path: '/announcement', query: { id: fresh.id } }) }, random],
})
}
// 知识点:和首页那一行同一个先后(本周升级 > 离下一档最近 > 班里在学、自己还没碰过),只说一件
const map = knowledge.value
if (map) {
const touched = new Set(map.classTouched)
const go = (name: string) => ({ label: '去做', run: () => router.push(tagLink(name)) })
const upgraded = map.tags.filter((tag) => tag.level > tag.levelAtWeekStart).sort((a, b) => b.level - a.level)[0]
const learning = map.tags
.filter((tag) => tag.level >= 1 && tag.level < MAX_LEVEL)
.sort((a, b) => Number(touched.has(b.name)) - Number(touched.has(a.name)) || remainingToNext(a)! - remainingToNext(b)!)[0]
const untouched = map.tags.find((tag) => tag.level === 0 && touched.has(tag.name))
if (upgraded)
list.push({ situation: 'home.knowledgeUp', vars: { tag: upgraded.name, level: levelName(upgraded.level) }, actions: [go(upgraded.name), random] })
else if (learning)
list.push({
situation: 'home.knowledgeNext',
n: remainingToNext(learning)!,
vars: { tag: learning.name, level: levelName(learning.level + 1) },
actions: [go(learning.name), random],
})
else if (untouched) list.push({ situation: 'home.knowledgeFresh', vars: { tag: untouched.name }, actions: [go(untouched.name), random] })
}
// 本周进步榜:第一就说第一;前一名在榜面上(只回前几名)、差得不多就报还要几道。
// 同分时提交少的在前,追平不一定超得过,所以按多做 1 道算
const me = weekly.value?.me
if (me) {
const rank = goAction(router, '去排名', '/rank?period=week')
const ahead = weekly.value!.results.find((row) => row.rank === me.rank - 1)
if (me.rank === 1) list.push({ situation: 'rank.top', actions: [rank, random] })
else if (ahead && ahead.solvedCount - me.solvedCount <= 5)
list.push({ situation: 'rank.gap', n: ahead.solvedCount - me.solvedCount + 1, actions: [rank, random] })
}
return list
}
function learnLink(track: Track) { function learnLink(track: Track) {
return tutorialPath(track.type, track.step) return tutorialPath(track.type, track.step)
} }
+27
View File
@@ -6,6 +6,7 @@ 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'
/** /**
@@ -66,6 +67,32 @@ function openTask(row: ClassRow) {
router.push({ name: 'task', query: { className: row.className } }) router.push({ name: 'task', query: { className: row.className } })
} }
/**
* 看板娘:今天有班布置了题,就带去那个班这节课的提交(和提交列表「这节课」同一组筛选:班、这几道题、今天),
* 有好几个班时挑正在上课的那个
*/
useMascotGuide(() => {
if (!today.value) return null
const assigned = today.value.classes.filter((row) => row.source === 'teacher' && row.problems.length)
const row = assigned.find((it) => stateOf(it).live) ?? assigned[0]
if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] }
const query = { className: row.className, problem: row.problems.map((problem) => problem.problemDisplayId).join(','), today: '1' }
// 有人交过了就说这节课做完几个,还没人交就照旧那几句
const progress = row.activeCount
? {
situation: row.allDoneCount >= row.rosterCount ? ('teacher.home.progressDone' as const) : ('teacher.home.progress' as const),
vars: { cls: classLabel(row.className), done: row.allDoneCount, total: row.rosterCount, left: row.rosterCount - row.allDoneCount },
}
: { situation: 'teacher.home.assigned' as const }
return {
...progress,
actions: [
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
{ label: '课堂任务', run: () => openTask(row) },
],
}
})
const shortcuts = computed(() => const shortcuts = computed(() =>
[ [
{ label: '课堂任务', to: '/task', show: true }, { label: '课堂任务', to: '/task', show: true },
+18
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils' import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
import TutorialRow from './components/TutorialRow.vue' import TutorialRow from './components/TutorialRow.vue'
import ProjectTile from './components/ProjectTile.vue' import ProjectTile from './components/ProjectTile.vue'
@@ -94,6 +95,23 @@ function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
} }
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 1 课开始
useMascotGuide(() => {
if (!loaded.value || userStore.isTeacherOrAbove) return null
const it = resume.value
if (it) {
const next = goAction(router, '接着学', tutorialPath(it.course.type, it.step))
// 接着学的那课之前,离它最近的一课打开过、练一练却没全对(「在学」那一态):先叫回去看看
const { titles, progress } = it.course
for (let step = it.step - 1; step >= 1; step--) {
if (tutorialState(progress[titles[step - 1]!.id]) !== 'started') continue
return { situation: 'learn.wrongExercise', n: step, actions: [goAction(router, '去看看', tutorialPath(it.course.type, step)), next] }
}
return { situation: 'learn.continue', n: it.step, actions: [next] }
}
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
})
watch(() => userStore.isAuthed, load) watch(() => userStore.isAuthed, load)
onMounted(load) onMounted(load)
</script> </script>
+11
View File
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils' import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
import TutorialBody from './components/TutorialBody.vue' import TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue' import TutorialEnd from './components/TutorialEnd.vue'
@@ -233,6 +234,16 @@ function jump(id: string) {
function jumpExercise(id: number) { function jumpExercise(id: number) {
jump(`exercise-${id}`) jump(`exercise-${id}`)
} }
// 看板娘:练一练没做完就带去第一道没做对的;做完了(或这课没有练一练)就去下一课
useMascotGuide(() => {
if (!tutorial.value || userStore.isTeacherOrAbove) return null
const to = next.value?.to
const nextLesson: MascotAction[] = to ? [{ label: '下一课', run: () => router.push(to) }] : []
const unsolved = firstUnsolved.value
if (!unsolved) return { situation: 'tutorial.passed', actions: nextLesson }
return { situation: 'tutorial', actions: [{ label: '练一练', run: () => jumpExercise(unsolved.id) }, ...nextLesson] }
})
</script> </script>
<template> <template>
+5
View File
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
import { useAIStream } from 'shared/composables/aiStream' import { useAIStream } from 'shared/composables/aiStream'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import PkDuel from './components/PkDuel.vue' import PkDuel from './components/PkDuel.vue'
import PkGrid from './components/PkGrid.vue' import PkGrid from './components/PkGrid.vue'
import PkRace from './components/PkRace.vue' import PkRace from './components/PkRace.vue'
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
const MAX_CLASSES = 8 const MAX_CLASSES = 8
const teacher = computed(() => userStore.isTeacherOrAbove) const teacher = computed(() => userStore.isTeacherOrAbove)
// 看板娘:学生念叨一句班级对决,老师只打招呼
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
const pk = ref<ClassPk | null>(null) const pk = ref<ClassPk | null>(null)
const loading = ref(false) const loading = ref(false)
const battle = ref<ClassBattleItem[]>([]) const battle = ref<ClassBattleItem[]>([])
@@ -71,8 +71,8 @@ const label = computed(() => {
} }
.block { .block {
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
display: inline-flex; display: inline-flex;
@@ -6,6 +6,7 @@ import { applyFixes, clearErrorMark, currentCode, showErrorMark, showPunctuation
import { explainCCompileError } from 'oj/problem/utils/cError' import { explainCCompileError } from 'oj/problem/utils/cError'
import { explainPythonCompileError } from 'utils/pythonError' import { explainPythonCompileError } from 'utils/pythonError'
import { stripJudgePath } from 'utils/judge' import { stripJudgePath } from 'utils/judge'
import SectionHead from './SectionHead.vue'
/** /**
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走 * 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
@@ -80,17 +81,17 @@ onUnmounted(clearErrorMark)
</script> </script>
<template> <template>
<div class="explain-card"> <!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明;那一行编辑器里已经标红了 -->
<div class="explain-section">
<template v-if="explanation"> <template v-if="explanation">
<p class="explain"> <SectionHead icon="problem" tone="amber">{{ explanation.line ? `第 ${explanation.line} 行` : '代码的格式写错了' }}</SectionHead>
<b v-if="explanation.line">第 {{ explanation.line }} 行:</b>{{ explanation.message }} <p class="explain">{{ explanation.message }}</p>
</p>
<p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p> <p v-if="otherPunctuationLines.length" class="explain other">第 {{ otherPunctuationLines.join('、') }} 行也有中文标点,编辑器里都标出来了。</p>
<div v-if="explanation.punctuation"> <div v-if="explanation.punctuation" class="fix">
<n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button> <n-button v-if="punctuationFixCount" type="primary" @click="fixPunctuation"> 把中文标点都换成英文({{ punctuationFixCount }} 处) </n-button>
<n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text> <n-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text>
</div> </div>
<n-collapse> <n-collapse class="raw-collapse">
<n-collapse-item title="原始报错(英文)" name="raw"> <n-collapse-item title="原始报错(英文)" name="raw">
<div class="raw">{{ rawError }}</div> <div class="raw">{{ rawError }}</div>
</n-collapse-item> </n-collapse-item>
@@ -98,6 +99,7 @@ onUnmounted(clearErrorMark)
</template> </template>
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 --> <!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
<template v-else> <template v-else>
<SectionHead icon="problem" tone="amber">代码的格式写错了</SectionHead>
<p class="explain">请仔细检查,看看代码的格式是不是写错了!</p> <p class="explain">请仔细检查,看看代码的格式是不是写错了!</p>
<div class="raw">{{ rawError }}</div> <div class="raw">{{ rawError }}</div>
</template> </template>
@@ -105,20 +107,15 @@ onUnmounted(clearErrorMark)
</template> </template>
<style scoped> <style scoped>
/* 和答错的说明同一种浅灰卡片(设计稿「语法没过」) */ .explain-section {
.explain-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
} }
.explain { .explain {
margin: 0; margin: 0 0 10px;
line-height: 1.7; font-size: 16px;
line-height: 1.75;
} }
.explain.other { .explain.other {
@@ -126,6 +123,10 @@ onUnmounted(clearErrorMark)
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
} }
.fix {
margin-bottom: 10px;
}
.raw { .raw {
white-space: pre; white-space: pre;
overflow-x: auto; overflow-x: auto;
+232 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api' import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
@@ -33,15 +34,16 @@ const ctx = useProblemPageContext()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
const theme = useThemeVars() const theme = useThemeVars()
const isDark = useDark() const isDark = useDark()
/** 做完的实心绿圈里的勾:暗色下主题绿很亮,白勾看不清 */
const doneDotText = computed(() => (isDark.value ? 'rgba(0, 0, 0, 0.85)' : '#fff'))
/** /**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、专题浅蓝、比赛浅黄。 * 课堂、比赛在电脑上是左边的竖栏(课堂浅绿底、比赛浅黄底),专题是一条不带底色的细横条;
* 手机上三种都是横条(课堂浅绿、比赛浅黄)。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清 * 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/ */
const tone = computed(() => ({ const tone = computed(() => ({
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed, lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
setText: isDark.value ? '#8fb4ee' : '#1d4f9c',
setBar: isDark.value ? '#6f9be0' : '#2f6bc4',
contestText: isDark.value ? '#f2c97d' : '#8a5200', contestText: isDark.value ? '#f2c97d' : '#8a5200',
})) }))
const userStore = useUserStore() const userStore = useUserStore()
@@ -241,8 +243,35 @@ onSolvedHere(() => {
</script> </script>
<template> <template>
<!-- 课堂 --> <!--
<div v-if="lesson" class="context-bar lesson"> 课堂(电脑):左栏最左边一条竖栏,只写题号,题名鼠标停上去看(学生认题靠题号)。
不占高度:机房 1366×768 那种屏,顶上每省一行,题面和编辑器就多一行(设计稿「题目页观感」合一版)
-->
<nav v-if="lesson && !isMobile" class="lesson-rail" aria-label="这节课的题">
<div class="rail-head">
<span>这节课</span>
<b class="rail-done">{{ lesson.done }}/{{ lesson.total }}</b>
<span v-if="lessonStore.languageText" class="rail-lang">{{ lessonStore.languageText }}</span>
</div>
<router-link
v-for="(chip, index) in lesson.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.note ? `${chip.title}(${chip.note})` : chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<span class="rail-dot">
<Icon v-if="chip.status === 'done'" icon="ph:check-bold" :width="14" />
<template v-else>{{ index + 1 }}</template>
</span>
<span class="rail-id">{{ chip.displayId }}</span>
</router-link>
</nav>
<!-- 课堂(手机):一条横的 -->
<div v-else-if="lesson" class="context-bar lesson">
<span class="lead lesson-lead" <span class="lead lesson-lead"
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span >这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
> >
@@ -356,7 +385,37 @@ onSolvedHere(() => {
</component> </component>
</div> </div>
<!-- 比赛 --> <!--
比赛(电脑):和课堂一样是左边一条竖栏 —— 比赛一般 3 到 10 道题,题号一列摆得下。
顶上「‹ 比赛」回比赛页(比赛名鼠标停上去看)、还剩多久;底下「排名」(期中期末进行中不给)
-->
<nav v-else-if="contestBar && !isMobile" class="lesson-rail contest-rail" aria-label="比赛的题">
<router-link :to="contestBar.home" class="rail-back" :title="`回到比赛:${contestBar.title}`">
<Icon icon="ph:caret-left-bold" :width="12" aria-hidden="true" />比赛
</router-link>
<div class="rail-head">
<template v-if="contestBar.remaining">
<span>还剩</span>
<b class="rail-time">{{ contestBar.remaining }}</b>
</template>
<span v-else>{{ contestBar.notStarted ? '还没开始' : '已结束' }}</span>
</div>
<router-link
v-for="chip in contestBar.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<!-- 比赛题号本来就短(1、2、A),直接写在圈里;做对了圈涂绿、号还在,认得出是哪道 -->
<span class="rail-dot">{{ chip.displayId }}</span>
</router-link>
<router-link v-if="contestBar.rank" :to="contestBar.rank" class="rail-rank"> <Icon icon="ph:ranking" :width="18" aria-hidden="true" />排名 </router-link>
</nav>
<!-- 比赛(手机) -->
<div v-else-if="contestBar" class="context-bar contest"> <div v-else-if="contestBar" class="context-bar contest">
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link> <router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" /> <ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
@@ -371,6 +430,165 @@ onSolvedHere(() => {
</template> </template>
<style scoped> <style scoped>
/* 比赛竖栏:浅黄底,和课堂分得开 */
.contest-rail {
background-color: rgba(208, 138, 28, 0.07);
border-right-color: rgba(208, 138, 28, 0.2);
}
.contest-rail .rail-item:hover {
background-color: rgba(208, 138, 28, 0.1);
}
.contest-rail .rail-dot {
font-size: 12px;
overflow: hidden;
}
.rail-back {
display: flex;
align-items: center;
justify-content: center;
gap: 1px;
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: v-bind('tone.contestText');
text-decoration: none;
}
.rail-back:hover {
text-decoration: underline;
}
.rail-time {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.contestText');
}
.rail-rank {
margin-top: auto;
padding: 8px 0 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 12px;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-rank:hover {
color: v-bind('tone.contestText');
}
/* ==================== 课堂竖栏(电脑) ==================== */
.lesson-rail {
width: 56px;
flex: none;
box-sizing: border-box;
padding: 8px 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
overflow-y: auto;
scrollbar-width: none;
background-color: rgba(24, 160, 88, 0.06);
border-right: 1px solid rgba(24, 160, 88, 0.14);
}
.rail-head {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
line-height: 1.35;
color: v-bind('theme.textColor3');
}
.rail-done {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.lessonText');
}
.rail-lang {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 7px 0;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-item:hover {
background-color: rgba(24, 160, 88, 0.08);
}
.rail-item.current {
font-weight: 700;
color: v-bind('tone.lessonText');
background-color: v-bind('theme.bodyColor');
box-shadow: inset 3px 0 v-bind('theme.primaryColor');
}
.rail-id {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-dot {
width: 28px;
height: 28px;
box-sizing: border-box;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
border: 1.5px solid rgba(128, 128, 128, 0.4);
background-color: v-bind('theme.bodyColor');
color: v-bind('theme.textColor2');
}
.done .rail-dot {
border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: v-bind('doneDotText');
}
/* 做过没对:红圈,和题目列表的红「−」一个意思 */
.tried .rail-dot {
border-color: v-bind('theme.errorColor');
color: v-bind('theme.errorColor');
}
.current .rail-dot {
border-width: 2px;
border-color: v-bind('theme.primaryColor');
color: v-bind('tone.lessonText');
}
.current.done .rail-dot {
color: v-bind('doneDotText');
}
.context-bar { .context-bar {
height: 44px; height: 44px;
flex: none; flex: none;
@@ -390,10 +608,12 @@ onSolvedHere(() => {
border-bottom-color: rgba(24, 160, 88, 0.18); border-bottom-color: rgba(24, 160, 88, 0.18);
} }
/* 专题:不带底色的一条细横条(设计稿「题目页观感」合一版)。专题可以有几十道题、还有「加入专题」,
竖栏放不下,留在页签行上面 */
.problemset { .problemset {
height: 40px;
gap: 10px; gap: 10px;
background-color: rgba(47, 107, 196, 0.06); border-bottom-color: v-bind('theme.dividerColor');
border-bottom-color: rgba(47, 107, 196, 0.16);
} }
.contest { .contest {
@@ -437,7 +657,7 @@ onSolvedHere(() => {
} }
.set-back { .set-back {
color: v-bind('tone.setText'); color: v-bind('theme.textColor1');
} }
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */ /* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
@@ -452,14 +672,14 @@ onSolvedHere(() => {
width: 110px; width: 110px;
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16); background-color: rgba(128, 128, 128, 0.16);
overflow: hidden; overflow: hidden;
} }
.set-progress-fill { .set-progress-fill {
height: 100%; height: 100%;
border-radius: 3px; border-radius: 3px;
background-color: v-bind('tone.setBar'); background-color: v-bind('theme.primaryColor');
} }
.countdown { .countdown {
@@ -503,7 +723,7 @@ onSolvedHere(() => {
} }
.problemset .step.forward { .problemset .step.forward {
color: v-bind('tone.setText'); color: v-bind('theme.textColor1');
} }
.step:not(.disabled):hover { .step:not(.disabled):hover {
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useCodeStore } from 'oj/store/code' import { useCodeStore } from 'oj/store/code'
@@ -29,7 +31,7 @@ function fillSample(input: string) {
<template> <template>
<!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) --> <!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) -->
<div class="custom"> <div class="custom">
<span class="label">输入</span> <SectionHead icon="input">输入</SectionHead>
<n-input <n-input
v-model:value="customInput" v-model:value="customInput"
type="textarea" type="textarea"
@@ -38,15 +40,16 @@ function fillSample(input: string) {
class="mono" class="mono"
/> />
<div class="actions"> <div class="actions">
<n-button type="primary" ghost :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()"> <n-button type="primary" secondary :loading="customRunning" :disabled="!codeStore.code.value.trim()" @click="submissionStore.runCustom()">
<template #icon><Icon icon="ph:play-fill" /></template>
用这组数据运行 用这组数据运行
</n-button> </n-button>
<n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text class="fill" @click="fillSample(sample.input)"> <n-button v-for="(sample, index) in problem?.samples ?? []" :key="index" text type="primary" class="fill" @click="fillSample(sample.input)">
填入例子 {{ index + 1 }} 填入例子 {{ index + 1 }}
</n-button> </n-button>
</div> </div>
<span class="label">输出</span> <SectionHead icon="output" class="output-head">输出</SectionHead>
<TrialErrorNote <TrialErrorNote
v-if="customResult !== null && customResult !== SubmissionStatus.accepted" v-if="customResult !== null && customResult !== SubmissionStatus.accepted"
:result="customResult" :result="customResult"
@@ -68,13 +71,14 @@ function fillSample(input: string) {
gap: 8px; gap: 8px;
} }
.label { .output-head {
font-size: 13px; margin-top: 14px;
font-weight: 600;
} }
.mono :deep(textarea) { .mono :deep(textarea) {
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
} }
.actions { .actions {
@@ -85,13 +89,12 @@ function fillSample(input: string) {
} }
.fill { .fill {
font-size: 13px; font-size: 14px;
color: v-bind('theme.textColor2');
} }
.output { .output {
margin: 0; margin: 0;
min-height: 88px; min-height: 60px;
box-sizing: border-box; box-sizing: border-box;
padding: 10px 10px; padding: 10px 10px;
border-radius: 6px; border-radius: 6px;
@@ -99,7 +102,9 @@ function fillSample(input: string) {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: Consolas, Monaco, monospace; font-family: Consolas, Monaco, monospace;
font-size: 14px; font-size: 16px;
line-height: 24px;
border: 1px solid rgba(128, 128, 128, 0.14);
} }
.output.empty { .output.empty {
@@ -182,7 +182,9 @@ const changeLanguage = (v: LANGUAGE) => {
<n-select <n-select
v-if="!drawing" v-if="!drawing"
v-model:value="codeStore.code.language" v-model:value="codeStore.code.language"
class="language"
style="width: 120px" style="width: 120px"
:bordered="false"
:size="buttonSize" :size="buttonSize"
:options="languageOptions" :options="languageOptions"
:disabled="showCollabBar" :disabled="showCollabBar"
@@ -190,15 +192,19 @@ const changeLanguage = (v: LANGUAGE) => {
/> />
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 --> <!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
<span v-if="!drawing" class="divider" aria-hidden="true" />
<!-- 浅绿底:比实心的「提交」轻一档,又比旁边不带框的按钮重(设计稿「题目页观感」合一版) -->
<n-button <n-button
v-if="canRunSamples && isDesktop" v-if="canRunSamples && isDesktop"
:size="buttonSize" :size="buttonSize"
type="primary" type="primary"
ghost secondary
:loading="samplesRunning" :loading="samplesRunning"
:disabled="!codeStore.code.value.trim()" :disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()" @click="submissionStore.runSamples()"
> >
<template #icon><Icon icon="ph:play-fill" /></template>
运行例子 运行例子
</n-button> </n-button>
@@ -215,22 +221,26 @@ const changeLanguage = (v: LANGUAGE) => {
:size="buttonSize" :size="buttonSize"
:type="helpButtonType" :type="helpButtonType"
:secondary="teacherIn || collabStore.helpStatus !== 'idle'" :secondary="teacherIn || collabStore.helpStatus !== 'idle'"
:quaternary="!teacherIn && collabStore.helpStatus === 'idle'"
:title="helpButtonTitle" :title="helpButtonTitle"
class="help" class="help"
:class="{ busy: teacherIn }" :class="{ busy: teacherIn }"
@click="toggleHelp" @click="toggleHelp"
> >
<span v-if="teacherIn" class="dot" aria-hidden="true" /> <span v-if="teacherIn" class="dot" aria-hidden="true" />
<Icon v-else-if="collabStore.helpStatus === 'idle'" icon="ph:hand" :width="17" class="help-icon" aria-hidden="true" />
{{ helpButtonText }} {{ helpButtonText }}
</n-button> </n-button>
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" @click="openStatistics(problem!._id)"> <n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" quaternary @click="openStatistics(problem!._id)">
课堂统计 课堂统计
</n-button> </n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 --> <!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
<n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect"> <n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect">
<n-button :size="buttonSize" class="more" aria-label="更多:去自测猫、复制代码、重置代码……"> ⋯ </n-button> <n-button :size="buttonSize" quaternary class="more" aria-label="更多:去自测猫、复制代码、重置代码……">
<Icon icon="ph:dots-three-bold" :width="18" />
</n-button>
</n-dropdown> </n-dropdown>
</div> </div>
</template> </template>
@@ -288,6 +298,23 @@ const changeLanguage = (v: LANGUAGE) => {
padding: 0; padding: 0;
} }
/* 语言选择不带框,字加粗:它不是一个要常去点的按钮 */
.language :deep(.n-base-selection-label) {
font-weight: 600;
}
.divider {
flex: none;
width: 1px;
height: 20px;
margin: 0 2px;
background-color: v-bind('theme.dividerColor');
}
.help-icon {
margin-right: 4px;
}
.help.busy { .help.busy {
cursor: default; cursor: default;
} }
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useLessonStore } from 'shared/store/lesson' import { useLessonStore } from 'shared/store/lesson'
import ChallengeList from './ChallengeList.vue' import ChallengeList from './ChallengeList.vue'
@@ -44,16 +45,17 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</script> </script>
<template> <template>
<!-- 设计稿「答案正确:下一题在结果页签里」:浅绿卡片,一个 44px 的大按钮 --> <!--
<div v-if="lesson" class="lesson-next"> 设计稿「题目页观感」结果 X:「接着做」一节,一句话说还剩几道,一个 44px 的大按钮写下一题。
原来是一张浅绿卡片:左边竖栏已经有这节课的进度,不用再框一块
-->
<section v-if="lesson" class="lesson-next">
<SectionHead icon="next">接着做</SectionHead>
<template v-if="lesson.next"> <template v-if="lesson.next">
<div class="text"> <p class="text">
<span class="headline">{{ label }}还剩 {{ lesson.remaining }} 道</span> {{ label }}还剩 {{ lesson.remaining }} 道,下一道 {{ lesson.next.problemDisplayId }}
<span class="detail"> {{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}。
{{ lesson.next.problemDisplayId }} </p>
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}
</span>
</div>
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next"> <router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
<span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span> <span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span>
<svg <svg
@@ -72,15 +74,12 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</router-link> </router-link>
</template> </template>
<template v-else> <template v-else>
<div class="text"> <p class="text">{{ label }}都做完了,一共 {{ lesson.total }} 道。<template v-if="!challenges">想接着练,可以去专题里挑一个。</template></p>
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span v-if="!challenges" class="detail">想接着练,可以去专题里挑一个</span>
</div>
<router-link v-if="!challenges" to="/problemset" class="next secondary"> <router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去专题继续练</span> <span class="next-label">去专题继续练</span>
</router-link> </router-link>
</template> </template>
</div> </section>
<!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 --> <!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
<template v-if="lesson && !lesson.next && challenges"> <template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" /> <ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
@@ -90,30 +89,15 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
<style scoped> <style scoped>
.lesson-next { .lesson-next {
margin-top: 12px; margin-top: 4px;
padding: 16px 18px;
border-radius: 8px;
background-color: rgba(24, 160, 88, 0.07);
border: 1px solid rgba(24, 160, 88, 0.25);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
} }
.text { .text {
display: flex; margin: 0 0 12px;
flex-direction: column; font-size: 16px;
gap: 4px; line-height: 1.75;
}
.headline {
font-size: 15px;
font-weight: 600;
}
.detail {
font-size: 13px;
color: v-bind('theme.textColor2');
} }
.next { .next {
@@ -125,10 +109,10 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
gap: 8px; gap: 8px;
height: 44px; height: 44px;
padding: 0 20px; padding: 0 20px;
border-radius: 6px; border-radius: 8px;
background-color: v-bind('theme.primaryColor'); background-color: v-bind('theme.primaryColor');
color: v-bind('buttonText'); color: v-bind('buttonText');
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
@@ -1,14 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { isSqlProblem, parseFlowDoc } from '@oj2/contract' import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { DIFFICULTY } from 'utils/constants' import { DIFFICULTY } from 'utils/constants'
import { copyToClipboard, getTagColor } from 'utils/functions' import { getTagColor } from 'utils/functions'
import { useSubmissionStore } from 'oj/store/submission' import { useSubmissionStore } from 'oj/store/submission'
import Markdown from 'shared/components/Markdown.vue' import Markdown from 'shared/components/Markdown.vue'
import SqlProblemData from './SqlProblemData.vue' import SqlProblemData from './SqlProblemData.vue'
import SimilarProblems from './SimilarProblems.vue' import SimilarProblems from './SimilarProblems.vue'
import SampleList from './SampleList.vue'
import SectionHead from './SectionHead.vue'
import { tidyStatement } from '../utils/statement'
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart' import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
import { useMyFlowchartStore } from 'shared/store/myFlowchart' import { useMyFlowchartStore } from 'shared/store/myFlowchart'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
@@ -73,27 +77,15 @@ watch(
() => (myFlowchartOpen.value = true), () => (myFlowchartOpen.value = true),
) )
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */ /**
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
*/
const flowchartGrade = computed(() => { const flowchartGrade = computed(() => {
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade)) const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
if (!passed.length) return '' return best?.grade ?? ''
return passed.some((row) => row.grade === 'S') ? 'S' : 'A'
}) })
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, {
immediate: false,
})
async function copySample(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。 // 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来 // 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
@@ -105,140 +97,96 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位), // 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? []) const samples = computed(() => problem.value?.samples ?? [])
const submissionStore = useSubmissionStore() const submissionStore = useSubmissionStore()
const SAMPLE_SIDES = ['input', 'output'] as const /** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 utils/statement) */
const SAMPLE_SIDE_LABEL = { input: '输入', output: '输出' } as const const statement = computed(() => ({
description: tidyStatement(problem.value?.description),
/* input: tidyStatement(problem.value?.inputDescription),
* 例子默认摆成「例子 N | 输入 | 输出」的表,可机房那两种屏上左栏只有 550px 左右,每格只放得下 output: tidyStatement(problem.value?.outputDescription),
* 二十来个字符,而例子输出最长那行的 p90 就有 28 个字符宽(中文按两个算)—— 一成的题会在 hint: tidyStatement(problem.value?.hint),
* 半格里横着滚。所以按这道题最长那行量一下:半格放得下就摆表,放不下这道题的例子就整体改成 }))
* 上下排(输入一行、输出一行,各占满宽)。整宽还放不下的二十来道才横着滚。不自动折行:折出来
* 的行学生分不清是不是真换了行,照着它输出就错了。
* 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数。
*/
const SAMPLE_FONT = '14px Consolas, Monaco, monospace'
const SAMPLE_NO_W = 56
const SAMPLE_GAP = 8
const SAMPLE_PAD_L = 10
const SAMPLE_PAD_R = 4
const SAMPLE_COPY_W = 44
const sampleCss = {
font: SAMPLE_FONT,
noW: `${SAMPLE_NO_W}px`,
gap: `${SAMPLE_GAP}px`,
pad: `2px ${SAMPLE_PAD_R}px 2px ${SAMPLE_PAD_L}px`,
copyW: `${SAMPLE_COPY_W}px`,
}
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const sampleTextWidth = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = SAMPLE_FONT
let widest = 0
for (const sample of samples.value) {
for (const side of SAMPLE_SIDES) {
for (const line of sample[side].split('\n')) {
// <pre> 里 tab 按 8 列排
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > widest) widest = width
}
}
}
return widest
})
const sampleTable = useTemplateRef<HTMLElement>('sampleTable')
const { width: sampleTableWidth } = useElementSize(sampleTable)
/**
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
*/
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
/** 摆成表时,半格里留给文字的宽度 */
const sampleHalfWidth = computed(
() => (sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) / 2 - SAMPLE_PAD_L - SAMPLE_PAD_R - SAMPLE_COPY_W,
)
const samplesStacked = computed(() => sampleTableWidth.value > 0 && sampleTextWidth.value > sampleHalfWidth.value)
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS + // 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约 // ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
// astRequirementSchema。规则原文(engine / target)不下发给学生。 // astRequirementSchema。规则原文(engine / target)不下发给学生。
const KIND_TAG_TYPE = { // 每条规则前面一个小图标说是哪一类,规则的字照后端给的原样写(里面已经有「必须 / 不能」)
require: 'success', const KIND_ICON = {
forbid: 'error', require: 'ph:check-circle-fill',
count: 'info', forbid: 'ph:prohibit-fill',
count: 'ph:hash-bold',
} as const } as const
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {})) const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
</script> </script>
<template> <template>
<div v-if="problem" class="problem-content"> <div v-if="problem" class="problem-content">
<!-- 题号、题名、难度和状态一行(设计稿「题目页观感」定稿)。题名太长折到下一行,标签留在右边 -->
<header class="problem-head"> <header class="problem-head">
<div class="title-row">
<span class="pid">{{ problem._id }}</span> <span class="pid">{{ problem._id }}</span>
<h1 class="problem-title">{{ problem.title }}</h1> <h1 class="problem-title">{{ problem.title }}</h1>
</div>
<div class="meta"> <div class="meta">
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)"> <n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }} {{ DIFFICULTY[problem.difficulty] }}
</n-tag> </n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag> <n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" class="solved"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」 <!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码 --> 「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
<template v-else-if="!flowchartGrade"> 「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag> 「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag> <template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="error" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
</template> </template>
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag> <n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div> </div>
</header> </header>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 --> <!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
<div v-if="astRequirements.length > 0" class="requirements"> 黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
<span class="requirements-head">语法要求</span> <section v-if="astRequirements.length > 0" class="block">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<template v-for="[lang, rules] in astRequirements" :key="lang"> <template v-for="[lang, rules] in astRequirements" :key="lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span> <div v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</div>
<n-tag v-for="(rule, i) in rules" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false"> <ul class="rules">
{{ rule.description }} <li v-for="(rule, i) in rules" :key="`${lang}-${i}`" :class="rule.kind">
</n-tag> <Icon :icon="KIND_ICON[rule.kind]" :width="16" class="rule-icon" aria-hidden="true" />
<span>{{ rule.description }}</span>
</li>
</ul>
</template> </template>
<span class="requirements-note">提交时会逐条检查</span>
</div>
<section class="block">
<h3 class="title">描述</h3>
<Markdown html :content="problem.description" />
</section> </section>
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 -->
<div v-if="!isSQL" class="io">
<section class="block"> <section class="block">
<h3 class="title">输入</h3> <SectionHead icon="description">描述</SectionHead>
<Markdown html :content="problem.inputDescription" /> <Markdown html :content="statement.description" />
</section>
<!-- 输入、输出上下排:并排的时候多一两句就挤成窄条(2020 的输入折成三行),旁边一栏空着 -->
<template v-if="!isSQL">
<section class="block">
<SectionHead icon="input">输入</SectionHead>
<Markdown html :content="statement.input" />
</section> </section>
<section class="block"> <section class="block">
<h3 class="title">输出</h3> <SectionHead icon="output">输出</SectionHead>
<Markdown html :content="problem.outputDescription" /> <Markdown html :content="statement.output" />
</section> </section>
</div> </template>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 --> <!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block"> <section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
<div class="block-head"> <SectionHead icon="flowchart">
<h3 class="title">你画的流程图</h3> 你画的流程图
<n-button text type="primary" size="small" @click="myFlowchartZoom = true"> 放大看 </n-button> <template #extra>
<n-button text type="primary" size="small" @click="myFlowchartOpen = !myFlowchartOpen"> <n-button text type="primary" @click="myFlowchartZoom = true">放大看 ›</n-button>
<n-button text type="primary" @click="myFlowchartOpen = !myFlowchartOpen">
{{ myFlowchartOpen ? '收起' : '展开' }} {{ myFlowchartOpen ? '收起' : '展开' }}
</n-button> </n-button>
</div> </template>
</SectionHead>
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true"> <div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
<MyFlowchart compact /> <MyFlowchart compact />
</div> </div>
@@ -247,49 +195,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal> </n-modal>
</section> </section>
<template v-if="!isSQL"> <section v-if="!isSQL && samples.length" class="block">
<!-- <SectionHead icon="samples">
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子 例子
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」 <template #extra>
--> <n-button text type="primary" @click="submissionStore.revealResult('custom')">自己输入数据试试 ›</n-button>
<section v-if="samples.length" class="block">
<div class="block-head">
<h3 class="title">例子</h3>
<n-button text type="primary" size="small" class="block-link" @click="submissionStore.revealResult('custom')"> 自己输入数据试试 › </n-button>
</div>
<!-- 半格放不下最长那行就整体改成上下排,见 samplesStacked -->
<div ref="sampleTable" class="sample-table" :class="{ stacked: samplesStacked, narrow: sampleNarrow }" role="table" aria-label="例子">
<div v-if="!samplesStacked" class="sample-row" role="row">
<span v-if="!sampleNarrow" role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="sample-row" role="row">
<span class="sample-no" role="rowheader">例子 {{ index + 1 }}</span>
<template v-for="side in SAMPLE_SIDES" :key="side">
<span v-if="samplesStacked" class="sample-label sample-side">
{{ SAMPLE_SIDE_LABEL[side] }}
</span>
<div class="sample-cell" role="cell">
<pre class="testcase">{{ sample[side] }}</pre>
<button
type="button"
class="sample-copy"
:aria-label="`复制例子 ${index + 1} 的${SAMPLE_SIDE_LABEL[side]}`"
@click="copySample(`${index}-${side}`, sample[side])"
>
{{ copiedKey === `${index}-${side}` ? '已复制' : '复制' }}
</button>
</div>
</template> </template>
</div> </SectionHead>
</div> <SampleList :samples="samples" />
</section> </section>
</template>
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive"> <SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
<template #title="{ text }"> <template #title="{ text }">
<h3 class="title sql-title">{{ text }}</h3> <SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
</template> </template>
</SqlProblemData> </SqlProblemData>
@@ -298,10 +216,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏 折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
--> -->
<section v-if="canShowReferenceFlowchart" class="block"> <section v-if="canShowReferenceFlowchart" class="block">
<div class="block-head"> <SectionHead icon="flowchart">
<h3 class="title">参考流程图</h3> 参考流程图
<n-button text type="primary" size="small" class="block-link" @click="referenceZoom = true"> 放大看 </n-button> <template #extra>
</div> <n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
</template>
</SectionHead>
<div <div
class="reference-flowchart" class="reference-flowchart"
role="button" role="button"
@@ -319,268 +239,158 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal> </n-modal>
</section> </section>
<section v-if="problem.hint" class="block"> <section v-if="statement.hint" class="block">
<h3 class="title">提示</h3> <SectionHead icon="hint" tone="amber">提示</SectionHead>
<Markdown html :content="problem.hint" /> <Markdown html :content="statement.hint" />
</section> </section>
<section v-if="problem.source" class="block"> <SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
<h3 class="title">来源</h3>
<Markdown html :content="problem.source" />
</section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" /> <!-- 来源不单起一节:学生做题用不上它,缩成最底下一行灰字 -->
<footer v-if="problem.source" class="source">
<span class="source-label">来源</span>
<Markdown html :content="problem.source" class="source-text" />
</footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* 设计稿「题目页签:还没运行过」 */
.problem-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.pid {
flex: none;
padding: 2px 8px;
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.12);
font-size: 13px;
color: v-bind('theme.textColor2');
}
.problem-title {
margin: 0;
font-size: 22px;
line-height: 1.3;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
}
.block {
margin-top: 18px;
}
.block-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.block-head .title {
margin: 0;
}
.problem-content { .problem-content {
container: problem-content / inline-size; container: problem-content / inline-size;
} }
.block-link { /* 题号 | 题名 | ……… | 难度 状态 */
margin-left: auto; .problem-head {
}
/*
* 小节标题(描述 / 输入 / 输出 / 例子 / 提示……)。画板上是 15px 半粗,比 14px 的正文只大一号,
* 一屏题面扫下来分不出哪是标题(用户 2026-09-29 提出):加粗、大一号,左边一道主色竖条
*/
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
margin: 0 0 6px;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
}
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
@container problem-content (max-width: 480px) {
.io {
grid-template-columns: minmax(0, 1fr);
}
}
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex; display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px 8px; align-items: baseline;
font-size: 13px; gap: 4px 10px;
} }
.requirements-head { .pid {
font-weight: 600; flex: none;
} font-size: 16px;
font-variant-numeric: tabular-nums;
.requirements-note {
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.lang-label { .problem-title {
margin: 0;
min-width: 0;
font-size: 22px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
align-self: center;
}
/* 「已解决」实心绿:做完了要一眼看得出 */
.meta .solved {
background-color: v-bind('theme.successColor');
color: #fff;
font-weight: 600; font-weight: 600;
} }
/* 参考流程图:和例子的格子同一种浅灰底 */ .block {
margin-top: 20px;
}
/* 题面里老师自己写的标题(2020 开头的「鸣潮公式」是 h1):降成和正文一样大的粗体,
不然比题名还大,顶在「描述」下面像另起了一道题 */
.problem-content :deep(.oj-markdown :is(h1, h2, h3, h4, h5, h6)) {
margin: 0.8em 0 0.3em;
font-size: 16px;
font-weight: 700;
line-height: 1.5;
}
/* 语法要求:每条一行 */
.lang-label {
margin: 6px 0 2px;
font-size: 14px;
font-weight: 600;
}
.rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.rules li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.rule-icon {
flex: none;
margin-top: 0.3em;
}
.rules .require .rule-icon {
color: v-bind('theme.successColor');
}
.rules .forbid .rule-icon {
color: v-bind('theme.errorColor');
}
.rules .count .rule-icon {
color: v-bind('theme.infoColor');
}
/* SQL 的「数据表 / 期望结果」和别的小节一样隔开 */
.sql-head {
margin-top: 20px;
}
/* 参考流程图:和例子的输入格同一种浅灰底 */
.reference-flowchart { .reference-flowchart {
cursor: zoom-in; cursor: zoom-in;
border-radius: 6px; border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05); background-color: rgba(128, 128, 128, 0.05);
padding: 16px 12px; padding: 16px 12px;
} }
.my-flowchart { .my-flowchart {
cursor: zoom-in; cursor: zoom-in;
border-radius: 6px; border-radius: 8px;
background-color: rgba(128, 128, 128, 0.06); border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 8px; padding: 8px;
} }
/* .source {
* 例子:例子 N | 输入 | 输出,每格右边一个「复制」。 margin-top: 24px;
* 放不下时(.stacked)每个例子两行:例子 N | 输入 | 那格,下一行 输出 | 那格 padding-top: 10px;
*/ border-top: 1px solid v-bind('theme.dividerColor');
.sample-table {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 6px; gap: 8px;
} font-size: 13px;
.sample-table.stacked {
gap: 12px;
}
.sample-row {
display: grid;
grid-template-columns: v-bind('sampleCss.noW') minmax(0, 1fr) minmax(0, 1fr);
gap: 6px v-bind('sampleCss.gap');
align-items: stretch;
}
.stacked .sample-row {
grid-template-columns: v-bind('sampleCss.noW') 32px minmax(0, 1fr);
}
.sample-label,
.sample-no {
font-size: 12px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.sample-no { .source-label {
display: flex;
align-items: center;
}
/* 上下排时「例子 N」竖跨输入、输出两行,和「输入」对齐在顶上 */
.stacked .sample-no {
grid-row: span 2;
align-items: flex-start;
}
.stacked .sample-no,
.sample-side {
padding-top: 8px;
line-height: 1.5;
}
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
.narrow .sample-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.narrow.stacked .sample-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.narrow .sample-no,
.narrow.stacked .sample-no {
grid-column: 1 / -1;
grid-row: auto;
padding-top: 4px;
}
.sample-table.narrow {
gap: 10px;
}
.sample-cell {
min-width: 0;
display: flex;
align-items: flex-start;
padding: v-bind('sampleCss.pad');
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
}
.testcase {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 0;
font: v-bind('sampleCss.font');
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
/* 整宽也放不下、只好横着滚的那二十来道:Windows 上默认那根带箭头的粗滚动条比例子本身还显眼 */
.testcase::-webkit-scrollbar {
height: 6px;
}
.testcase::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.35);
}
.testcase::-webkit-scrollbar-track {
background: transparent;
}
.sample-copy {
flex: none; flex: none;
width: v-bind('sampleCss.copyW');
height: 24px;
margin-top: 2px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind('theme.primaryColorPressed');
cursor: pointer;
} }
.sample-copy:hover { .source-text {
background-color: rgba(24, 160, 88, 0.1); min-width: 0;
} font-size: 13px;
line-height: 1.6;
.sql-title {
margin-top: 14px;
} }
</style> </style>
@@ -182,16 +182,25 @@ watch(
* 没有画这一步的课,画着流程图时不动他 * 没有画这一步的课,画着流程图时不动他
*/ */
const lessonStore = useLessonStore() const lessonStore = useLessonStore()
let lessonLanguageFor: string | null = null /**
* 换过的(题 + 这一节 + 该做的那一步)都记下:换到别的题再回来,同一步不再换第二次。
* 编辑器在换题时不重新挂载(detail.vue 只重新 init),只记最后一个的话一回来又被拽回去
*/
const lessonSwitched = new Set<string>()
let lessonViewing: string | null = null
watch( watch(
() => [problem.value?._id, lessonStore.activity] as const, () => [problem.value?._id, lessonStore.activity] as const,
([id, activity]) => { ([id, activity]) => {
if (!id || !activity) return if (!id || !activity) return
const key = [id, activity.day, activity.mode, activity.language].join('|') const lesson = [id, activity.day, activity.mode, activity.language].join('|')
if (lessonLanguageFor === key) return
lessonLanguageFor = key
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
const item = lessonStore.find(id) const item = lessonStore.find(id)
const key = [lesson, item?.draw && item.draw !== 'accepted' ? 'draw' : 'code'].join('|')
// 停在这道题上、只是课堂状态刷新了(比如刚画对图):不跟着换,也不记,下回打开这道题再换
const stay = lessonViewing === lesson
lessonViewing = lesson
if (lessonSwitched.has(key) || stay) return
lessonSwitched.add(key)
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
if (!item) return if (!item) return
if (item.draw && item.draw !== 'accepted') { if (item.draw && item.draw !== 'accepted') {
if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart') if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart')
@@ -101,8 +101,8 @@ const color = computed(() => {
.result-header { .result-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
margin-bottom: 12px; margin-bottom: 18px;
} }
.line { .line {
@@ -114,8 +114,8 @@ const color = computed(() => {
.badge { .badge {
flex: none; flex: none;
width: 24px; width: 26px;
height: 24px; height: 26px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -125,12 +125,14 @@ const color = computed(() => {
} }
.title { .title {
font-size: 18px; font-size: 22px;
line-height: 1.3;
font-weight: 700; font-weight: 700;
color: v-bind('color.text'); color: v-bind('color.text');
} }
.sub { .sub {
font-size: 15px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
@@ -74,8 +74,9 @@ function pick(segment: Segment) {
<template> <template>
<FlowchartResult v-if="drawing" /> <FlowchartResult v-if="drawing" />
<div v-show="!drawing" class="result-pane"> <div v-show="!drawing" class="result-pane">
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 --> <!-- 三个分段,带上那次结果的时间 -->
<div v-if="showSegments" class="segments" role="tablist" aria-label="结果"> <div v-if="showSegments" class="segments">
<div class="segment-track" role="tablist" aria-label="结果">
<button <button
v-for="segment in segments" v-for="segment in segments"
:key="segment.value" :key="segment.value"
@@ -86,8 +87,9 @@ function pick(segment: Segment) {
:aria-selected="isActive(segment.value)" :aria-selected="isActive(segment.value)"
@click="pick(segment.value)" @click="pick(segment.value)"
> >
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template> {{ segment.label }}<span v-if="segment.time" class="segment-time">{{ segment.time }}</span>
</button> </button>
</div>
<span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span> <span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span>
</div> </div>
@@ -116,35 +118,53 @@ function pick(segment: Segment) {
padding-bottom: 16px; padding-bottom: 16px;
} }
/* 设计稿「题目页观感」结果 X:浅灰底的分段按钮,选中的白底加粗(和工具栏「写代码 / 画流程图」一个样子) */
.segments { .segments {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; align-items: center;
margin-bottom: 14px; gap: 8px 12px;
margin-bottom: 16px;
}
.segment-track {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 8px;
background-color: rgba(128, 128, 128, 0.12);
} }
.segment { .segment {
height: 28px; height: 28px;
padding: 0 12px; padding: 0 12px;
border: 0; border: 0;
border-radius: 15px; border-radius: 6px;
background-color: rgba(128, 128, 128, 0.12); background: transparent;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
font: inherit; font: inherit;
font-size: 13px; font-size: 14px;
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.segment-time {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: v-bind('theme.textColor3');
}
.segment-note { .segment-note {
margin-left: auto;
align-self: center;
font-size: 13px; font-size: 13px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
.segment.active { .segment.active {
background-color: v-bind('theme.textColor1'); background-color: v-bind('theme.cardColor');
color: v-bind('theme.cardColor'); color: v-bind('theme.textColor1');
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
} }
.formatted { .formatted {
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark' import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark'
import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError' import { explainRuntimeError, type RuntimeErrorInfo } from 'oj/problem/utils/runtimeError'
import SectionHead from './SectionHead.vue'
/** /**
* 运行时错误的中文说明卡片(诊断由后端 judge/runtime-diagnosis.ts 给)。 * 运行时错误的中文说明(诊断由后端 judge/runtime-diagnosis.ts 给)。
* 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。 * 知道是第几行的,就把那一行代码摆出来,并在编辑器里标红;卸掉时清掉标记。
*/ */
const props = defineProps<{ const props = defineProps<{
@@ -34,34 +35,50 @@ onUnmounted(clearErrorMark)
</script> </script>
<template> <template>
<n-card embedded class="explain-card"> <!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明,再把那一行代码摆出来 -->
<n-flex vertical :size="12"> <div class="explain-section">
<div class="explain"> <SectionHead icon="problem" tone="amber">{{ info.line ? `第 ${info.line} 行出错` : '程序跑到一半出错了' }}</SectionHead>
<b v-if="info.line">第 {{ info.line }} 行运行时出错:</b>{{ message }} <p class="explain">{{ message }}</p>
<div v-if="sourceLine" class="explain-code">
<span class="line-no">{{ info.line }}</span>
<pre>{{ sourceLine }}</pre>
</div>
</div> </div>
<pre v-if="sourceLine" class="explain-code">{{ sourceLine }}</pre>
</n-flex>
</n-card>
</template> </template>
<style scoped> <style scoped>
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
.explain-card {
max-width: 560px;
}
.explain { .explain {
margin: 0 0 10px;
font-size: 16px; font-size: 16px;
line-height: 1.7; line-height: 1.75;
} }
/* 出错的那一行:红底,前面写行号,和编辑器里标红的那一行对得上 */
.explain-code { .explain-code {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 5px 10px;
border-radius: 6px;
background-color: rgba(208, 48, 80, 0.06);
border: 1px solid rgba(208, 48, 80, 0.24);
font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
}
.line-no {
flex: none;
font-weight: 700;
color: #d03050;
}
.explain-code pre {
flex: 1 1 auto;
min-width: 0;
margin: 0; margin: 0;
padding: 8px 12px; font: inherit;
border-radius: 4px;
font-size: 15px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
background-color: rgba(208, 48, 80, 0.12);
} }
</style> </style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { copyToClipboard } from 'utils/functions'
import { SubmissionStatus } from 'utils/constants'
import { useCodeStore } from 'oj/store/code'
import { useSubmissionStore } from 'oj/store/submission'
/**
* 题面的例子:每组一行「输入 → 输出」,输出浅绿底(设计稿「题目页观感」ExCard,2026-11-04 定)。
*
* - 输入写的是「无」或者空的(常用题里 12% 的例子)写灰字「不用输入」,不给复制:
* 原来摆成一格可复制的「无」,学生以为要输入一个「无」。
* - 行首的空格画成浅灰点:菱形、三角形那几道靠行首空格对齐,数不清空格就照着排错了。
* 只是画成点,复制出去的还是空格。
* - 半格放不下最长那行,这道题的例子整体改成上下排(输入、↓、输出)。整宽还放不下的那一行
* 才折行,**折下来的部分缩进两格**:原来不折、横着滚,就是怕学生分不清折行和真换行、
* 照着多输出一个换行;缩进之后两者一眼能分开,又不用去找那根细滚动条。
* - 「运行例子」跑过之后,序号上标 ✓ / ✗(还是会跳到「结果」页签,切回来就看得到)。
* 代码改过就不再标:标的是上一份代码的对错,留着会让学生以为改完的还对。
*/
const { samples } = defineProps<{
samples: { input: string; output: string }[]
}>()
const SIDES = ['input', 'output'] as const
const SIDE_LABEL = { input: '输入', output: '输出' } as const
/** 输入写「无」或者空着:这道题不用输入 */
function noInput(text: string) {
const trimmed = text.trim()
return trimmed === '' || trimmed === '无'
}
/** 每一行拆成「行首空格数 + 余下的字」,模板里把行首空格画成点 */
function linesOf(text: string) {
return text.split('\n').map((line) => {
const lead = /^ +/.exec(line)?.[0].length ?? 0
return { lead, rest: line.slice(lead) }
})
}
// ==================== 摆成一行还是上下排 ====================
// 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数
const FONT_SIZE = 16
const FONT = `${FONT_SIZE}px Consolas, Monaco, monospace`
const DOT_W = 26
const ARROW_W = 22
const GAP = 6
/** 格子里除了字以外占掉的:左右内边距、复制按钮、按钮前的间距、边框 */
const PANE_CHROME = 10 + 4 + 26 + 4 + 2
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const widest = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = FONT
let max = 0
for (const sample of samples) {
for (const side of SIDES) {
if (side === 'input' && noInput(sample.input)) continue
for (const line of sample[side].split('\n')) {
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > max) max = width
}
}
}
return max
})
const list = useTemplateRef<HTMLElement>('list')
const { width: listWidth } = useElementSize(list)
const halfText = computed(() => (listWidth.value - DOT_W - ARROW_W - GAP * 3) / 2 - PANE_CHROME)
const stacked = computed(() => listWidth.value > 0 && widest.value > halfText.value)
// ==================== 复制 ====================
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, { immediate: false })
async function copy(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// ==================== 运行例子的 ✓ / ✗ ====================
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const marks = computed<Record<number, 'ok' | 'bad'>>(() => {
const trial = submissionStore.trial
const ran = trial.samplesCode.value
if (trial.samplesRunning.value || !ran) return {}
if (ran.value !== codeStore.code.value || ran.language !== codeStore.code.language) return {}
const out: Record<number, 'ok' | 'bad'> = {}
for (const run of trial.sampleRuns.value) {
out[run.index] = run.result === SubmissionStatus.accepted ? 'ok' : 'bad'
}
return out
})
function markLabel(index: number) {
const mark = marks.value[index]
if (mark === 'ok') return `例子 ${index + 1},运行结果对了`
if (mark === 'bad') return `例子 ${index + 1},运行结果不对`
return `例子 ${index + 1}`
}
const css = {
font: FONT,
dotW: `${DOT_W}px`,
arrowW: `${ARROW_W}px`,
gap: `${GAP}px`,
}
</script>
<template>
<div ref="list" class="sample-list" :class="{ stacked }" role="list" aria-label="例子">
<div v-if="!stacked" class="cols" aria-hidden="true">
<span />
<span>输入</span>
<span />
<span>输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="pair" role="listitem">
<span class="dot" :class="marks[index]" :aria-label="markLabel(index)">
<Icon v-if="marks[index] === 'ok'" icon="ph:check-bold" :width="13" />
<Icon v-else-if="marks[index] === 'bad'" icon="ph:x-bold" :width="12" />
<template v-else>{{ index + 1 }}</template>
</span>
<div class="sides">
<template v-for="side in SIDES" :key="side">
<span v-if="side === 'output'" class="arrow" aria-hidden="true">
<Icon :icon="stacked ? 'ph:arrow-down' : 'ph:arrow-right'" :width="16" />
</span>
<div v-if="side === 'input' && noInput(sample.input)" class="pane in none">不用输入</div>
<div v-else class="pane" :class="side === 'input' ? 'in' : 'out'">
<pre class="text" :aria-label="`例子 ${index + 1} 的${SIDE_LABEL[side]}`"><span
v-for="(line, i) of linesOf(sample[side])"
:key="i"
class="line"
><span v-if="line.lead" class="lead">{{ '·'.repeat(line.lead) }}</span>{{ line.rest }}</span
></pre>
<button type="button" class="copy" :aria-label="`复制例子 ${index + 1} 的${SIDE_LABEL[side]}`" @click="copy(`${index}-${side}`, sample[side])">
<Icon :icon="copiedKey === `${index}-${side}` ? 'ph:check' : 'ph:copy'" :width="15" />
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.sample-list {
display: flex;
flex-direction: column;
gap: 8px;
--pane-in-bg: rgba(128, 128, 128, 0.07);
--pane-in-border: rgba(128, 128, 128, 0.14);
--pane-out-bg: rgba(24, 160, 88, 0.08);
--pane-out-border: rgba(24, 160, 88, 0.26);
--lead: rgba(24, 160, 88, 0.45);
--muted: rgba(128, 128, 128, 0.75);
}
:global(html.dark) .sample-list {
--pane-out-bg: rgba(99, 226, 183, 0.08);
--pane-out-border: rgba(99, 226, 183, 0.26);
--lead: rgba(99, 226, 183, 0.45);
}
.sample-list.stacked {
gap: 12px;
}
.cols,
.pair {
display: grid;
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
}
.cols {
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
margin-bottom: -4px;
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
/* 一行:输入 → 输出;上下排:输入、↓、输出 */
.sides {
display: grid;
grid-template-columns: minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
align-items: stretch;
min-width: 0;
}
.stacked .sides {
grid-template-columns: minmax(0, 1fr);
}
.dot {
width: 22px;
height: 22px;
margin-top: 7px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
background-color: rgba(128, 128, 128, 0.14);
color: var(--muted);
}
.dot.ok {
background-color: #18a058;
color: #fff;
}
.dot.bad {
background-color: #d03050;
color: #fff;
}
.arrow {
display: flex;
justify-content: center;
padding-top: 10px;
color: var(--muted);
}
.stacked .arrow {
justify-content: flex-start;
padding: 2px 0 2px 14px;
}
.pane {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 5px 4px 5px 10px;
border-radius: 6px;
}
.pane.in {
background-color: var(--pane-in-bg);
border: 1px solid var(--pane-in-border);
}
.pane.out {
background-color: var(--pane-out-bg);
border: 1px solid var(--pane-out-border);
}
.pane.none {
align-self: start;
background-color: transparent;
border-style: dashed;
padding-right: 10px;
font-size: 14px;
line-height: 24px;
color: var(--muted);
}
.text {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font: v-bind('css.font');
line-height: 24px;
}
/* 一行一块:整宽还放不下才折,折下来的缩进两格,和真换行分得开 */
.line {
display: block;
min-height: 24px;
padding-left: 2ch;
text-indent: -2ch;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.lead {
color: var(--lead);
}
.copy {
flex: none;
width: 26px;
height: 24px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.copy:hover {
background-color: rgba(128, 128, 128, 0.14);
}
</style>
@@ -103,22 +103,14 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
</ResultHeader> </ResultHeader>
<template v-if="current"> <template v-if="current">
<!-- 对上了:三栏摆出来就行 --> <!-- 对上了:输入、正确输出、你的输出竖着摆出来就行,你的输出也是绿的 -->
<div v-if="current.result === SubmissionStatus.accepted" class="passed-card"> <div v-if="current.result === SubmissionStatus.accepted" class="passed-card">
<div class="blocks"> <span class="label">输入</span>
<div class="block"> <pre class="pane in">{{ current.input }}</pre>
<div class="label">输入</div> <span class="label">正确输出</span>
<pre>{{ current.input }}</pre> <pre class="pane ok">{{ current.expected }}</pre>
</div> <span class="label">你的输出</span>
<div class="block"> <pre class="pane ok">{{ current.output }}</pre>
<div class="label">正确输出</div>
<pre>{{ current.expected }}</pre>
</div>
<div class="block">
<div class="label">你的输出</div>
<pre>{{ current.output }}</pre>
</div>
</div>
</div> </div>
<!-- 跑完了、输出对不上:和判完之后同一套说明 --> <!-- 跑完了、输出对不上:和判完之后同一套说明 -->
<WrongAnswerExplain <WrongAnswerExplain
@@ -134,6 +126,7 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
:code="samplesCode?.value ?? ''" :code="samplesCode?.value ?? ''"
:language="samplesCode?.language ?? ''" :language="samplesCode?.language ?? ''"
:plain="inContest" :plain="inContest"
section="stack"
/> />
<!-- 没跑完:编译不过、运行出错、超时 --> <!-- 没跑完:编译不过、运行出错、超时 -->
<TrialErrorNote <TrialErrorNote
@@ -165,15 +158,16 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
} }
.run { .run {
height: 26px; height: 28px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 10px; padding: 0 10px;
border-radius: 13px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@@ -189,56 +183,58 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
color: v-bind('theme.errorColorPressed'); color: v-bind('theme.errorColorPressed');
} }
.run.active { /* 选中的那个涂实心:设计稿「运行例子」 */
font-weight: 600;
border-width: 1.5px;
}
.run.ok.active { .run.ok.active {
border-color: v-bind('theme.successColor'); border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: #fff;
} }
.run.bad.active { .run.bad.active {
border-color: v-bind('theme.errorColor'); border-color: v-bind('theme.errorColor');
background-color: v-bind('theme.errorColor');
color: #fff;
} }
/* 对上了:和答错时同一张两列的网格(标签 | 格子),只是你的输出也是绿的 */
.passed-card { .passed-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
}
.blocks {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: 64px minmax(0, 1fr);
gap: 8px; gap: 6px 8px;
} align-items: start;
.block {
min-width: 0;
padding: 6px 10px 8px;
border-radius: 5px;
background-color: v-bind('theme.cardColor');
border: 1px solid v-bind('theme.dividerColor');
} }
.label { .label {
font-size: 12px; font-size: 13px;
opacity: 0.7; line-height: 36px;
margin-bottom: 4px; white-space: nowrap;
color: v-bind('theme.textColor3');
} }
.block pre { .pane {
min-width: 0;
margin: 0; margin: 0;
padding: 5px 10px;
border-radius: 6px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: Monaco, Consolas, monospace; font-family: Consolas, Monaco, monospace;
font-size: 14px; font-size: 16px;
line-height: 24px;
}
.pane.in {
background-color: rgba(128, 128, 128, 0.07);
border: 1px solid rgba(128, 128, 128, 0.14);
}
.pane.ok {
background-color: rgba(24, 160, 88, 0.08);
border: 1px solid rgba(24, 160, 88, 0.26);
} }
.note { .note {
margin: 12px 0 0; margin: 16px 0 0;
font-size: 13px; font-size: 13px;
color: v-bind('theme.textColor3'); color: v-bind('theme.textColor3');
} }
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
/**
* 题面的小节标题:线条图标 + 绿字 + 一根细线拖到头,线尾可以挂一个链接
* (「自己输入数据试试 ›」「放大看 ›」)。设计稿「题目页观感」⑦,2026-11-04 定。
*
* 语法要求、提示用黄色:一个是硬性条件、一个是帮忙的话,扫一眼要能和别的节分开。
* 结果页签里「例子 1 没对上」「第 2 行出错」这类说哪里不对的也用黄色(设计稿结果 X)。
* 明暗两套色都在 CSS 里(html.dark),别在这里调 useDark() —— 理由见 ProblemTypeTag。
*/
export type SectionIcon =
| 'description'
| 'input'
| 'output'
| 'samples'
| 'hint'
| 'rules'
| 'table'
| 'expected'
| 'flowchart'
| 'similar'
| 'problem'
| 'next'
| 'challenge'
const ICON: Record<SectionIcon, string> = {
description: 'ph:book-open',
input: 'ph:sign-in',
output: 'ph:sign-out',
samples: 'ph:flask',
hint: 'ph:lightbulb',
rules: 'ph:code',
table: 'ph:table',
expected: 'ph:check-circle',
flowchart: 'ph:flow-arrow',
similar: 'ph:shuffle-angular',
// 结果页签:哪里不对、接着做、还能挑战
problem: 'ph:warning-circle',
next: 'ph:arrow-right',
challenge: 'ph:target',
}
const { icon, tone = 'green' } = defineProps<{
icon: SectionIcon
tone?: 'green' | 'amber'
}>()
</script>
<template>
<div class="section-head" :class="tone">
<h3 class="section-title">
<Icon :icon="ICON[icon]" :width="19" class="section-icon" aria-hidden="true" />
<slot />
</h3>
<span class="section-line" aria-hidden="true" />
<span v-if="$slots.extra" class="section-extra"><slot name="extra" /></span>
</div>
</template>
<style scoped>
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
--head-text: #0c6b3b;
--head-icon: #18a058;
--head-line: rgba(24, 160, 88, 0.22);
}
.section-head.amber {
--head-text: #8a5a00;
--head-icon: #c98a00;
--head-line: rgba(201, 138, 0, 0.25);
}
:global(html.dark) .section-head {
--head-text: #63e2b7;
--head-icon: #63e2b7;
--head-line: rgba(99, 226, 183, 0.22);
}
:global(html.dark) .section-head.amber {
--head-text: #f2c97d;
--head-icon: #f2c97d;
--head-line: rgba(242, 201, 125, 0.25);
}
.section-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 2px;
white-space: nowrap;
color: var(--head-text);
}
.section-icon {
flex: none;
color: var(--head-icon);
}
.section-line {
flex: 1 1 auto;
min-width: 12px;
height: 1px;
background-color: var(--head-line);
}
.section-extra {
flex: none;
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 14px;
white-space: nowrap;
}
</style>
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { ProblemRow } from 'utils/types' import type { ProblemRow } from 'utils/types'
type SimilarRow = ProblemRow & { sharedTags: string[] }
/** /**
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了 * 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
* 每个实例各一份 * 每个实例各一份
*/ */
const cache = new Map<string, Promise<ProblemRow[]>>() const cache = new Map<string, Promise<SimilarRow[]>>()
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
@@ -13,17 +15,28 @@ import { storeToRefs } from 'pinia'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { getSimilarProblems } from 'oj/api' import { getSimilarProblems } from 'oj/api'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { useTone } from 'shared/composables/tone'
import { problemTypes } from '../utils/problemType'
import ProblemStatus from './ProblemStatus.vue'
import ProblemTypeTag from './ProblemTypeTag.vue'
import SectionHead from './SectionHead.vue'
/** /**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后) * 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。 * 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里用题面的小节标题(SectionHead),结果页签里和「还能挑战」对齐。
*
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)。整行可点,题目名
* 和题目列表一样用正文色、不用主题绿:绿字细、在白底上发虚
*/ */
const props = defineProps<{ title: string }>() const props = defineProps<{ title: string; place: 'content' | 'result' }>()
const theme = useThemeVars() const theme = useThemeVars()
const tone = useTone()
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
const list = ref<ProblemRow[]>([]) const list = ref<SimilarRow[]>([])
watch( watch(
() => problem.value?._id, () => problem.value?._id,
@@ -41,30 +54,37 @@ watch(
{ immediate: true }, { immediate: true },
) )
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High // 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
function difficultyType(difficulty: string) { const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
if (difficulty === '简单') return 'success'
if (difficulty === '困难') return 'error' const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
return 'warning'
function difficultyStyle(row: SimilarRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
} }
</script> </script>
<template> <template>
<section v-if="list.length" class="similar"> <section v-if="list.length" class="similar" :class="props.place">
<h3 class="title">{{ props.title }}</h3> <SectionHead v-if="props.place === 'content'" icon="similar">{{ props.title }}</SectionHead>
<n-list bordered> <h3 v-else class="heading">{{ props.title }}</h3>
<n-list-item v-for="sp in list" :key="sp._id"> <div class="rows">
<n-flex align="center" justify="space-between" :wrap="false"> <router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link"> <span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag> <span class="id">{{ sp._id }}</span>
<span class="name">{{ sp.title }}</span> <span class="name">
<span class="text">{{ sp.title }}</span>
<ProblemTypeTag v-for="kind in problemTypes(sp)" :key="kind" :kind="kind" />
</span>
<span v-if="sp.difficulty" class="difficulty" :style="difficultyStyle(sp)">{{ sp.difficulty }}</span>
<span class="shared">{{ sp.sharedTags.join(' · ') }}</span>
<span class="people"
><b>{{ sp.solvedUsers }}</b> 人做对</span
>
</router-link> </router-link>
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)"> </div>
{{ sp.difficulty }}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
</section> </section>
</template> </template>
@@ -73,34 +93,126 @@ function difficultyType(difficulty: string) {
margin-top: 20px; margin-top: 20px;
} }
.title { .similar.result {
font-size: 16px; margin-top: 14px;
font-weight: 600; }
.heading {
margin: 0 0 8px; margin: 0 0 8px;
} }
/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */ /* 和结果页签里的「还能挑战」一样 */
.similar :deep(.n-list-item__main) { .result .heading {
min-width: 0; font-size: 15px;
font-weight: 700;
} }
.link { .rows {
display: inline-flex; border: 1px solid v-bind('theme.dividerColor');
border-radius: 6px;
overflow: hidden;
}
/*
* 两行:题号 | 题目名 + 类型标签 | 难度
* | 共同的标签 | 做对人数
* 做过没对的题最前面多一列红「−」
*/
.row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
'id name difficulty'
'. shared people';
align-items: center; align-items: center;
gap: 8px; gap: 4px 12px;
min-width: 0; padding: 10px 16px;
color: inherit; color: v-bind('theme.textColor1');
text-decoration: none; text-decoration: none;
} }
.link:hover .name { .row.with-status {
grid-template-columns: 16px auto minmax(0, 1fr) auto;
grid-template-areas:
'status id name difficulty'
'. . shared people';
}
.row + .row {
border-top: 1px solid v-bind('theme.dividerColor');
}
.row:hover {
background: v-bind('theme.hoverColor');
}
.row:hover .text {
text-decoration: underline; text-decoration: underline;
} }
.status {
grid-area: status;
display: flex;
justify-content: center;
}
.id {
grid-area: id;
min-width: 36px;
font-size: var(--oj-fs-body);
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
}
.name { .name {
color: v-bind('theme.primaryColor'); grid-area: name;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.text {
min-width: 0;
font-size: var(--oj-fs-body);
font-weight: 500;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.difficulty {
grid-area: difficulty;
justify-self: end;
height: 22px;
padding: 0 7px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
display: inline-flex;
align-items: center;
}
.shared {
grid-area: shared;
min-width: 0;
font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor2');
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.people {
grid-area: people;
justify-self: end;
font-size: var(--oj-fs-sec);
color: v-bind('theme.textColor2');
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.people b {
font-weight: 600;
color: v-bind('theme.textColor1');
}
</style> </style>
@@ -33,8 +33,6 @@ const changedTables = computed(() => {
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */ /** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length) const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length)
const anyChanged = computed(() => changedTables.value.some(({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0)))
</script> </script>
<template> <template>
@@ -62,7 +60,6 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
</template> </template>
</div> </div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p> <p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend"><span class="swatch" aria-hidden="true"></span>执行后变了的格子</p>
</template> </template>
<style scoped> <style scoped>
@@ -77,22 +74,4 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
opacity: 0.65; opacity: 0.65;
margin: 0 0 8px; margin: 0 0 8px;
} }
.legend {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 12px;
height: 12px;
border: 1px solid #f0d48a;
background-color: #fff4d6;
}
html.dark .swatch {
border-color: rgba(240, 180, 40, 0.45);
background-color: rgba(240, 180, 40, 0.18);
}
</style> </style>
@@ -16,6 +16,7 @@ import RuntimeErrorExplain from './RuntimeErrorExplain.vue'
import WrongAnswerExplain from './WrongAnswerExplain.vue' import WrongAnswerExplain from './WrongAnswerExplain.vue'
import LessonNext from './LessonNext.vue' import LessonNext from './LessonNext.vue'
import ResultHeader from './ResultHeader.vue' import ResultHeader from './ResultHeader.vue'
import SectionHead from './SectionHead.vue'
import SimilarProblems from './SimilarProblems.vue' import SimilarProblems from './SimilarProblems.vue'
import ChallengeList from './ChallengeList.vue' import ChallengeList from './ChallengeList.vue'
import ChallengeOutcome from './ChallengeOutcome.vue' import ChallengeOutcome from './ChallengeOutcome.vue'
@@ -357,26 +358,34 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
:language="submission.language" :language="submission.language"
:refresh-key="submission.id" :refresh-key="submission.id"
/> />
<SimilarProblems v-if="showSimilar" title="再练几道相似的" /> <SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
</div> </div>
</template> </template>
<div v-show="!waitingReveal"> <div v-show="!waitingReveal">
<n-flex vertical v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"> <n-flex
vertical
:size="20"
v-if="compileError || runtimeError || sampleCheck || msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
>
<CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" /> <CompileErrorExplain v-if="compileError" :err-info="compileError" :language="submission.language" />
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" /> <RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
<WrongAnswerExplain v-if="sampleCheck" full :check="sampleCheck" :code="submission.code" :language="submission.language" /> <WrongAnswerExplain v-if="sampleCheck" full section="stack" :check="sampleCheck" :code="submission.code" :language="submission.language" />
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded> <!-- 语法要求逐条对没对:和题面的「语法要求」同一个黄色小节标题 -->
<n-flex vertical :size="8"> <section v-if="submission.statisticInfo?.ast_results?.length">
<n-flex v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i" align="center" :size="6"> <SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor"> <ul class="ast-rules">
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" /> <li v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i">
</n-icon> <Icon
:icon="rule.passed ? 'ph:check-circle-fill' : 'ph:x-circle-fill'"
:width="17"
:color="rule.passed ? theme.successColor : theme.errorColor"
/>
<span>{{ rule.description }}</span> <span>{{ rule.description }}</span>
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 --> <!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
<span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span> <span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span>
</n-flex> </li>
</n-flex> </ul>
</n-card> </section>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card> <n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" /> <n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
</n-flex> </n-flex>
@@ -386,7 +395,10 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
问过之后,AI 的回答才放进卡片 问过之后,AI 的回答才放进卡片
--> -->
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions"> <div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
<n-button v-if="showAIHint && !hintTarget" type="primary" ghost :loading="hintLoading" @click="fetchHint(submission.id)"> 让 AI 分析我的代码 </n-button> <n-button v-if="showAIHint && !hintTarget" type="primary" secondary :loading="hintLoading" @click="fetchHint(submission.id)">
<template #icon><Icon icon="ph:sparkle-fill" /></template>
让 AI 分析我的代码
</n-button>
<n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')"> <n-button v-if="customRunHint" text type="primary" @click="submissionStore.revealResult('custom')">
{{ customRunHint }} {{ customRunHint }}
</n-button> </n-button>
@@ -425,7 +437,24 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px 16px; gap: 8px 16px;
margin-top: 12px; margin-top: 16px;
}
.ast-rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.ast-rules li {
display: flex;
align-items: center;
gap: 8px;
} }
/* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */ /* 手机上「让 AI 分析我的代码」占满一行、44px 高,好按 */
@@ -6,6 +6,7 @@ import { useProblemStore } from 'oj/store/problem'
import { markdownToText } from 'oj/problem/utils/plainText' import { markdownToText } from 'oj/problem/utils/plainText'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants' import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer' import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer'
import SectionHead from './SectionHead.vue'
/** /**
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。 * 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
@@ -31,6 +32,15 @@ const props = defineProps<{
* 提示句全是对学生说的第二人称,整句换掉人称就读得通 * 提示句全是对学生说的第二人称,整句换掉人称就读得通
*/ */
peer?: boolean peer?: boolean
/**
* 新的样子(设计稿「题目页观感」结果 X):黄色小节标题说哪里不对,说明用正文大小,
* 输入 / 正确输出 / 你的输出是灰、绿、红三格。
* - stack:三格竖着排,和题面的例子一个样子。题目页的结果页签用(左栏只有 500 多宽)
* - row:三格横着并排,正确输出和你的输出一行对一行,不一样的那行两边都垫一层浅黄。
* 提交详情页用(一栏 1200 宽,多行输出对照着看);放不下(窄于 600,比如手机)就退回竖排
* 不给的话是原来那张浅灰卡片
*/
section?: 'stack' | 'row'
}>() }>()
const theme = useThemeVars() const theme = useThemeVars()
@@ -114,39 +124,48 @@ function lines(text: string) {
</script> </script>
<template> <template>
<div class="explain-card"> <div :class="section ? ['explain-section', section] : 'explain-card'">
<p v-if="check.passed" class="explain">题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p> <template v-if="section">
<template v-else> <SectionHead icon="problem" tone="amber">
<p class="explain"> {{ check.passed ? '例子都对了,错在没给你看的测试点' : `例子 ${(check.index ?? 0) + 1} 没对上` }}
</SectionHead>
<p v-if="check.passed" class="explain">想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p>
<p v-else-if="!plain" class="explain">{{ hint }}</p>
</template>
<p v-else-if="check.passed" class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</p>
<p v-else class="explain">
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b <b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? '。' : ':' }}</b
><template v-if="!plain">{{ hint }}</template> ><template v-if="!plain">{{ hint }}</template>
</p> </p>
<template v-if="!check.passed">
<div class="blocks"> <div class="blocks">
<div class="block"> <div class="block in">
<div class="label">输入</div> <div class="label">输入</div>
<div class="text"> <div class="text">
<div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" /> <div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" />
</div> </div>
</div> </div>
<div class="block"> <div class="block ok">
<div class="label">正确输出</div> <div class="label">正确输出</div>
<div class="text"> <div class="text">
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i"> <template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
<div v-if="i === diffLine && diffParts?.expected" class="line"> <div v-if="i === diffLine && diffParts?.expected" class="line hit">
<span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" /> <span v-text="diffParts.expected.before" /><span class="diff" v-text="diffParts.expected.marked" /><span v-text="diffParts.expected.after" />
</div> </div>
<div v-else class="line" v-text="line" /> <div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template> </template>
</div> </div>
</div> </div>
<div class="block"> <div class="block bad">
<div class="label">{{ peer ? '学生' : '你' }}的输出</div> <div class="label">{{ peer ? '学生' : '你' }}的输出</div>
<div v-if="check.output" class="text"> <div v-if="check.output" class="text">
<template v-for="(line, i) in lines(check.output)" :key="i"> <template v-for="(line, i) in lines(check.output)" :key="i">
<div v-if="i === diffLine && diffParts?.output" class="line"> <div v-if="i === diffLine && diffParts?.output" class="line hit">
<span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" /> <span v-text="diffParts.output.before" /><span class="diff" v-text="diffParts.output.marked" /><span v-text="diffParts.output.after" />
</div> </div>
<div v-else class="line" v-text="line" /> <div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template> </template>
</div> </div>
<div v-else class="text empty">(什么都没有输出)</div> <div v-else class="text empty">(什么都没有输出)</div>
@@ -241,4 +260,145 @@ function lines(text: string) {
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
line-height: 1.6; line-height: 1.6;
} }
/*
* 题目页结果页签(section):标签一列、格子一列,输入灰、正确输出绿、你的输出红。
* .block 用 display: contents 把「标签 + 格子」直接摊进这张两列的网格
*/
.explain-section {
display: flex;
flex-direction: column;
}
.explain-section .explain {
margin: 0 0 10px;
font-size: 16px;
line-height: 1.75;
}
.explain-section .blocks {
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
align-items: start;
}
.explain-section .block {
display: contents;
}
.explain-section .label {
margin: 0;
font-size: 13px;
line-height: 36px;
white-space: nowrap;
}
.explain-section .bad .label {
font-weight: 600;
color: v-bind('theme.errorColor');
}
.explain-section .text {
min-width: 0;
padding: 5px 10px;
border-radius: 6px;
font-size: 16px;
line-height: 24px;
max-height: 260px;
}
.explain-section .text.empty {
font-size: 14px;
}
.explain-section .in .text {
background-color: rgba(128, 128, 128, 0.07);
border: 1px solid rgba(128, 128, 128, 0.14);
}
.explain-section .ok .text {
background-color: rgba(24, 160, 88, 0.08);
border: 1px solid rgba(24, 160, 88, 0.26);
}
.explain-section .bad .text {
background-color: rgba(208, 48, 80, 0.06);
border: 1px solid rgba(208, 48, 80, 0.24);
}
.explain-section .line {
min-height: 24px;
}
.explain-section .cut {
grid-column: 2;
}
.explain-section .requirement {
margin-top: 10px;
font-size: 14px;
color: v-bind('theme.textColor3');
}
/*
* 横排(row,提交详情页):三格并排,标签在格子上面;行的左右留白挪到每一行上,
* 不一样的那行垫的浅黄才能铺满整格宽、两边对齐
*/
.explain-section.row {
container: explain-row / inline-size;
}
.explain-section.row .blocks {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.explain-section.row .block {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
}
.explain-section.row .label {
line-height: 1.5;
}
.explain-section.row .text {
padding: 6px 0;
line-height: 26px;
}
.explain-section.row .line {
min-height: 26px;
padding: 0 12px;
}
.explain-section.row .text.empty {
padding: 6px 12px;
}
.explain-section.row .line.hit {
background-color: rgba(227, 160, 8, 0.14);
}
/* 放不下三格(窄于 600:手机、课堂任务的弹窗)就退回竖排 */
@container explain-row (max-width: 600px) {
.explain-section.row .blocks {
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
}
.explain-section.row .block {
display: contents;
}
.explain-section.row .label {
line-height: 36px;
}
}
</style> </style>
@@ -0,0 +1,273 @@
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { HINT_MIN_FAILURES, isFlowchartPass } from '@oj2/contract'
import { habitSituation, logMascotEvent, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
import { useChallengeStore } from 'oj/store/challenge'
import { useFlowchartStore } from 'oj/store/flowchart'
import type { MascotSituation } from 'shared/mascot/lines'
import { useCollabStore } from 'shared/store/collab'
import { useLessonStore } from 'shared/store/lesson'
import { useUserStore } from 'shared/store/user'
import { useCodeStore } from 'oj/store/code'
import { useProblemStore } from 'oj/store/problem'
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
import { SubmissionStatus } from 'utils/constants'
import type { ProblemDetail } from 'utils/types'
import { useProblemPageContext } from './problemPageContext'
import { useTeacherCollab } from './teacherCollab'
/** 同一道题错到这么多次,她就不怼了,劝去举手 */
const STUCK_FAILS = 5
/** 错过这么多次以后做对,算「终于」 */
const FINALLY_FAILS = 2
const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
[SubmissionStatus.wrong_answer]: 'problem.wrong',
[SubmissionStatus.partial_accepted]: 'problem.wrong',
[SubmissionStatus.ast_check_failed]: 'problem.wrong',
[SubmissionStatus.compile_error]: 'problem.compileError',
[SubmissionStatus.runtime_error]: 'problem.runtimeError',
[SubmissionStatus.memory_limit_exceeded]: 'problem.runtimeError',
[SubmissionStatus.cpu_time_limit_exceeded]: 'problem.timeout',
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
}
/** 一道题上这么久没交、没跑例子,她就来问一句(主动开口) */
const IDLE_MS = 15 * 60_000
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
const UNSAMPLED_SUBMITS = 3
/** 同班做对这么多人,就拿出来说「全班已经 N 个人做对了」 */
const CLASS_MANY = 5
/** 同班交过这么多人、做对的不超过 CLASS_FEW 个,算「全班都卡着」 */
const CLASS_TRIED = 5
const CLASS_FEW = 2
/** 全站交过这么多次,通过率才有意义 */
const RATE_MIN_SUBMISSIONS = 30
const HARD_RATE = 0.3
const EASY_RATE = 0.7
/** 几种「换个说法」各自的机会:不是每次都说同一件事 */
const CHANCE = { classMany: 0.3, classFew: 0.5, rate: 0.4, hint: 0.5, challenge: 0.4 } as const
const roll = (chance: number) => Math.random() < chance
const FINISHED = (result: number | undefined) =>
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
/**
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
*
* 另外几件事她会主动开口(只对学生,频率上限在 Mascot.vue):同一道题错到第 5 次、连着 3 次
* 没跑例子就交还错了、又犯了自己的老毛病(behavior.ts 的 habitSituation)、15 分钟没交没跑。
*
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
*/
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
const router = useRouter()
const userStore = useUserStore()
const problemStore = useProblemStore()
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const collabStore = useCollabStore()
const lessonStore = useLessonStore()
const ctx = useProblemPageContext()
const teacherCollab = useTeacherCollab()
const { isDesktop } = useBreakpoints()
const flowchartStore = useFlowchartStore()
const challengeStore = useChallengeStore()
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
function studentActions(problem: ProblemDetail) {
const language = codeStore.code.language
const entry = ctx.value
const runSamples: MascotAction | null =
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
const help: MascotAction | null =
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
: null
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
const whySamples: MascotAction = { label: '我错在哪', run: () => showResult('samples') }
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
const submit: MascotAction | null = busy
? null
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
return { runSamples, help, why, whySamples, submit }
}
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
situation,
actions: actions.filter((it): it is MascotAction => !!it),
})
useMascotGuide(() => {
const problem = problemStore.problem
if (!problem) return null
if (userStore.isTeacherOrAbove) {
// 协作中别把老师带离这一页:跳走协作就断了
if (teacherCollab.value) return null
const actions: MascotAction[] = [{ label: '全班这题的提交', run: () => router.push({ name: 'submissions', query: { problem: problem._id } }) }]
if (isDesktop.value && userStore.canEditProblem(problem)) {
actions.push({ label: '编辑这道题', run: () => window.open(router.resolve(`/admin/problem/edit/${problem.id}`).href, '_blank') })
}
return { situation: 'teacher.problem', actions }
}
const entry = ctx.value
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
if (codeStore.code.language === 'Flowchart') {
// 画过、评过分了:分低就说分低,拿了 S 夸一句
const grade = flowchartStore.latestRating.grade
if (grade === 'S') return guide('flowchart.good', help)
if (grade && !isFlowchartPass(grade)) return guide('flowchart.low', help)
return guide('problem.flowchart', help)
}
// 正在判、正在跑例子:没什么好说的,打个招呼
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
const stuck = problemStore.failCount >= STUCK_FAILS
if (submissionStore.syntaxErrorInfo) return stuck ? guide('problem.stuck', help, why) : guide('problem.compileError', why, help)
const runs = submissionStore.trial.sampleRuns.value
if (submissionStore.resultSegment === 'samples' && runs.length) {
return runs.every((run) => run.result === SubmissionStatus.accepted)
? guide('problem.samplesPassed', submit)
: guide('problem.samplesFailed', whySamples, help)
}
const result = submissionStore.submission?.result
const lesson = lessonStore.find(problem._id)
if (result === SubmissionStatus.accepted) {
const next = nextAction()
// 课堂条的数是拉的时候的,刚做对重拉回来可能已经把自己算进去了
if (lesson && lesson.acceptedCount <= (lesson.myStatus === 'accepted' ? 1 : 0)) return guide('class.firstSolver', next)
if (entry.challenges && challengeStore.current?.challenges.length && roll(CHANCE.challenge)) return guide('problem.challenge', next)
const fails = problemStore.failCount
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
}
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
if (failed) {
if (stuck) return guide('problem.stuck', help, why)
const actions = [why, failed === 'problem.wrong' ? runSamples : null, help]
// AI 提示开了还没要:提一句,按钮带去结果里(提示在那儿)
const hintOpen = !problem.contestId && problemStore.failCount >= HINT_MIN_FAILURES && !submissionStore.hint.hintContent.value
if (hintOpen && roll(CHANCE.hint)) return guide('problem.hintReady', ...actions)
const classGuide = classSituation(lesson)
if (classGuide) return { ...guide(classGuide.situation, ...actions), n: classGuide.n }
const rate = problem.submissionNumber >= RATE_MIN_SUBMISSIONS ? problem.acceptedNumber / problem.submissionNumber : null
if (rate !== null && rate < HARD_RATE && roll(CHANCE.rate)) return guide('problem.hardWrong', ...actions)
if (rate !== null && rate > EASY_RATE && failed === 'problem.wrong' && roll(CHANCE.rate)) return guide('problem.easyWrong', ...actions)
return guide(failed, ...actions)
}
// 这两种只说话、只留举手:上线第一天「做过了」的「下一道」没人点过,空着代码跑例子也没意义,
// 而空白页上「举手求助」是真有人点(不会下手)。没有按钮的气泡 5 秒自己收起,不一直挡着
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore')
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', help)
const classGuide = classSituation(lesson)
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
return guide('problem.unsubmitted', runSamples, help)
/** 这节课的题:同班多少人做对了(课堂条那份数),不在这节课里就没有 */
function classSituation(item: typeof lesson) {
if (!item || item.myStatus === 'accepted') return null
if (item.acceptedCount >= CLASS_MANY && roll(CHANCE.classMany)) return { situation: 'class.manyDone' as const, n: item.acceptedCount }
if (item.userCount >= CLASS_TRIED && item.acceptedCount <= CLASS_FEW && roll(CHANCE.classFew))
return { situation: 'class.fewDone' as const, n: item.acceptedCount }
return null
}
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
function nextAction(): MascotAction | null {
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
const next = lessonStore.nextAfter(problem!._id)?.next
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
}
})
// ---- 主动开口(只对学生;比赛里 Mascot.vue 不弹)----
const student = () => userStore.isAuthed && !userStore.isTeacherOrAbove && ctx.value.entry !== 'contest'
/** 这道题上连着几次没跑例子就交了。跑过一次例子就清零 */
let unsampledSubmits = 0
let sampledSinceSubmit = false
/** 上一次「动静」(进题、交、跑例子)的时刻,给「盯了好久」用 */
let lastActivity = Date.now()
watch(
() => problemStore.problem?.id,
() => {
unsampledSubmits = 0
sampledSinceSubmit = false
lastActivity = Date.now()
},
)
watch(
() => submissionStore.trial.samplesRunAt.value,
(at) => {
if (!at) return
sampledSinceSubmit = true
lastActivity = Date.now()
if (student()) logMascotEvent({ kind: 'trial', action: 'samples' })
},
)
// 「运行例子」「自己输入」「举手」别处都不存,记下来给她看一周的习惯(behavior.ts)
watch(
() => submissionStore.trial.customRunning.value,
(running) => {
if (!running) return
lastActivity = Date.now()
if (student()) logMascotEvent({ kind: 'trial', action: 'custom' })
},
)
watch(
() => collabStore.helpStatus,
(status, previous) => {
if (status === 'pending' && previous === 'idle' && student()) logMascotEvent({ kind: 'help' })
},
)
watch(
() => submissionStore.submission?.id,
(id) => {
if (!id) return
unsampledSubmits = sampledSinceSubmit ? 0 : unsampledSubmits + 1
sampledSinceSubmit = false
lastActivity = Date.now()
},
)
// 判完了:错了就看要不要说一句;不管对错都刷新行为数(最近的错、今天做对几道)
watch(
() => [submissionStore.submission?.id, submissionStore.submission?.result] as const,
([id, result]) => {
const problem = problemStore.problem
if (!id || !FINISHED(result) || !problem || !student()) return
refreshMascotProfile(true)
const failed = RESULT_SITUATION[result as SubmissionStatus]
if (!failed) return
const { runSamples, help, why } = studentActions(problem)
if (problemStore.failCount >= STUCK_FAILS) {
requestSpeech({ key: `stuck:${problem.id}`, ...guide('problem.stuck', help, why) })
} else if (unsampledSubmits >= UNSAMPLED_SUBMITS && runSamples) {
requestSpeech({ key: `unsampled:${problem.id}`, ...guide('habit.noSamples', runSamples) })
} else {
const habit = habitSituation(failed)
if (habit) requestSpeech({ key: `habit:${habit}`, ...guide(habit, why) })
}
},
)
// 盯着一道没做对的题 15 分钟,没交也没跑:问一句
useIntervalFn(() => {
const problem = problemStore.problem
if (!problem || !student() || Date.now() - lastActivity < IDLE_MS) return
if (problem.myStatus === SubmissionStatus.accepted || submissionStore.submission?.result === SubmissionStatus.accepted) return
if (codeStore.code.language === 'Flowchart') return
const { runSamples, help } = studentActions(problem)
requestSpeech({ key: `idle:${problem.id}`, ...guide('idle.stuck', help, runSamples) })
}, 30_000)
}
@@ -14,28 +14,36 @@ const EMOJI_SVGS = [
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用 // 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
/**
* 表情在屏幕上的边长(px)。位图就按这个大小画,放的时候 `scalar: 1`、矩阵不缩放。
*
* **别改回「位图画大、矩阵缩小、scalar 放大」**:canvas-confetti 画位图粒子是每帧
* `fillRect(位图边长 × scalar)` 再用图案填,填的矩形和表情实际多大无关。原来位图 64、
* scalar 5,表情显示 50px,每个粒子每帧却要填 320×320 —— 两团 32 个粒子一帧三百多万像素,
* 机房电脑(显卡弱、Chrome 常走软件渲染)放到这一种就卡(用户 2026-11 反馈)。
* 现在填的正好是 50×50,同样大小、同样轨迹,像素少到四十分之一。
*/
const EMOJI_PX = 50
function svgToBitmapShape(svg: string): Promise<confetti.Shape> { function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const blob = new Blob([svg], { type: 'image/svg+xml' }) const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const img = new Image() const img = new Image()
img.onload = async () => { img.onload = async () => {
const renderSize = 64
const baseUnit = 10
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = renderSize canvas.width = EMOJI_PX
canvas.height = renderSize canvas.height = EMOJI_PX
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, renderSize, renderSize) ctx.drawImage(img, 0, 0, EMOJI_PX, EMOJI_PX)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
try { try {
const bitmap = await createImageBitmap(canvas) const bitmap = await createImageBitmap(canvas)
const scale = baseUnit / renderSize
resolve({ resolve({
type: 'bitmap', type: 'bitmap',
bitmap, bitmap,
matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2], matrix: [1, 0, 0, 1, -EMOJI_PX / 2, -EMOJI_PX / 2],
} as unknown as confetti.Shape) } as unknown as confetti.Shape)
} catch (e) { } catch (e) {
reject(e) reject(e)
@@ -415,7 +423,8 @@ export function useFireworks() {
const options: confetti.Options = { const options: confetti.Options = {
shapes: emojiShapes, shapes: emojiShapes,
flat: true, flat: true,
scalar: 5, // 大小在位图里(EMOJI_PX),这里放大的话填的矩形跟着放大,见 EMOJI_PX
scalar: 1,
particleCount: 16, particleCount: 16,
spread: 360, spread: 360,
startVelocity: 40, startVelocity: 40,
+56 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { errorCode } from 'utils/api' import { errorCode } from 'utils/api'
import { getProblem } from 'oj/api' import { getProblem } from 'oj/api'
@@ -19,6 +20,7 @@ import ContextBar from './components/ContextBar.vue'
import { useEditorMenu } from './composables/editorMenu' import { useEditorMenu } from './composables/editorMenu'
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext' import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab' import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
import { useProblemMascotGuide } from './composables/mascotGuide'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版, // 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
@@ -120,6 +122,13 @@ watch(
) )
const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key]) const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key])
/** 抽屉按钮前的小图标:页签行右边三个不带框的按钮,和左边的页签分得开(原来是三个圆角胶囊,看着像另一组页签) */
const DRAWER_ICON: Record<DrawerKey, string> = {
info: 'ph:chart-bar',
reaction: 'ph:chat-circle',
submission: 'ph:clock-counter-clockwise',
}
function toggleDrawer(key: DrawerKey) { function toggleDrawer(key: DrawerKey) {
drawer.value = drawer.value === key ? null : key drawer.value = drawer.value === key ? null : key
} }
@@ -208,6 +217,13 @@ watch(
}, },
) )
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
useProblemMascotGuide((segment) => {
drawer.value = null
currentTab.value = 'result'
submissionStore.resultSegment = segment
})
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数 // 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' }) const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
@@ -306,7 +322,7 @@ onBeforeUnmount(() => {
</div> </div>
<div v-if="ctx.drawers.length" class="drawer-buttons"> <div v-if="ctx.drawers.length" class="drawer-buttons">
<button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)"> <button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)">
{{ drawerLabel(key) }} <Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
</button> </button>
</div> </div>
</div> </div>
@@ -409,26 +425,46 @@ onBeforeUnmount(() => {
cursor: col-resize; cursor: col-resize;
} }
/*
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
*/
.left-pane { .left-pane {
position: relative; position: relative;
height: 100%; height: 100%;
display: flex; display: grid;
flex-direction: column; grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden; overflow: hidden;
} }
.left-pane > :deep(.lesson-rail) {
grid-column: 1;
grid-row: 1 / -1;
}
.left-pane > :deep(.context-bar),
.left-pane > .tab-row {
grid-column: 2;
}
.left-pane > .pane-body {
grid-column: 2;
grid-row: 3;
}
.right-pane { .right-pane {
height: 100%; height: 100%;
} }
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮。 /* 「题目 / 结果」:下划线式页签,右边是抽屉的三个按钮(图标 + 字,不带框)。
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排, 高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
差 6px 的话两条下边线在分隔线处错成一个台阶 */ 差 6px 的话两条下边线在分隔线处错成一个台阶 */
.tab-row { .tab-row {
flex: none; flex: none;
height: 48px; height: 48px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 20px 0 12px; padding: 0 10px 0 8px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
@@ -460,24 +496,32 @@ onBeforeUnmount(() => {
.drawer-buttons { .drawer-buttons {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
gap: 4px; gap: 2px;
} }
.drawer-button { .drawer-button {
height: 32px; height: 32px;
padding: 0 11px; padding: 0 9px;
border: 1px solid v-bind('theme.borderColor'); border: 0;
border-radius: 16px; border-radius: 6px;
background: transparent; background: transparent;
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit; font: inherit;
font-size: 13px; font-size: 14px;
color: v-bind('theme.textColor2'); color: v-bind('theme.textColor2');
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.drawer-button svg {
color: v-bind('theme.textColor3');
}
.drawer-button:hover { .drawer-button:hover {
border-color: v-bind('theme.primaryColorHover'); background-color: rgba(128, 128, 128, 0.1);
color: v-bind('theme.primaryColor'); color: v-bind('theme.textColor1');
} }
.pane-body { .pane-body {
+13
View File
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import ProblemStatus from './components/ProblemStatus.vue' import ProblemStatus from './components/ProblemStatus.vue'
import ProblemTypeTag from './components/ProblemTypeTag.vue' import ProblemTypeTag from './components/ProblemTypeTag.vue'
import ProgressRow from './components/ProgressRow.vue' import ProgressRow from './components/ProgressRow.vue'
@@ -272,6 +273,18 @@ async function pickRandom() {
} }
} }
// 看板娘:学生给找题的几条路(「随便来一道」按这页选着的知识点和类型挑),老师给新建题目
useMascotGuide(() => {
if (teacher.value) {
return {
situation: 'teacher.problems',
actions: userStore.hasProblemPermission ? [{ label: '新建题目', run: () => router.push('/admin/problem/create') }] : [],
}
}
const undone: MascotAction | null = userStore.isAuthed && !undoneOn.value ? { label: '只看没做完', run: () => (undoneOn.value = true) } : null
return { situation: 'problems', actions: [focusJumpAction(), ...(undone ? [undone] : []), { label: '随便来一道', run: pickRandom }] }
})
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ---- // ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
const nextTag = computed(() => { const nextTag = computed(() => {
const p = mine.value const p = mine.value
@@ -0,0 +1,14 @@
/**
* 题面正文(描述 / 输入 / 输出 / 提示)在渲染前去掉空段落。
*
* 库里的题面多是 wangEditor 时代的 HTML,老师为了「隔开一点」敲的空行存成
* `<p><br></p>`、`<p>&nbsp;</p>`,每个都撑出一整行高的缝(2020 一道题就有两处)。
* 段落之间的间距题面自己会给,这些空段落一律去掉。只动「整段什么字都没有」的,
* 段落里带图片、带字的都不碰。
*/
const EMPTY_PARAGRAPH = /<p(?:\s[^>]*)?>(?:\s|&nbsp;|&#160;| |<br\s*\/?>)*<\/p>/gi
export function tidyStatement(html: string | null | undefined): string {
if (!html) return ''
return html.replace(EMPTY_PARAGRAPH, '')
}
+15
View File
@@ -8,6 +8,7 @@ import { parseTime } from 'utils/functions'
import type { ProblemSet, ProblemSetProblem } from 'utils/types' import type { ProblemSet, ProblemSetProblem } from 'utils/types'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { useFireworks } from '../problem/composables/useFireworks' import { useFireworks } from '../problem/composables/useFireworks'
import NotFound from 'shared/components/NotFound.vue' import NotFound from 'shared/components/NotFound.vue'
import BadgeLadder from './components/BadgeLadder.vue' import BadgeLadder from './components/BadgeLadder.vue'
@@ -122,6 +123,20 @@ async function join() {
} }
} }
// 看板娘:老师看全班,学生还差几道必做题、带去下一道;没加入就只打招呼
useMascotGuide(() => {
if (!set.value) return null
if (userStore.isTeacherOrAbove) return { situation: 'teacher.problemset', actions: [{ label: '全班情况', run: () => (tab.value = 'class') }] }
if (!joined.value) return null
const todo = problems.value.filter((item) => item.isRequired && !item.isCompleted)
const next = todo[0]
if (progress.value?.isCompleted || !next) return { situation: 'problemset.done', actions: [goAction(router, '专题列表', '/problemset')] }
const actions = [{ label: '去做下一道', run: () => router.push(problemLink(next)) }]
// 只剩最后一道、做完还有奖章没拿:说「就差最后 1 道」比「还差 1 道」更想让人做完
if (todo.length === 1 && set.value.badges.some((badge) => !badge.isEarned)) return { situation: 'problemset.lastOne', actions }
return { situation: 'problemset.unfinished', n: todo.length, actions }
})
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */ /** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id)) const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
+21 -2
View File
@@ -3,6 +3,7 @@ import { useThemeVars } from 'naive-ui'
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 { getProblemSetList } from 'oj/api' import { getProblemSetList } from 'oj/api'
import { rgba, useTone } from 'shared/composables/tone' import { rgba, useTone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
@@ -55,6 +56,16 @@ const others = computed(() =>
}), }),
) )
// 看板娘:加入了还没做完的专题,布置中的优先,其次最近加入的
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const pending = sets.value.filter((set) => set.userProgress.isJoined && !set.userProgress.isCompleted)
const latest = (set: ProblemSet) => Date.parse(set.userProgress.joinTime ?? '') || 0
const target = pending.find((set) => set.assigning) ?? [...pending].sort((a, b) => latest(b) - latest(a))[0]
if (!target) return null
return { situation: 'problemsets.unfinished', actions: [goAction(router, '接着做', `/problemset/${target.id}`)] }
})
function description(set: ProblemSet) { function description(set: ProblemSet) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示 // 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : '' return set.description && set.description !== set.title ? set.description : ''
@@ -83,6 +94,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
<div class="page oj-page"> <div class="page oj-page">
<div class="top"> <div class="top">
<h2>专题</h2> <h2>专题</h2>
<!-- 没有布置中的,就只有一组:数量写在标题旁边,不再另起一行「专题 14 个」把标题再说一遍 -->
<span v-if="!assigning.length && others.length" class="muted count">{{ others.length }} 个</span>
<div class="spacer"></div> <div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" /> <n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 --> <!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
@@ -133,8 +146,8 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</div> </div>
</template> </template>
<div v-if="others.length" class="sect"> <div v-if="assigning.length && others.length" class="sect">
{{ assigning.length ? '其他专题' : '专题' }} 其他专题
<span class="muted">{{ others.length }} 个</span> <span class="muted">{{ others.length }} 个</span>
</div> </div>
<div class="small-grid"> <div class="small-grid">
@@ -191,6 +204,12 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
font-size: var(--oj-fs-title); font-size: var(--oj-fs-title);
} }
/* 和分组标题旁的「N 个」同一档:小字、不加粗 */
.top .count {
font-size: var(--oj-fs-sec);
font-weight: 400;
}
.search { .search {
width: 220px; width: 220px;
} }
+18
View File
@@ -7,6 +7,7 @@ import UserName from 'shared/components/UserName.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
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 { displayName } from '@oj2/contract' import { displayName } from '@oj2/contract'
import { STORAGE_KEY } from 'utils/constants' import { STORAGE_KEY } from 'utils/constants'
@@ -271,6 +272,23 @@ function openDetail(className: string) {
showDetail.value = true showDetail.value = true
} }
// 看板娘:自己在榜上就把自己那一行滚到眼前(领奖台上是「你」那个标签,赛道上是高亮的那一行)
useMascotGuide(() => {
if (teacher.value || !board.value) return null
const listed = rows.value.some((row) => row.user.id === meId.value)
const findMe = () => {
const el = document.querySelector('.track-card .entry.me, .track-card .prow.me, .track-card .tag.me')
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const actions = listed ? [{ label: '我在哪', run: findMe }] : []
const { me: mine, ahead } = board.value
if (mine?.solved && mine.rank === 1) return { situation: 'rank.top', actions }
// 同分按先做到排,追平还在后面,所以要比前一名多做 1 道
const gap = mine && ahead ? ahead.solved - mine.solved : null
if (gap !== null && gap <= 5) return { situation: 'rank.gap', n: gap + 1, actions }
return { situation: 'rank', actions }
})
function open(username: string) { function open(username: string) {
router.push({ path: '/user', query: { name: username } }) router.push({ path: '/user', query: { name: username } })
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { useDark } from '@vueuse/core' import { useDark } from 'shared/composables/dark'
/** /**
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、 * 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import { useMascotGuide } from 'shared/mascot/guide'
import StatisticsView from './components/StatisticsView.vue' import StatisticsView from './components/StatisticsView.vue'
import { DEFAULT_PERIOD } from './period' import { DEFAULT_PERIOD } from './period'
@@ -19,6 +20,8 @@ const period = useRouteQuery<string>('period', DEFAULT_PERIOD, { mode: 'replace'
const from = useRouteQuery<string>('from', '', { mode: 'replace' }) const from = useRouteQuery<string>('from', '', { mode: 'replace' })
const to = useRouteQuery<string>('to', '', { mode: 'replace' }) const to = useRouteQuery<string>('to', '', { mode: 'replace' })
useMascotGuide(() => ({ situation: 'teacher.statistics' }))
function openList(query: Record<string, string>) { function openList(query: Record<string, string>) {
window.open(router.resolve({ name: 'submissions', query }).href, '_blank') window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
} }
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useDark } from '@vueuse/core' import { useDark } from 'shared/composables/dark'
/** /**
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。 * 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
@@ -23,6 +23,8 @@ import UserName from 'shared/components/UserName.vue'
*/ */
const props = defineProps<{ const props = defineProps<{
row: SubmissionListItem row: SubmissionListItem
/** 按 ↑ ↓ 会落到的上一条、下一条:这条加载完顺手把它们也拉回来,换条时不用等网络 */
neighbors?: SubmissionListItem[]
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */ /** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
teacher: boolean teacher: boolean
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */ /** 在比赛的提交列表里(不能重判,题目链接走比赛) */
@@ -61,7 +63,42 @@ const detail = ref<Submission | null>(null)
const loading = ref(false) const loading = ref(false)
const failed = ref(false) const failed = ref(false)
const judging = computed(() => props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging) function isJudging(row: SubmissionListItem) {
return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging
}
const judging = computed(() => isJudging(props.row))
/**
* 同一条只发一次请求:预取的还在路上时按到了它,接着等那一个,不再发第二个
*/
const inflight = new Map<string, Promise<Submission>>()
function fetchDetail(id: string) {
let request = inflight.get(id)
if (!request) {
request = getSubmission(id).finally(() => inflight.delete(id))
inflight.set(id, request)
}
return request
}
/**
* 预取上一条、下一条。只在这一条落定之后才做(按住 ↓ 一路划过去的那些行不预取),
* 进缓存的口径和 load() 一样:还在判的不要,列表和详情的结果对不上也不要。
*
* 没预取的时候,换到没看过的一条要先把代码区卸掉、转圈、再整个重新挂上 ——
* 2026-11 实测(生产构建、CPU 降速 6 倍)每次卡 60–100ms;走缓存的只换内容,不卡。
*/
function prefetch() {
for (const row of props.neighbors ?? []) {
if (!row.showLink || isJudging(row) || cache.get(row.id)?.result === row.result) continue
fetchDetail(row.id).then(
(res) => {
if (res.result === row.result) cache.set(row.id, res)
},
() => {},
)
}
}
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的 // 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
let loadSeq = 0 let loadSeq = 0
@@ -78,15 +115,17 @@ async function load() {
const hit = cache.get(row.id) const hit = cache.get(row.id)
if (hit && hit.result === row.result) { if (hit && hit.result === row.result) {
detail.value = hit detail.value = hit
prefetch()
return return
} }
loading.value = true loading.value = true
try { try {
const res = await getSubmission(row.id) const res = await fetchDetail(row.id)
if (seq !== loadSeq) return if (seq !== loadSeq) return
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上 // 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
if (!judging.value && res.result === props.row.result) cache.set(row.id, res) if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
detail.value = res detail.value = res
prefetch()
} catch { } catch {
if (seq === loadSeq) failed.value = true if (seq === loadSeq) failed.value = true
} finally { } finally {
@@ -310,7 +310,15 @@ function handleMore(key: string) {
class="code" class="code"
/> />
<WrongAnswerExplain v-if="sampleCheck" :check="sampleCheck" :code="shown.code" :language="shown.language" :plain="!!shown.contestId" :peer="!own" /> <WrongAnswerExplain
v-if="sampleCheck"
section="row"
:check="sampleCheck"
:code="shown.code"
:language="shown.language"
:plain="!!shown.contestId"
:peer="!own"
/>
<ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" /> <ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" />
</template> </template>
@@ -35,6 +35,13 @@ export function useRowCursor(options: {
const index = navigable.value.findIndex((row) => row.id === selectedId.value) const index = navigable.value.findIndex((row) => row.id === selectedId.value)
return index === -1 ? null : { index: index + 1, count: navigable.value.length } return index === -1 ? null : { index: index + 1, count: navigable.value.length }
}) })
/** 按 ↑ ↓ 会落到的上一条、下一条(本页之内),右栏拿去预取 */
const neighbors = computed(() => {
const list = navigable.value
const index = list.findIndex((row) => row.id === selectedId.value)
if (index === -1) return []
return [list[index + 1], list[index - 1]].filter((row): row is Row => row !== undefined)
})
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选 // 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
let pendingJump: 'first' | 'last' | null = null let pendingJump: 'first' | 'last' | null = null
@@ -125,5 +132,5 @@ export function useRowCursor(options: {
move(e.key === 'ArrowUp' ? -1 : 1) move(e.key === 'ArrowUp' ? -1 : 1)
}) })
return { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } return { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump }
} }
+34 -1
View File
@@ -1,8 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSubmission } from 'oj/api'
import { useMascotGuide } from 'shared/mascot/guide'
import { useUserStore } from 'shared/store/user'
import type { Submission } from 'utils/types'
import SubmissionView from './components/SubmissionView.vue' import SubmissionView from './components/SubmissionView.vue'
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */ /** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>() const props = defineProps<{ submissionID: string }>()
const router = useRouter()
const userStore = useUserStore()
/**
* 看板娘:学生看自己的提交时带回这道题。SubmissionView 里的数据拿不出来(弹层也用它,不挂看板娘),
* 这里另取一份不带「这道题交的几次」的,只给学生取
*/
const mine = ref<Submission | null>(null)
watch(
() => [props.submissionID, userStore.isAuthed && !userStore.isTeacherOrAbove] as const,
async ([id, student]) => {
mine.value = null
if (!student) return
const res = await getSubmission(id).catch(() => null)
if (res && id === props.submissionID && res.userId === userStore.user?.id) mine.value = res
},
{ immediate: true },
)
useMascotGuide(() => {
const s = mine.value
if (!s || userStore.isTeacherOrAbove) return null
// 和页面上题目那个链接同一个去处:比赛里交的回比赛里的题
const to = s.contestId
? { name: 'contest problem', params: { contestID: String(s.contestId), problemID: s.problemDisplayId } }
: '/problem/' + s.problemDisplayId
return { situation: 'submission.detail', actions: [{ label: '回到这道题', run: () => router.push(to) }] }
})
</script> </script>
<template> <template>
+16 -1
View File
@@ -11,6 +11,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { usePagination } from 'shared/composables/pagination' import { usePagination } from 'shared/composables/pagination'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants' import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types' import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types'
@@ -263,7 +264,7 @@ watch(
// ==================== 选中与翻阅 ==================== // ==================== 选中与翻阅 ====================
const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({ const { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({
rows, rows,
total, total,
query, query,
@@ -427,6 +428,18 @@ const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
info: (text) => message.info(text), info: (text) => message.info(text),
}) })
// 看板娘:学生带去看自己的;「这节课」只有老师那一版有,老师给它
useMascotGuide(() => {
if (inContest.value) return null
if (!teacher.value) return { situation: 'submissions', actions: mine.value ? [] : [{ label: '只看我的', run: () => (scope.value = 'mine') }] }
const toLesson = () => {
// 「我的」时这节课的按钮是藏着的,先切回全部
if (mine.value) scope.value = 'all'
toggleLesson()
}
return { situation: 'teacher.submissions', actions: lessonActive.value ? [] : [{ label: '这节课', run: toLesson }] }
})
const activeFilters = computed( const activeFilters = computed(
() => () =>
[query.username, query.className, query.result, query.problem].filter(Boolean).length + [query.username, query.className, query.result, query.problem].filter(Boolean).length +
@@ -858,6 +871,7 @@ function dayBreak(index: number) {
<SubmissionPane <SubmissionPane
v-else v-else
:row="selectedRow as SubmissionListItem" :row="selectedRow as SubmissionListItem"
:neighbors="neighbors as SubmissionListItem[]"
:teacher="teacher" :teacher="teacher"
:class-name="query.className" :class-name="query.className"
:contest="inContest" :contest="inContest"
@@ -893,6 +907,7 @@ function dayBreak(index: number) {
<SubmissionPane <SubmissionPane
v-else v-else
:row="selectedRow as SubmissionListItem" :row="selectedRow as SubmissionListItem"
:neighbors="neighbors as SubmissionListItem[]"
:teacher="teacher" :teacher="teacher"
:class-name="query.className" :class-name="query.className"
:contest="inContest" :contest="inContest"
@@ -92,6 +92,9 @@ function openPicker() {
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) })) pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true picking.value = true
} }
// 看板娘气泡里的「选题」:课堂任务页没布置时替老师打开选题弹框
defineExpose({ openPicker })
function pickerConfirm(ids: string[]) { function pickerConfirm(ids: string[]) {
lessonInput.value = ids.join(' ') lessonInput.value = ids.join(' ')
saveLesson() saveLesson()
+215 -41
View File
@@ -10,6 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
import { useTeacherHelp } from 'shared/composables/teacherHelp' import { useTeacherHelp } from 'shared/composables/teacherHelp'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { requestSpeech } from 'shared/mascot/behavior'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
@@ -122,8 +124,11 @@ onUnmounted(pause)
*/ */
const editing = ref<false | 'edit' | 'new' | 'carry'>(false) const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
/** 这节课做什么;推断出来的课为 null(代码交对或流程图画对都算) */ /**
const mode = computed(() => board.value?.mode ?? null) * 第几道题今天做什么:今天几节里有这道题的要求合起来(先画流程图一节、再写代码一节 = 先画再写),
* 和学生那边的「做完」一致。推断出来的课为 null(代码交对或流程图画对都算)
*/
const modeAt = (index: number) => board.value?.problems[index]?.mode ?? null
/** /**
* 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久, * 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
@@ -200,7 +205,7 @@ function groupOf(student: TaskBoardStudent): Group {
const cells = student.cells const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done' if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
if (!student.lastSubmitAt) return 'idle' if (!student.lastSubmitAt) return 'idle'
if (cells.some((cell) => isStuck(cell, mode.value))) return 'stuck' if (cells.some((cell, i) => isStuck(cell, modeAt(i)))) return 'stuck'
return 'working' return 'working'
} }
@@ -327,23 +332,24 @@ function drawKind(cell: TaskBoardCell): ErrorKind {
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆 * 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
*/ */
function wrongOf(index: number): WrongPerson[] { function wrongOf(index: number): WrongPerson[] {
const mode = modeAt(index)
return present.value.flatMap((student): WrongPerson[] => { return present.value.flatMap((student): WrongPerson[] => {
const cell = student.cells[index] const cell = student.cells[index]
if (!cell || cell.status !== 'tried') return [] if (!cell || cell.status !== 'tried') return []
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, mode)
if (stage === 'draw') { if (stage === 'draw') {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1 const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }] return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }]
} }
if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }] if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }]
if (mode.value === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') { if (mode === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }] return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }]
} }
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆 // 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
const kind: ErrorKind = cell.latest const kind: ErrorKind = cell.latest
? errorKind(cell.latest) ? errorKind(cell.latest)
: !mode.value && cell.attempts : !mode && cell.attempts
? { key: 'flowchart', label: '今天只画了流程图', line: null } ? { key: 'flowchart', label: '今天只画了流程图', line: null }
: { key: 'before', label: '以前交过,今天还没交', line: null } : { key: 'before', label: '以前交过,今天还没交', line: null }
return [{ student, cell, kind, explain: cell.latest ? 'code' : null }] return [{ student, cell, kind, explain: cell.latest ? 'code' : null }]
@@ -362,21 +368,18 @@ const problemStats = computed(() =>
const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null
// 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码 // 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码
const steps = const steps =
mode.value === 'both' problem.mode === 'both'
? { ? {
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length, drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length, coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
} }
: null : null
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps } // 先画再写的题,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
const wrongWord = problem.mode === 'both' ? '没做完' : '错着'
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps, wrongWord }
}), }),
) )
/**
* 先画再写的课,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
*/
const wrongWord = computed(() => (mode.value === 'both' ? '没做完' : '错着'))
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value)) const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
function toggleProblem(problemId: number) { function toggleProblem(problemId: number) {
@@ -408,10 +411,14 @@ const explainShow = ref(false)
const explainIndex = ref(0) const explainIndex = ref(0)
const explainProblemIndex = ref(-1) const explainProblemIndex = ref(-1)
const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null) const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null)
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */ /**
const explainPeople = computed<ExplainPerson[]>(() => { * 弹层顶上那排人:这道题错着的人,顺序和错法堆一样。打开那一刻定下来,开着时不跟 15 秒的刷新变:
if (explainProblemIndex.value < 0) return [] * 弹层记的是第几个人,名单一变(有人改对了、图评完了回到名单里)就成了讲着讲着换了个人
const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain) */
const explainPeople = ref<ExplainPerson[]>([])
function explainPeopleOf(problemIndex: number): ExplainPerson[] {
if (problemIndex < 0) return []
const people = wrongOf(problemIndex).filter((person) => person.explain)
const order = new Map<string, number>() const order = new Map<string, number>()
for (const person of people) { for (const person of people) {
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1) order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
@@ -423,10 +430,11 @@ const explainPeople = computed<ExplainPerson[]>(() => {
? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label } ? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label }
: { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label }, : { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label },
) )
}) }
function openExplain(problemIndex: number, userId: number) { function openExplain(problemIndex: number, userId: number) {
explainProblemIndex.value = problemIndex explainProblemIndex.value = problemIndex
explainPeople.value = explainPeopleOf(problemIndex)
explainIndex.value = Math.max( explainIndex.value = Math.max(
0, 0,
explainPeople.value.findIndex((person) => person.userId === userId), explainPeople.value.findIndex((person) => person.userId === userId),
@@ -442,14 +450,15 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor, actionColor: theme.value.actionColor,
successColor: theme.value.successColor, successColor: theme.value.successColor,
})) }))
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value) /** `index`:第几道题,格子按那道题今天做什么来画(modeAt) */
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value) const cellStyle = (cell: TaskBoardCell, index: number) => cellStyleOf(cell, board.value?.day, cellColors.value, modeAt(index))
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value) const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent, index: number) => cellTitleOf(cell, student, board.value?.day, modeAt(index))
const cellText = (cell: TaskBoardCell, index: number) => cellTextOf(cell, modeAt(index))
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */ /** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: TaskBoardCell) { function explainable(cell: TaskBoardCell, index: number) {
if (cell.status !== 'tried') return false if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, modeAt(index))
if (stage === 'draw') { if (stage === 'draw') {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
return !!latest && latest.status !== 0 && latest.status !== 1 return !!latest && latest.status !== 0 && latest.status !== 1
@@ -490,7 +499,7 @@ function submissionsHref(
// SQL 题不受布置的语言限制,格子数的是 SQL 交的 // SQL 题不受布置的语言限制,格子数的是 SQL 交的
const language = problem?.isSql ? 'SQL' : board.value?.language const language = problem?.isSql ? 'SQL' : board.value?.language
// 只画流程图的课,格子数的全是流程图 // 只画流程图的课,格子数的全是流程图
const flowSide = flowchart || mode.value === 'flowchart' const flowSide = flowchart || problem?.mode === 'flowchart'
return router.resolve({ return router.resolve({
name: 'submissions', name: 'submissions',
query: { query: {
@@ -504,6 +513,161 @@ function submissionsHref(
}).href }).href
} }
/**
* 看板娘:布置了就带去这节课的提交,筛法同提交列表「这节课」(班、这几道题、今天)。
* 语言和格子一致:画流程图的课看流程图,选了语言的只看它 —— 夹着 SQL 题的不筛语言,免得把 SQL 交的藏掉。
* 还没布置时填题号的框就是这页最大的东西,不给按钮
*/
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm')
// ---------- 看板娘:点她时挑全班这一刻最要紧的一件说;卡住的多起来、全班做完时她自己开口 ----------
/** 同一种错有这么多人才算「扎堆」 */
const PILE_MIN = 3
/** 布置这么久了,人在线却一道没交,才算「没动」 */
const IDLE_AFTER_MS = 10 * 60_000
/** 一道题卡住的人涨到这么多,她主动说一声 */
const STUCK_RISE = 3
/** 人最多的那一堆错(灰底那几堆不算:不是今天的错,没得讲) */
function biggestPile() {
let best: { index: number; kind: ErrorKind; people: WrongPerson[] } | null = null
const count = board.value?.problems.length ?? 0
for (let index = 0; index < count; index++) {
const groups = new Map<string, WrongPerson[]>()
for (const person of wrongOf(index)) {
if (QUIET_KINDS.has(person.kind.key)) continue
groups.set(person.kind.key, [...(groups.get(person.kind.key) ?? []), person])
}
for (const people of groups.values()) {
if (!best || people.length > best.people.length) best = { index, kind: people[0]!.kind, people }
}
}
return best && best.people.length >= PILE_MIN ? best : null
}
/** 卡住的人(cells.ts 的 isStuck),交得多的在前 */
function stuckPeople() {
return present.value
.flatMap((student) => student.cells.map((cell, index) => ({ student, cell, index })))
.filter(({ cell, index }) => isStuck(cell, modeAt(index)))
.sort((a, b) => b.cell.attempts - a.cell.attempts)
}
/** 在线、今天一道没交、也不是早就全做完了的人 */
function idlePeople() {
const assignedAt = board.value?.assignedAt
if (!assignedAt || Date.now() - Date.parse(assignedAt) < IDLE_AFTER_MS) return []
return present.value.filter(
(student) => student.online && student.cells.every((cell) => cell.attempts === 0) && student.cells.some((cell) => cell.status !== 'accepted'),
)
}
const allDone = () => present.value.length > 0 && present.value.every((student) => student.cells.every((cell) => cell.status === 'accepted'))
/** 点名字:能讲的打开讲错误的弹层,不能讲的展开那道题的错法 */
function lookAt(index: number, student: TaskBoardStudent): MascotAction {
const cell = student.cells[index]!
const problem = board.value!.problems[index]!
return {
label: `看 ${displayName(student.username)} 的`,
run: () => (explainable(cell, index) ? openExplain(index, student.userId) : (selectedId.value = problem.problemId)),
}
}
useMascotGuide((): MascotGuide | null => {
const b = board.value
if (!b?.className) return null
if (needLesson.value)
return { situation: 'teacher.task.unassigned', actions: startForm.value ? [{ label: '选题', run: () => startForm.value?.openPicker() }] : [] }
const language = b.mode === 'flowchart' ? 'Flowchart' : b.problems.some((problem) => problem.isSql) ? null : b.language
const query = {
className: b.className,
problem: b.problems.map((problem) => problem.problemDisplayId).join(','),
today: '1',
...(language ? { language } : {}),
}
// 新标签打开,和点格子一样:任务页一般开着不关
const submissions: MascotAction = { label: '这节课的提交', run: () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') }
if (allDone())
return { situation: 'teacher.task.allDone', actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }] }
const pile = biggestPile()
if (pile) {
const problem = b.problems[pile.index]!
const first = pile.people.find((person) => person.explain)
const actions: MascotAction[] = [{ label: '看这堆', run: () => (selectedId.value = problem.problemId) }]
if (first) actions.push({ label: '讲第一个', run: () => openExplain(pile.index, first.student.userId) })
return {
situation: 'teacher.task.errorPile',
n: pile.people.length,
vars: { pid: problem.problemDisplayId, kind: pile.kind.label },
actions: [...actions, submissions],
}
}
const stuck = stuckPeople()
if (stuck.length) {
const top = stuck[0]!
return {
situation: 'teacher.task.stuck',
n: new Set(stuck.map((it) => it.student.userId)).size,
vars: { name: displayName(top.student.username), k: top.cell.attempts, pid: b.problems[top.index]!.problemDisplayId },
actions: [lookAt(top.index, top.student), submissions],
}
}
const idle = idlePeople()
if (idle.length) {
const name = displayName(idle[0]!.username)
return idle.length === 1 ? { situation: 'teacher.task.idleOne', vars: { name } } : { situation: 'teacher.task.idle', n: idle.length, vars: { name } }
}
const challenging = present.value.filter((student) => student.cells.some((cell) => cell.challenge === 'doing')).length
if (challenging) return { situation: 'teacher.task.challenging', n: challenging, actions: [submissions] }
return { situation: 'teacher.task.assigned', actions: [submissions] }
})
/**
* 主动开口:每 15 秒刷新一次,和上一次比 —— 某道题卡住的人从不到 3 个涨到 3 个、全班刚做完。
* 刚打开页面(或换了班、换了一节)的那一次只记数不说,不然一刷新就把已经知道的事再说一遍
*/
let seen: { key: string; stuck: Map<number, number>; done: boolean } | null = null
watch(board, (b) => {
if (!b?.className || needLesson.value) return
const key = `${b.className}|${b.day}|${b.lessonId ?? ''}|${b.problems.map((problem) => problem.problemId).join(',')}`
const stuck = new Map<number, number>()
for (const { index } of stuckPeople()) {
const id = b.problems[index]!.problemId
stuck.set(id, (stuck.get(id) ?? 0) + 1)
}
const done = allDone()
const before = seen?.key === key ? seen : null
seen = { key, stuck, done }
if (!before) return
if (done && !before.done) {
requestSpeech({
key: `done:${key}`,
situation: 'teacher.task.allDone',
actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }],
})
return
}
for (const [id, n] of stuck) {
if (n < STUCK_RISE || (before.stuck.get(id) ?? 0) >= STUCK_RISE) continue
const problem = b.problems.find((it) => it.problemId === id)!
requestSpeech({
key: `stuck:${key}:${id}`,
situation: 'teacher.task.stuckRise',
n,
vars: { pid: problem.problemDisplayId },
actions: [{ label: '看这道', run: () => (selectedId.value = id) }],
})
return
}
})
function lastText(student: TaskBoardStudent) { function lastText(student: TaskBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm') if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) { if (oftenIdle(student)) {
@@ -532,7 +696,8 @@ function lastText(student: TaskBoardStudent) {
/> />
<div v-else-if="board?.problems.length && !needLesson" class="lesson"> <div v-else-if="board?.problems.length && !needLesson" class="lesson">
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span> <span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
<span v-if="board.mode && board.mode !== 'code'" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span> <!-- 写代码也写出来:只写「Python」的话,看不出这节是写代码还是先画再写 -->
<span v-if="board.mode" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span>
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span> <span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip"> <span v-for="problem in board.problems" :key="problem.problemId" class="chip">
{{ problem.problemDisplayId }} {{ problem.problemDisplayId }}
@@ -570,7 +735,16 @@ function lastText(student: TaskBoardStudent) {
<!-- 刚上课还没有题:填题号的框是这页最大的东西 --> <!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start"> <section v-if="needLesson" class="start">
<div class="start-title">这节课做哪几道题?</div> <div class="start-title">这节课做哪几道题?</div>
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" /> <LessonForm
v-if="className"
ref="startForm"
:board="board"
:class-name="className"
:lesson-id="null"
variant="start"
class="start-row"
@saved="lessonSaved"
/>
<div v-if="staleLesson" class="earlier"> <div v-if="staleLesson" class="earlier">
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span> <span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span> <span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
@@ -601,7 +775,7 @@ function lastText(student: TaskBoardStudent) {
</span> </span>
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span> <span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
<span class="card-foot"> <span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ wrongWord }} {{ stat.wrong }}</span> <span :class="stat.wrong ? 'wrong' : 'muted'">{{ stat.wrongWord }} {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span> <span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span> <span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
</span> </span>
@@ -624,7 +798,7 @@ function lastText(student: TaskBoardStudent) {
{{ board.problems[selectedIndex]!.title }} {{ board.problems[selectedIndex]!.title }}
</a> </a>
<span> <span>
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人 {{ problemStats[selectedIndex]!.wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span> </span>
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span> <span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
<div class="spacer"></div> <div class="spacer"></div>
@@ -643,7 +817,7 @@ function lastText(student: TaskBoardStudent) {
<template v-for="person in group.people" :key="person.student.userId"> <template v-for="person in group.people" :key="person.student.userId">
<button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)"> <button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
<UserName :username="person.student.username" /> <UserName :username="person.student.username" />
<span class="mini" :style="cellStyle(person.cell)">{{ cellText(person.cell) }}</span> <span class="mini" :style="cellStyle(person.cell, selectedIndex)">{{ cellText(person.cell, selectedIndex) }}</span>
</button> </button>
<!-- 没有今天的代码可讲:去提交列表看他交过的 --> <!-- 没有今天的代码可讲:去提交列表看他交过的 -->
<a <a
@@ -747,15 +921,15 @@ function lastText(student: TaskBoardStudent) {
</td> </td>
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }"> <td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
<button <button
v-if="explainable(cell)" v-if="explainable(cell, i)"
type="button" type="button"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
@click="openExplain(i, student.userId)" @click="openExplain(i, student.userId)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</button> </button>
<n-popover <n-popover
v-else-if="cell.trail.length" v-else-if="cell.trail.length"
@@ -771,11 +945,11 @@ function lastText(student: TaskBoardStudent) {
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }" :class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
@click="toggleTrail(`${student.userId}:${i}`)" @click="toggleTrail(`${student.userId}:${i}`)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</button> </button>
</template> </template>
<TrailPanel <TrailPanel
@@ -791,15 +965,15 @@ function lastText(student: TaskBoardStudent) {
v-else-if="cell.attempts" v-else-if="cell.attempts"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)" :href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank" target="_blank"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</a> </a>
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell)" :title="cellTitle(cell, student)"> <span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell, i)" :title="cellTitle(cell, student, i)">
{{ cellText(cell) }} {{ cellText(cell, i) }}
</span> </span>
</td> </td>
<td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td> <td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td>
+8
View File
@@ -4,6 +4,7 @@ import { getProfileOverview } from 'oj/api'
import { getProfile } from 'shared/api' import { getProfile } from 'shared/api'
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 type { Profile } from 'utils/types' import type { Profile } from 'utils/types'
import AchievementCard from './components/AchievementCard.vue' import AchievementCard from './components/AchievementCard.vue'
import KnowledgeMap from './components/KnowledgeMap.vue' import KnowledgeMap from './components/KnowledgeMap.vue'
@@ -17,6 +18,7 @@ import VersusCard from './components/VersusCard.vue'
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。 * /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
*/ */
const route = useRoute() const route = useRoute()
const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -47,6 +49,12 @@ async function load() {
} }
watch(username, load, { immediate: true }) watch(username, load, { immediate: true })
// 看板娘:只在看自己的主页时说话
useMascotGuide(() => {
if (!isSelf.value || !profile.value || userStore.isTeacherOrAbove) return null
return { situation: 'profile', actions: [goAction(router, '我的成就', '/achievement'), goAction(router, '我的提交', '/submission?myself=1')] }
})
</script> </script>
<template> <template>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sutera-Diffusus
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more