Compare commits

..
25 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
173 changed files with 13150 additions and 715 deletions

No files matched your search

+2 -1
View File
@@ -62,7 +62,8 @@ cd apps/web && bun run build # 前端构建(不在 verify 里
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
(`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`,和根目录那份无关。
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
+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,
"tag": "0042_class_name_cleanup",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1793693911394,
"tag": "0043_mascot_event",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1793695621303,
"tag": "0044_achievement_more_tiers",
"breakpoints": true
}
]
}
+43
View File
@@ -46,6 +46,7 @@ import type {
JudgeStatus,
LessonLanguage,
LessonMode,
MascotEventKind,
ProblemChallenges,
ProblemDifficulty,
ProblemLanguage,
@@ -1411,3 +1412,45 @@ export const challengeSuccess = pgTable(
}).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 { submissionRoutes } from './routes/submission'
import { trialRunRoutes } from './routes/trial-run'
import { mascotRoutes } from './routes/mascot'
import { siteRoutes } from './routes/site'
import { scanPendingChallenges } from './services/challenge'
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
@@ -44,6 +45,7 @@ app.route('/api', problemRoutes)
app.route('/api', problemStatsRoutes)
app.route('/api', submissionRoutes)
app.route('/api', trialRunRoutes)
app.route('/api', mascotRoutes)
app.route('/api/admin', adminRoutes)
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
+99 -17
View File
@@ -63,15 +63,74 @@ async function loadLanguage(language: string) {
return loaded
}
function collectNodes(root: Node, type: string, result: Node[] = []) {
if (root.type === type) result.push(root)
for (const child of root.children) collectNodes(child, type, result)
type Match = (node: Node) => boolean
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
}
function hasNode(root: Node, type: string): boolean {
if (root.type === type) return true
return root.children.some((child) => hasNode(child, type))
function hasNode(root: Node, match: Match): boolean {
if (match(root)) return true
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) {
@@ -201,6 +260,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
if (!rule.keyword?.trim()) 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
@@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record<string, string> = {
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) {
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')
if (!fn) return false
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 取决于
// 有没有 using namespace std,两种都得认,所以末段也比一次
if (language === 'C++' && fn.type === 'qualified_identifier') {
@@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) {
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
// 会静默地永远失败
if (language === 'C++') {
return collectNodes(root, 'call_expression').filter((call) => {
return collectNodes(root, ofType('call_expression')).filter((call) => {
const fn = call.childForFieldName('function')
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
})
}
if (language !== 'Python') return []
return collectNodes(root, 'call').filter((call) => {
return collectNodes(root, ofType('call')).filter((call) => {
const fn = call.childForFieldName('function')
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 {
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) {
case 'must_exist_node':
return {
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
passed: hasNode(root, match),
}
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)
}
case 'count_node': {
const nodes = collectNodes(root, nodeType)
const nodes = collectNodes(root, match)
return withLines(
{
description: describeAstRule(rule, language),
@@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_use_operator':
return {
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
passed: hasNode(root, match),
}
case 'must_use_keyword':
return {
@@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_have_nesting': {
const outer = rule.outer ?? ''
const inner = rule.inner ?? ''
const outerType = astTargetNodeType(outer, language)
const innerType = astTargetNodeType(inner, language)
const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)))
const innerMatch = nodeMatch(inner, language)
const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch)))
return { description: describeAstRule(rule, language), passed }
}
default:
+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 ProblemProgress,
type ProblemTypeFilter,
type SimilarProblem,
type Tag,
} from '@oj2/contract'
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) => {
const [target] = await db
.select({ id: schema.problem.id })
.select({ id: schema.problem.id, difficulty: schema.problem.difficulty })
.from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
.limit(1)
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))
if (targetTags.length === 0) return success(c, [])
// 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面
// 只是为了「共同的标签」那行先写练的是什么
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) 再在内存里筛,刷题多的
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
@@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const solvedIds = states ? [...states.solved] : []
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
// 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 difficultyGap = sql`abs(${difficultyRank} - ${sql.raw(String(rankOf(target.difficulty)))})`
const sharedTags = count(schema.problemTags.problemtagId)
const rows = await db
.select({
@@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
)
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
// 末尾的 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)
const ids = rows.map((row) => row.problem.id)
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
return success(
c,
rows.map((row) => listItem(row, tags, statuses, states, counts)),
rows.map((row) => {
const own = new Set(tags.get(row.problem.id))
return { ...listItem(row, tags, statuses, states, counts), sharedTags: targetTags.map((tag) => tag.name).filter((name) => own.has(name)) }
}) satisfies SimilarProblem[],
)
})
+12 -7
View File
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{
key: 'accepted_count',
name: '做对的题数',
helpText: '去重后通过的题目数量(不含比赛)',
helpText: '去重后通过的题目数量(含比赛题)',
},
{
key: 'mid_ac_count',
name: '做对的中等题',
helpText: '去重后通过的中等难度题目数(不含比赛)',
helpText: '去重后通过的中等难度题目数(含比赛题)',
},
{
key: 'hard_ac_count',
name: '做对的困难题',
helpText: '去重后通过的困难题目数(不含比赛)',
helpText: '去重后通过的困难题目数(含比赛题)',
},
{
key: 'submission_count',
name: '提交次数',
helpText: '提交次数(不含比赛)',
helpText: '提交次数(含比赛)',
},
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
{
@@ -45,13 +45,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{
key: 'max_ac_week_streak',
name: '连续有新做对的周数',
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)',
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
},
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
{
key: 'contest_joined',
name: '参加比赛',
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
helpText: '交过题的比赛数量',
},
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
{
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
name: '做完的专题',
helpText: '完成的专题数量',
},
{
key: 'challenge_count',
name: '挑战成功',
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
},
{
key: 'first_try_ac_count',
name: '一次就对的题',
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{
key: 'max_code_lines',
name: '最长的代码行数',
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)',
helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
},
{
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 { publishAchievementNotification } from '../events'
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
/**
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
* 参加过几场比赛、挑战成功几次也在这里一起数。
*
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
* 一个人的提交也就几百条。
*
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
*/
async function solvedCounts(tx: DbOrTx, userId: number) {
const [row] = await tx.execute<{
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
mid: number
hard: number
first_try: number
contests: number
challenges: number
}>(
sql`
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
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
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 distinct on (problem_id) result from mine order by problem_id, create_time, id
) 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
`,
)
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))
.where(eq(schema.submission.id, submissionId))
.limit(1)
if (!row || row.submission.contestId !== null) return []
if (!row) return []
const rejudged = countedAs !== null
const accepted = isAccepted(row.submission.result)
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
value.mid_ac_count = solved.mid
value.hard_ac_count = solved.hard
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 (firstAc) {
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
lt(schema.submission.createTime, row.submission.createTime),
),
)
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
// contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
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 浅合并,
* 其余指标不动,写法同 refreshContestJoinedForAll)。
*
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
*/
async function refreshAcWeekStreakForAll() {
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
.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)
.as('first_accepted')
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
+10
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import UserName from 'shared/components/UserName.vue'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
import ClassCard from './components/ClassCard.vue'
@@ -59,6 +60,15 @@ async function load() {
onMounted(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>
<template>
+6
View File
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
unit: '道',
remain: (r) => `再做对 ${r} 道困难题`,
},
challenge_count: {
group: '做题',
label: '挑战成功',
unit: '次',
remain: (r) => `再挑战成功 ${r} 次`,
},
max_ac_week_streak: {
group: '坚持',
label: '连着几周有做对',
+4
View File
@@ -5,6 +5,7 @@ import AdminLink from 'shared/components/AdminLink.vue'
import NotFound from 'shared/components/NotFound.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api'
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { Announcement, AnnouncementListItem } from 'utils/types'
@@ -27,6 +28,9 @@ const { isDesktop } = useBreakpoints()
const announcements = ref<AnnouncementListItem[]>([])
const loaded = ref(false)
// 看板娘:只念叨一句,不给按钮
useMascotGuide(() => ({ situation: 'announcement' }))
onMounted(async () => {
const res = await getAnnouncementList(0, LIMIT)
announcements.value = res.results
+4 -1
View File
@@ -41,6 +41,7 @@ import {
type ProblemRank,
type CreateSubmissionResponse,
type ProblemListItem,
type SimilarProblem,
type ProblemStats,
type ProblemProgress,
type CreateFlowchartResponse,
@@ -412,7 +413,9 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
// ==================== 相似题目推荐 ====================
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 { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { ContestStatus } from 'utils/constants'
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}`)
}
// 看板娘:有正在比的就带进去
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) {
const mine = contest.mine
if (!mine) return ''
+87 -3
View File
@@ -5,12 +5,13 @@ import { Icon } from '@iconify/vue'
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
import { getClassActivity } from 'shared/api'
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 { 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 { 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 {
type: TutorialType
@@ -36,6 +37,8 @@ const classActivity = ref<ClassActivity | null>(null)
const lastVisit = ref<LastVisit | null>(null)
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
const knowledgeHeadline = ref<Headline | null>(null)
/** 地图原样留着给看板娘:她要知识点名、档位名、还差几道,不是拼好的那句话 */
const knowledge = ref<KnowledgeMap | null>(null)
const loaded = ref(false)
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
@@ -123,7 +126,8 @@ async function loadAnnouncements() {
}
async function loadKnowledge() {
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap())
knowledge.value = await getKnowledgeMap()
knowledgeHeadline.value = pickHeadline(knowledge.value)
}
async function loadClassActivity() {
@@ -244,6 +248,86 @@ watch(
{ 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) {
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 { classLabel } from 'utils/classes'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/**
@@ -66,6 +67,32 @@ function openTask(row: ClassRow) {
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(() =>
[
{ 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 AdminLink from 'shared/components/AdminLink.vue'
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 TutorialRow from './components/TutorialRow.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
}
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 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)
onMounted(load)
</script>
+11
View File
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
import TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue'
@@ -233,6 +234,16 @@ function jump(id: string) {
function jumpExercise(id: number) {
jump(`exercise-${id}`)
}
// 看板娘:练一练没做完就带去第一道没做对的;做完了(或这课没有练一练)就去下一课
useMascotGuide(() => {
if (!tutorial.value || userStore.isTeacherOrAbove) return null
const to = next.value?.to
const nextLesson: MascotAction[] = to ? [{ label: '下一课', run: () => router.push(to) }] : []
const unsolved = firstUnsolved.value
if (!unsolved) return { situation: 'tutorial.passed', actions: nextLesson }
return { situation: 'tutorial', actions: [{ label: '练一练', run: () => jumpExercise(unsolved.id) }, ...nextLesson] }
})
</script>
<template>
+5
View File
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
import { useAIStream } from 'shared/composables/aiStream'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import PkDuel from './components/PkDuel.vue'
import PkGrid from './components/PkGrid.vue'
import PkRace from './components/PkRace.vue'
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
const MAX_CLASSES = 8
const teacher = computed(() => userStore.isTeacherOrAbove)
// 看板娘:学生念叨一句班级对决,老师只打招呼
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
const pk = ref<ClassPk | null>(null)
const loading = ref(false)
const battle = ref<ClassBattleItem[]>([])
@@ -71,8 +71,8 @@ const label = computed(() => {
}
.block {
width: 20px;
height: 20px;
width: 22px;
height: 22px;
border-radius: 4px;
box-sizing: border-box;
display: inline-flex;
@@ -6,6 +6,7 @@ import { applyFixes, clearErrorMark, currentCode, showErrorMark, showPunctuation
import { explainCCompileError } from 'oj/problem/utils/cError'
import { explainPythonCompileError } from 'utils/pythonError'
import { stripJudgePath } from 'utils/judge'
import SectionHead from './SectionHead.vue'
/**
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
@@ -80,17 +81,17 @@ onUnmounted(clearErrorMark)
</script>
<template>
<div class="explain-card">
<!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明;那一行编辑器里已经标红了 -->
<div class="explain-section">
<template v-if="explanation">
<p class="explain">
<b v-if="explanation.line">第 {{ explanation.line }} 行:</b>{{ explanation.message }}
</p>
<SectionHead icon="problem" tone="amber">{{ explanation.line ? `第 ${explanation.line} 行` : '代码的格式写错了' }}</SectionHead>
<p class="explain">{{ explanation.message }}</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-text v-else-if="fixedCount" type="success"> 换好了 {{ fixedCount }} 处,再提交一次试试。 </n-text>
</div>
<n-collapse>
<n-collapse class="raw-collapse">
<n-collapse-item title="原始报错(英文)" name="raw">
<div class="raw">{{ rawError }}</div>
</n-collapse-item>
@@ -98,6 +99,7 @@ onUnmounted(clearErrorMark)
</template>
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
<template v-else>
<SectionHead icon="problem" tone="amber">代码的格式写错了</SectionHead>
<p class="explain">请仔细检查,看看代码的格式是不是写错了!</p>
<div class="raw">{{ rawError }}</div>
</template>
@@ -105,20 +107,15 @@ onUnmounted(clearErrorMark)
</template>
<style scoped>
/* 和答错的说明同一种浅灰卡片(设计稿「语法没过」) */
.explain-card {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind('theme.dividerColor');
.explain-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.explain {
margin: 0;
line-height: 1.7;
margin: 0 0 10px;
font-size: 16px;
line-height: 1.75;
}
.explain.other {
@@ -126,6 +123,10 @@ onUnmounted(clearErrorMark)
color: v-bind('theme.textColor2');
}
.fix {
margin-bottom: 10px;
}
.raw {
white-space: pre;
overflow-x: auto;
+232 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { storeToRefs } from 'pinia'
import type { RouteLocationRaw } from 'vue-router'
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
@@ -33,15 +34,16 @@ const ctx = useProblemPageContext()
const { isMobile } = useBreakpoints()
const theme = useThemeVars()
const isDark = useDark()
/** 做完的实心绿圈里的勾:暗色下主题绿很亮,白勾看不清 */
const doneDotText = computed(() => (isDark.value ? 'rgba(0, 0, 0, 0.85)' : '#fff'))
/**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、专题浅蓝、比赛浅黄。
* 课堂、比赛在电脑上是左边的竖栏(课堂浅绿底、比赛浅黄底),专题是一条不带底色的细横条;
* 手机上三种都是横条(课堂浅绿、比赛浅黄)。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/
const tone = computed(() => ({
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
setText: isDark.value ? '#8fb4ee' : '#1d4f9c',
setBar: isDark.value ? '#6f9be0' : '#2f6bc4',
contestText: isDark.value ? '#f2c97d' : '#8a5200',
}))
const userStore = useUserStore()
@@ -241,8 +243,35 @@ onSolvedHere(() => {
</script>
<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"
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
>
@@ -356,7 +385,37 @@ onSolvedHere(() => {
</component>
</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">
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
@@ -371,6 +430,165 @@ onSolvedHere(() => {
</template>
<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 {
height: 44px;
flex: none;
@@ -390,10 +608,12 @@ onSolvedHere(() => {
border-bottom-color: rgba(24, 160, 88, 0.18);
}
/* 专题:不带底色的一条细横条(设计稿「题目页观感」合一版)。专题可以有几十道题、还有「加入专题」,
竖栏放不下,留在页签行上面 */
.problemset {
height: 40px;
gap: 10px;
background-color: rgba(47, 107, 196, 0.06);
border-bottom-color: rgba(47, 107, 196, 0.16);
border-bottom-color: v-bind('theme.dividerColor');
}
.contest {
@@ -437,7 +657,7 @@ onSolvedHere(() => {
}
.set-back {
color: v-bind('tone.setText');
color: v-bind('theme.textColor1');
}
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
@@ -452,14 +672,14 @@ onSolvedHere(() => {
width: 110px;
height: 6px;
border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16);
background-color: rgba(128, 128, 128, 0.16);
overflow: hidden;
}
.set-progress-fill {
height: 100%;
border-radius: 3px;
background-color: v-bind('tone.setBar');
background-color: v-bind('theme.primaryColor');
}
.countdown {
@@ -503,7 +723,7 @@ onSolvedHere(() => {
}
.problemset .step.forward {
color: v-bind('tone.setText');
color: v-bind('theme.textColor1');
}
.step:not(.disabled):hover {
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia'
import { useCodeStore } from 'oj/store/code'
@@ -29,7 +31,7 @@ function fillSample(input: string) {
<template>
<!-- 设计稿「自己输入:顶替原来的『自测』模式」;「这里不判对错」在分段那一行右边(ResultPane) -->
<div class="custom">
<span class="label">输入</span>
<SectionHead icon="input">输入</SectionHead>
<n-input
v-model:value="customInput"
type="textarea"
@@ -38,15 +40,16 @@ function fillSample(input: string) {
class="mono"
/>
<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 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 }}
</n-button>
</div>
<span class="label">输出</span>
<SectionHead icon="output" class="output-head">输出</SectionHead>
<TrialErrorNote
v-if="customResult !== null && customResult !== SubmissionStatus.accepted"
:result="customResult"
@@ -68,13 +71,14 @@ function fillSample(input: string) {
gap: 8px;
}
.label {
font-size: 13px;
font-weight: 600;
.output-head {
margin-top: 14px;
}
.mono :deep(textarea) {
font-family: Consolas, Monaco, monospace;
font-size: 16px;
line-height: 24px;
}
.actions {
@@ -85,13 +89,12 @@ function fillSample(input: string) {
}
.fill {
font-size: 13px;
color: v-bind('theme.textColor2');
font-size: 14px;
}
.output {
margin: 0;
min-height: 88px;
min-height: 60px;
box-sizing: border-box;
padding: 10px 10px;
border-radius: 6px;
@@ -99,7 +102,9 @@ function fillSample(input: string) {
white-space: pre-wrap;
word-break: break-all;
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 {
@@ -182,7 +182,9 @@ const changeLanguage = (v: LANGUAGE) => {
<n-select
v-if="!drawing"
v-model:value="codeStore.code.language"
class="language"
style="width: 120px"
:bordered="false"
:size="buttonSize"
:options="languageOptions"
:disabled="showCollabBar"
@@ -190,15 +192,19 @@ const changeLanguage = (v: LANGUAGE) => {
/>
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
<span v-if="!drawing" class="divider" aria-hidden="true" />
<!-- 浅绿底:比实心的「提交」轻一档,又比旁边不带框的按钮重(设计稿「题目页观感」合一版) -->
<n-button
v-if="canRunSamples && isDesktop"
:size="buttonSize"
type="primary"
ghost
secondary
:loading="samplesRunning"
:disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()"
>
<template #icon><Icon icon="ph:play-fill" /></template>
运行例子
</n-button>
@@ -215,22 +221,26 @@ const changeLanguage = (v: LANGUAGE) => {
:size="buttonSize"
:type="helpButtonType"
:secondary="teacherIn || collabStore.helpStatus !== 'idle'"
:quaternary="!teacherIn && collabStore.helpStatus === 'idle'"
:title="helpButtonTitle"
class="help"
:class="{ busy: teacherIn }"
@click="toggleHelp"
>
<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 }}
</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-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>
</div>
</template>
@@ -288,6 +298,23 @@ const changeLanguage = (v: LANGUAGE) => {
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 {
cursor: default;
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import SectionHead from './SectionHead.vue'
import { useThemeVars } from 'naive-ui'
import { useLessonStore } from 'shared/store/lesson'
import ChallengeList from './ChallengeList.vue'
@@ -44,16 +45,17 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</script>
<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">
<div class="text">
<span class="headline">{{ label }}还剩 {{ lesson.remaining }} 道</span>
<span class="detail">
{{ lesson.next.problemDisplayId }}
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}
</span>
</div>
<p class="text">
{{ label }}还剩 {{ lesson.remaining }} 道,下一道 {{ lesson.next.problemDisplayId }}
{{ lesson.next.myStatus === 'tried' ? '你做过,还没对' : '你还没做过' }}。
</p>
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
<span class="next-label">下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span>
<svg
@@ -72,15 +74,12 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
</router-link>
</template>
<template v-else>
<div class="text">
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span v-if="!challenges" class="detail">想接着练,可以去专题里挑一个</span>
</div>
<p class="text">{{ label }}都做完了,一共 {{ lesson.total }} 道。<template v-if="!challenges">想接着练,可以去专题里挑一个。</template></p>
<router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去专题继续练</span>
</router-link>
</template>
</div>
</section>
<!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
<template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
@@ -90,30 +89,15 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
<style scoped>
.lesson-next {
margin-top: 12px;
padding: 16px 18px;
border-radius: 8px;
background-color: rgba(24, 160, 88, 0.07);
border: 1px solid rgba(24, 160, 88, 0.25);
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 12px;
}
.text {
display: flex;
flex-direction: column;
gap: 4px;
}
.headline {
font-size: 15px;
font-weight: 600;
}
.detail {
font-size: 13px;
color: v-bind('theme.textColor2');
margin: 0 0 12px;
font-size: 16px;
line-height: 1.75;
}
.next {
@@ -125,10 +109,10 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
gap: 8px;
height: 44px;
padding: 0 20px;
border-radius: 6px;
border-radius: 8px;
background-color: v-bind('theme.primaryColor');
color: v-bind('buttonText');
font-size: 15px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
}
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem'
import { DIFFICULTY } from 'utils/constants'
import { copyToClipboard, getTagColor } from 'utils/functions'
import { getTagColor } from 'utils/functions'
import { useSubmissionStore } from 'oj/store/submission'
import Markdown from 'shared/components/Markdown.vue'
import SqlProblemData from './SqlProblemData.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 { useMyFlowchartStore } from 'shared/store/myFlowchart'
import { useUserStore } from 'shared/store/user'
@@ -83,21 +87,6 @@ const flowchartGrade = computed(() => {
})
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 没有 —— 原来要先切到「流程图」这门「语言」才查。
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
watch(
@@ -108,141 +97,96 @@ watch(
{ immediate: true },
)
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位),
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
// 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? [])
const submissionStore = useSubmissionStore()
const SAMPLE_SIDES = ['input', 'output'] as const
const SAMPLE_SIDE_LABEL = { input: '输入', output: '输出' } as const
/*
* 例子默认摆成「例子 N | 输入 | 输出」的表,可机房那两种屏上左栏只有 550px 左右,每格只放得下
* 二十来个字符,而例子输出最长那行的 p90 就有 28 个字符宽(中文按两个算)—— 一成的题会在
* 半格里横着滚。所以按这道题最长那行量一下:半格放得下就摆表,放不下这道题的例子就整体改成
* 上下排(输入一行、输出一行,各占满宽)。整宽还放不下的二十来道才横着滚。不自动折行:折出来
* 的行学生分不清是不是真换了行,照着它输出就错了。
* 下面几个尺寸 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)
/** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 utils/statement) */
const statement = computed(() => ({
description: tidyStatement(problem.value?.description),
input: tidyStatement(problem.value?.inputDescription),
output: tidyStatement(problem.value?.outputDescription),
hint: tidyStatement(problem.value?.hint),
}))
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
// astRequirementSchema。规则原文(engine / target)不下发给学生。
const KIND_TAG_TYPE = {
require: 'success',
forbid: 'error',
count: 'info',
// 每条规则前面一个小图标说是哪一类,规则的字照后端给的原样写(里面已经有「必须 / 不能」)
const KIND_ICON = {
require: 'ph:check-circle-fill',
forbid: 'ph:prohibit-fill',
count: 'ph:hash-bold',
} as const
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
</script>
<template>
<div v-if="problem" class="problem-content">
<!-- 题号、题名、难度和状态一行(设计稿「题目页观感」定稿)。题名太长折到下一行,标签留在右边 -->
<header class="problem-head">
<div class="title-row">
<span class="pid">{{ problem._id }}</span>
<h1 class="problem-title">{{ problem.title }}</h1>
</div>
<div class="meta">
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }}
</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 的,代码交过没对照写;没交过代码就不写
「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
<template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag>
<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>
<n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div>
</header>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
<div v-if="astRequirements.length > 0" class="requirements">
<span class="requirements-head">语法要求</span>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
<section v-if="astRequirements.length > 0" class="block">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<template v-for="[lang, rules] in astRequirements" :key="lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
<n-tag v-for="(rule, i) in rules" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
{{ rule.description }}
</n-tag>
<div v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</div>
<ul class="rules">
<li v-for="(rule, i) in rules" :key="`${lang}-${i}`" :class="rule.kind">
<Icon :icon="KIND_ICON[rule.kind]" :width="16" class="rule-icon" aria-hidden="true" />
<span>{{ rule.description }}</span>
</li>
</ul>
</template>
<span class="requirements-note">提交时会逐条检查</span>
</div>
<section class="block">
<h3 class="title">描述</h3>
<Markdown html :content="problem.description" />
</section>
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 -->
<div v-if="!isSQL" class="io">
<section class="block">
<h3 class="title">输入</h3>
<Markdown html :content="problem.inputDescription" />
<SectionHead icon="description">描述</SectionHead>
<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 class="block">
<h3 class="title">输出</h3>
<Markdown html :content="problem.outputDescription" />
<SectionHead icon="output">输出</SectionHead>
<Markdown html :content="statement.output" />
</section>
</div>
</template>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
<div class="block-head">
<h3 class="title">你画的流程图</h3>
<n-button text type="primary" size="small" @click="myFlowchartZoom = true"> 放大看 </n-button>
<n-button text type="primary" size="small" @click="myFlowchartOpen = !myFlowchartOpen">
<SectionHead icon="flowchart">
你画的流程图
<template #extra>
<n-button text type="primary" @click="myFlowchartZoom = true">放大看 ›</n-button>
<n-button text type="primary" @click="myFlowchartOpen = !myFlowchartOpen">
{{ myFlowchartOpen ? '收起' : '展开' }}
</n-button>
</div>
</template>
</SectionHead>
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
<MyFlowchart compact />
</div>
@@ -251,49 +195,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal>
</section>
<template v-if="!isSQL">
<!--
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」
-->
<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>
<section v-if="!isSQL && samples.length" class="block">
<SectionHead icon="samples">
例子
<template #extra>
<n-button text type="primary" @click="submissionStore.revealResult('custom')">自己输入数据试试 ›</n-button>
</template>
</div>
</div>
</SectionHead>
<SampleList :samples="samples" />
</section>
</template>
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
<template #title="{ text }">
<h3 class="title sql-title">{{ text }}</h3>
<SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
</template>
</SqlProblemData>
@@ -302,10 +216,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
-->
<section v-if="canShowReferenceFlowchart" class="block">
<div class="block-head">
<h3 class="title">参考流程图</h3>
<n-button text type="primary" size="small" class="block-link" @click="referenceZoom = true"> 放大看 </n-button>
</div>
<SectionHead icon="flowchart">
参考流程图
<template #extra>
<n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
</template>
</SectionHead>
<div
class="reference-flowchart"
role="button"
@@ -323,268 +239,158 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal>
</section>
<section v-if="problem.hint" class="block">
<h3 class="title">提示</h3>
<Markdown html :content="problem.hint" />
<section v-if="statement.hint" class="block">
<SectionHead icon="hint" tone="amber">提示</SectionHead>
<Markdown html :content="statement.hint" />
</section>
<section v-if="problem.source" class="block">
<h3 class="title">来源</h3>
<Markdown html :content="problem.source" />
</section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
<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>
</template>
<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 {
container: problem-content / inline-size;
}
.block-link {
margin-left: auto;
}
/*
* 小节标题(描述 / 输入 / 输出 / 例子 / 提示……)。画板上是 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);
/* 题号 | 题名 | ……… | 难度 状态 */
.problem-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
font-size: 13px;
align-items: baseline;
gap: 4px 10px;
}
.requirements-head {
font-weight: 600;
}
.requirements-note {
.pid {
flex: none;
font-size: 16px;
font-variant-numeric: tabular-nums;
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;
}
/* 参考流程图:和例子的格子同一种浅灰底 */
.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 {
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);
padding: 16px 12px;
}
.my-flowchart {
cursor: zoom-in;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 8px;
}
/*
* 例子:例子 N | 输入 | 输出,每格右边一个「复制」。
* 放不下时(.stacked)每个例子两行:例子 N | 输入 | 那格,下一行 输出 | 那格
*/
.sample-table {
.source {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid v-bind('theme.dividerColor');
display: flex;
flex-direction: column;
gap: 6px;
}
.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;
align-items: baseline;
gap: 8px;
font-size: 13px;
color: v-bind('theme.textColor3');
}
.sample-no {
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 {
.source-label {
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 {
background-color: rgba(24, 160, 88, 0.1);
}
.sql-title {
margin-top: 14px;
.source-text {
min-width: 0;
font-size: 13px;
line-height: 1.6;
}
</style>
@@ -101,8 +101,8 @@ const color = computed(() => {
.result-header {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: 18px;
}
.line {
@@ -114,8 +114,8 @@ const color = computed(() => {
.badge {
flex: none;
width: 24px;
height: 24px;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
@@ -125,12 +125,14 @@ const color = computed(() => {
}
.title {
font-size: 18px;
font-size: 22px;
line-height: 1.3;
font-weight: 700;
color: v-bind('color.text');
}
.sub {
font-size: 15px;
color: v-bind('theme.textColor3');
}
@@ -74,8 +74,9 @@ function pick(segment: Segment) {
<template>
<FlowchartResult v-if="drawing" />
<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
v-for="segment in segments"
:key="segment.value"
@@ -86,8 +87,9 @@ function pick(segment: Segment) {
:aria-selected="isActive(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>
</div>
<span v-if="resultSegment === 'custom'" class="segment-note">自己编数据试试,这里不判对错</span>
</div>
@@ -116,35 +118,53 @@ function pick(segment: Segment) {
padding-bottom: 16px;
}
/* 设计稿「题目页观感」结果 X:浅灰底的分段按钮,选中的白底加粗(和工具栏「写代码 / 画流程图」一个样子) */
.segments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
align-items: center;
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 {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 15px;
background-color: rgba(128, 128, 128, 0.12);
border-radius: 6px;
background: transparent;
color: v-bind('theme.textColor2');
font: inherit;
font-size: 13px;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
}
.segment-time {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: v-bind('theme.textColor3');
}
.segment-note {
margin-left: auto;
align-self: center;
font-size: 13px;
color: v-bind('theme.textColor3');
}
.segment.active {
background-color: v-bind('theme.textColor1');
color: v-bind('theme.cardColor');
background-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 {
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { clearErrorMark, showErrorMark } from 'oj/problem/utils/errorMark'
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<{
@@ -34,34 +35,50 @@ onUnmounted(clearErrorMark)
</script>
<template>
<n-card embedded class="explain-card">
<n-flex vertical :size="12">
<div class="explain">
<b v-if="info.line">第 {{ info.line }} 行运行时出错:</b>{{ message }}
<!-- 设计稿「题目页观感」结果 X:黄色小节标题说第几行,正文大小的中文说明,再把那一行代码摆出来 -->
<div class="explain-section">
<SectionHead icon="problem" tone="amber">{{ info.line ? `第 ${info.line} 行出错` : '程序跑到一半出错了' }}</SectionHead>
<p class="explain">{{ message }}</p>
<div v-if="sourceLine" class="explain-code">
<span class="line-no">{{ info.line }}</span>
<pre>{{ sourceLine }}</pre>
</div>
</div>
<pre v-if="sourceLine" class="explain-code">{{ sourceLine }}</pre>
</n-flex>
</n-card>
</template>
<style scoped>
/* 结果弹窗不限宽,长句子不折行会把弹窗撑出屏幕 */
.explain-card {
max-width: 560px;
}
.explain {
margin: 0 0 10px;
font-size: 16px;
line-height: 1.7;
line-height: 1.75;
}
/* 出错的那一行:红底,前面写行号,和编辑器里标红的那一行对得上 */
.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;
padding: 8px 12px;
border-radius: 4px;
font-size: 15px;
font: inherit;
white-space: pre-wrap;
word-break: break-all;
background-color: rgba(208, 48, 80, 0.12);
}
</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>
<template v-if="current">
<!-- 对上了:三栏摆出来就行 -->
<!-- 对上了:输入、正确输出、你的输出竖着摆出来就行,你的输出也是绿的 -->
<div v-if="current.result === SubmissionStatus.accepted" class="passed-card">
<div class="blocks">
<div class="block">
<div class="label">输入</div>
<pre>{{ current.input }}</pre>
</div>
<div class="block">
<div class="label">正确输出</div>
<pre>{{ current.expected }}</pre>
</div>
<div class="block">
<div class="label">你的输出</div>
<pre>{{ current.output }}</pre>
</div>
</div>
<span class="label">输入</span>
<pre class="pane in">{{ current.input }}</pre>
<span class="label">正确输出</span>
<pre class="pane ok">{{ current.expected }}</pre>
<span class="label">你的输出</span>
<pre class="pane ok">{{ current.output }}</pre>
</div>
<!-- 跑完了、输出对不上:和判完之后同一套说明 -->
<WrongAnswerExplain
@@ -134,6 +126,7 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
:code="samplesCode?.value ?? ''"
:language="samplesCode?.language ?? ''"
:plain="inContest"
section="stack"
/>
<!-- 没跑完:编译不过、运行出错、超时 -->
<TrialErrorNote
@@ -165,15 +158,16 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
}
.run {
height: 26px;
height: 28px;
box-sizing: border-box;
padding: 0 10px;
border-radius: 13px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
@@ -189,56 +183,58 @@ const current = computed(() => sampleRuns.value[selected.value] ?? null)
color: v-bind('theme.errorColorPressed');
}
.run.active {
font-weight: 600;
border-width: 1.5px;
}
/* 选中的那个涂实心:设计稿「运行例子」 */
.run.ok.active {
border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: #fff;
}
.run.bad.active {
border-color: v-bind('theme.errorColor');
background-color: v-bind('theme.errorColor');
color: #fff;
}
/* 对上了:和答错时同一张两列的网格(标签 | 格子),只是你的输出也是绿的 */
.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;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.block {
min-width: 0;
padding: 6px 10px 8px;
border-radius: 5px;
background-color: v-bind('theme.cardColor');
border: 1px solid v-bind('theme.dividerColor');
grid-template-columns: 64px minmax(0, 1fr);
gap: 6px 8px;
align-items: start;
}
.label {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
font-size: 13px;
line-height: 36px;
white-space: nowrap;
color: v-bind('theme.textColor3');
}
.block pre {
.pane {
min-width: 0;
margin: 0;
padding: 5px 10px;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-all;
font-family: Monaco, Consolas, monospace;
font-size: 14px;
font-family: Consolas, Monaco, monospace;
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 {
margin: 12px 0 0;
margin: 16px 0 0;
font-size: 13px;
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">
import type { ProblemRow } from 'utils/types'
type SimilarRow = ProblemRow & { sharedTags: string[] }
/**
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
* 每个实例各一份
*/
const cache = new Map<string, Promise<ProblemRow[]>>()
const cache = new Map<string, Promise<SimilarRow[]>>()
</script>
<script setup lang="ts">
@@ -13,17 +15,28 @@ import { storeToRefs } from 'pinia'
import { useThemeVars } from 'naive-ui'
import { getSimilarProblems } from 'oj/api'
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 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里用题面的小节标题(SectionHead),结果页签里和「还能挑战」对齐。
*
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)。整行可点,题目名
* 和题目列表一样用正文色、不用主题绿:绿字细、在白底上发虚
*/
const props = defineProps<{ title: string }>()
const props = defineProps<{ title: string; place: 'content' | 'result' }>()
const theme = useThemeVars()
const tone = useTone()
const { problem } = storeToRefs(useProblemStore())
const list = ref<ProblemRow[]>([])
const list = ref<SimilarRow[]>([])
watch(
() => problem.value?._id,
@@ -41,30 +54,37 @@ watch(
{ immediate: true },
)
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High
function difficultyType(difficulty: string) {
if (difficulty === '简单') return 'success'
if (difficulty === '困难') return 'error'
return 'warning'
// 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
function difficultyStyle(row: SimilarRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
}
</script>
<template>
<section v-if="list.length" class="similar">
<h3 class="title">{{ props.title }}</h3>
<n-list bordered>
<n-list-item v-for="sp in list" :key="sp._id">
<n-flex align="center" justify="space-between" :wrap="false">
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link">
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag>
<span class="name">{{ sp.title }}</span>
<section v-if="list.length" class="similar" :class="props.place">
<SectionHead v-if="props.place === 'content'" icon="similar">{{ props.title }}</SectionHead>
<h3 v-else class="heading">{{ props.title }}</h3>
<div class="rows">
<router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
<span class="id">{{ sp._id }}</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>
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)">
{{ sp.difficulty }}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
</div>
</section>
</template>
@@ -73,34 +93,126 @@ function difficultyType(difficulty: string) {
margin-top: 20px;
}
.title {
font-size: 16px;
font-weight: 600;
.similar.result {
margin-top: 14px;
}
.heading {
margin: 0 0 8px;
}
/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */
.similar :deep(.n-list-item__main) {
min-width: 0;
/* 和结果页签里的「还能挑战」一样 */
.result .heading {
font-size: 15px;
font-weight: 700;
}
.link {
display: inline-flex;
.rows {
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;
gap: 8px;
min-width: 0;
color: inherit;
gap: 4px 12px;
padding: 10px 16px;
color: v-bind('theme.textColor1');
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;
}
.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 {
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;
text-overflow: ellipsis;
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>
@@ -33,8 +33,6 @@ const changedTables = computed(() => {
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
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>
<template>
@@ -62,7 +60,6 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
</template>
</div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend"><span class="swatch" aria-hidden="true"></span>执行后变了的格子</p>
</template>
<style scoped>
@@ -77,22 +74,4 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
opacity: 0.65;
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>
@@ -16,6 +16,7 @@ import RuntimeErrorExplain from './RuntimeErrorExplain.vue'
import WrongAnswerExplain from './WrongAnswerExplain.vue'
import LessonNext from './LessonNext.vue'
import ResultHeader from './ResultHeader.vue'
import SectionHead from './SectionHead.vue'
import SimilarProblems from './SimilarProblems.vue'
import ChallengeList from './ChallengeList.vue'
import ChallengeOutcome from './ChallengeOutcome.vue'
@@ -357,26 +358,34 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
:language="submission.language"
:refresh-key="submission.id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
</div>
</template>
<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" />
<RuntimeErrorExplain v-if="runtimeError" :info="runtimeError" :code="submission.code" />
<WrongAnswerExplain v-if="sampleCheck" full :check="sampleCheck" :code="submission.code" :language="submission.language" />
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
<n-flex vertical :size="8">
<n-flex v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i" align="center" :size="6">
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor">
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
</n-icon>
<WrongAnswerExplain v-if="sampleCheck" full section="stack" :check="sampleCheck" :code="submission.code" :language="submission.language" />
<!-- 语法要求逐条对没对:和题面的「语法要求」同一个黄色小节标题 -->
<section v-if="submission.statisticInfo?.ast_results?.length">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<ul class="ast-rules">
<li v-for="(rule, i) in submission.statisticInfo.ast_results" :key="i">
<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>
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
<span v-if="!rule.passed && rule.actual !== undefined" :style="{ color: theme.errorColor }"> 当前 {{ rule.actual }} 次 </span>
</n-flex>
</n-flex>
</n-card>
</li>
</ul>
</section>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
</n-flex>
@@ -386,7 +395,10 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
问过之后,AI 的回答才放进卡片
-->
<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')">
{{ customRunHint }}
</n-button>
@@ -425,7 +437,24 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
align-items: center;
flex-wrap: wrap;
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 高,好按 */
@@ -6,6 +6,7 @@ import { useProblemStore } from 'oj/store/problem'
import { markdownToText } from 'oj/problem/utils/plainText'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from 'utils/wrongAnswer'
import SectionHead from './SectionHead.vue'
/**
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
@@ -31,6 +32,15 @@ const props = defineProps<{
* 提示句全是对学生说的第二人称,整句换掉人称就读得通
*/
peer?: boolean
/**
* 新的样子(设计稿「题目页观感」结果 X):黄色小节标题说哪里不对,说明用正文大小,
* 输入 / 正确输出 / 你的输出是灰、绿、红三格。
* - stack:三格竖着排,和题面的例子一个样子。题目页的结果页签用(左栏只有 500 多宽)
* - row:三格横着并排,正确输出和你的输出一行对一行,不一样的那行两边都垫一层浅黄。
* 提交详情页用(一栏 1200 宽,多行输出对照着看);放不下(窄于 600,比如手机)就退回竖排
* 不给的话是原来那张浅灰卡片
*/
section?: 'stack' | 'row'
}>()
const theme = useThemeVars()
@@ -114,39 +124,48 @@ function lines(text: string) {
</script>
<template>
<div class="explain-card">
<p v-if="check.passed" class="explain">题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。</p>
<template v-else>
<p class="explain">
<div :class="section ? ['explain-section', section] : 'explain-card'">
<template v-if="section">
<SectionHead icon="problem" tone="amber">
{{ 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
><template v-if="!plain">{{ hint }}</template>
</p>
<template v-if="!check.passed">
<div class="blocks">
<div class="block">
<div class="block in">
<div class="label">输入</div>
<div class="text">
<div v-for="(line, i) in (check.input ?? '').split('\n')" :key="i" class="line" v-text="line" />
</div>
</div>
<div class="block">
<div class="block ok">
<div class="label">正确输出</div>
<div class="text">
<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" />
</div>
<div v-else class="line" v-text="line" />
<div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template>
</div>
</div>
<div class="block">
<div class="block bad">
<div class="label">{{ peer ? '学生' : '你' }}的输出</div>
<div v-if="check.output" class="text">
<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" />
</div>
<div v-else class="line" v-text="line" />
<div v-else class="line" :class="{ hit: i === diffLine }" v-text="line" />
</template>
</div>
<div v-else class="text empty">(什么都没有输出)</div>
@@ -241,4 +260,145 @@ function lines(text: string) {
color: v-bind('theme.textColor2');
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>
@@ -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,缓存后供重复调用复用
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> {
return new Promise((resolve, reject) => {
const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const img = new Image()
img.onload = async () => {
const renderSize = 64
const baseUnit = 10
const canvas = document.createElement('canvas')
canvas.width = renderSize
canvas.height = renderSize
canvas.width = EMOJI_PX
canvas.height = EMOJI_PX
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, renderSize, renderSize)
ctx.drawImage(img, 0, 0, EMOJI_PX, EMOJI_PX)
URL.revokeObjectURL(url)
try {
const bitmap = await createImageBitmap(canvas)
const scale = baseUnit / renderSize
resolve({
type: '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)
} catch (e) {
reject(e)
@@ -415,7 +423,8 @@ export function useFireworks() {
const options: confetti.Options = {
shapes: emojiShapes,
flat: true,
scalar: 5,
// 大小在位图里(EMOJI_PX),这里放大的话填的矩形跟着放大,见 EMOJI_PX
scalar: 1,
particleCount: 16,
spread: 360,
startVelocity: 40,
+56 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import { errorCode } from 'utils/api'
import { getProblem } from 'oj/api'
@@ -19,6 +20,7 @@ import ContextBar from './components/ContextBar.vue'
import { useEditorMenu } from './composables/editorMenu'
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
import { useProblemMascotGuide } from './composables/mascotGuide'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
@@ -120,6 +122,13 @@ watch(
)
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) {
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 接受渲染函数
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
@@ -306,7 +322,7 @@ onBeforeUnmount(() => {
</div>
<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)">
{{ drawerLabel(key) }}
<Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
</button>
</div>
</div>
@@ -409,26 +425,46 @@ onBeforeUnmount(() => {
cursor: col-resize;
}
/*
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
*/
.left-pane {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
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 {
height: 100%;
}
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮。
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个按钮(图标 + 字,不带框)。
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
差 6px 的话两条下边线在分隔线处错成一个台阶 */
.tab-row {
flex: none;
height: 48px;
box-sizing: border-box;
padding: 0 20px 0 12px;
padding: 0 10px 0 8px;
display: flex;
align-items: center;
gap: 2px;
@@ -460,24 +496,32 @@ onBeforeUnmount(() => {
.drawer-buttons {
margin-left: auto;
display: flex;
gap: 4px;
gap: 2px;
}
.drawer-button {
height: 32px;
padding: 0 11px;
border: 1px solid v-bind('theme.borderColor');
border-radius: 16px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit;
font-size: 13px;
font-size: 14px;
color: v-bind('theme.textColor2');
white-space: nowrap;
cursor: pointer;
}
.drawer-button svg {
color: v-bind('theme.textColor3');
}
.drawer-button:hover {
border-color: v-bind('theme.primaryColorHover');
color: v-bind('theme.primaryColor');
background-color: rgba(128, 128, 128, 0.1);
color: v-bind('theme.textColor1');
}
.pane-body {
+13
View File
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import ProblemStatus from './components/ProblemStatus.vue'
import ProblemTypeTag from './components/ProblemTypeTag.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 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 { useAuthModalStore } from 'shared/store/authModal'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { useFireworks } from '../problem/composables/useFireworks'
import NotFound from 'shared/components/NotFound.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) */
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
+11
View File
@@ -3,6 +3,7 @@ import { useThemeVars } from 'naive-ui'
import AdminLink from 'shared/components/AdminLink.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { getProblemSetList } from 'oj/api'
import { rgba, useTone } from 'shared/composables/tone'
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) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : ''
+18
View File
@@ -7,6 +7,7 @@ import UserName from 'shared/components/UserName.vue'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api'
import { displayName } from '@oj2/contract'
import { STORAGE_KEY } from 'utils/constants'
@@ -271,6 +272,23 @@ function openDetail(className: string) {
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) {
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)。用户说「颜色可以保留」:你绿、前一名蓝、
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useRouteQuery } from '@vueuse/router'
import { useMascotGuide } from 'shared/mascot/guide'
import StatisticsView from './components/StatisticsView.vue'
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 to = useRouteQuery<string>('to', '', { mode: 'replace' })
useMascotGuide(() => ({ situation: 'teacher.statistics' }))
function openList(query: Record<string, string>) {
window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
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<{
row: SubmissionListItem
/** 按 ↑ ↓ 会落到的上一条、下一条:这条加载完顺手把它们也拉回来,换条时不用等网络 */
neighbors?: SubmissionListItem[]
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
teacher: boolean
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
@@ -61,7 +63,42 @@ const detail = ref<Submission | null>(null)
const loading = 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
@@ -78,15 +115,17 @@ async function load() {
const hit = cache.get(row.id)
if (hit && hit.result === row.result) {
detail.value = hit
prefetch()
return
}
loading.value = true
try {
const res = await getSubmission(row.id)
const res = await fetchDetail(row.id)
if (seq !== loadSeq) return
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
detail.value = res
prefetch()
} catch {
if (seq === loadSeq) failed.value = true
} finally {
@@ -310,7 +310,15 @@ function handleMore(key: string) {
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" />
</template>
@@ -35,6 +35,13 @@ export function useRowCursor(options: {
const index = navigable.value.findIndex((row) => row.id === selectedId.value)
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
@@ -125,5 +132,5 @@ export function useRowCursor(options: {
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">
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'
/** 单独打开的一条提交,主体在 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>
<template>
+16 -1
View File
@@ -11,6 +11,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { usePagination } from 'shared/composables/pagination'
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
import { parseTime } from 'utils/functions'
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,
total,
query,
@@ -427,6 +428,18 @@ const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
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(
() =>
[query.username, query.className, query.result, query.problem].filter(Boolean).length +
@@ -858,6 +871,7 @@ function dayBreak(index: number) {
<SubmissionPane
v-else
:row="selectedRow as SubmissionListItem"
:neighbors="neighbors as SubmissionListItem[]"
:teacher="teacher"
:class-name="query.className"
:contest="inContest"
@@ -893,6 +907,7 @@ function dayBreak(index: number) {
<SubmissionPane
v-else
:row="selectedRow as SubmissionListItem"
:neighbors="neighbors as SubmissionListItem[]"
:teacher="teacher"
:class-name="query.className"
:contest="inContest"
@@ -92,6 +92,9 @@ function openPicker() {
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true
}
// 看板娘气泡里的「选题」:课堂任务页没布置时替老师打开选题弹框
defineExpose({ openPicker })
function pickerConfirm(ids: string[]) {
lessonInput.value = ids.join(' ')
saveLesson()
+167 -1
View File
@@ -10,6 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
import { useTeacherHelp } from 'shared/composables/teacherHelp'
import { useCollabStore } from 'shared/store/collab'
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 { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
@@ -511,6 +513,161 @@ function submissionsHref(
}).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) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) {
@@ -578,7 +735,16 @@ function lastText(student: TaskBoardStudent) {
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start">
<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">
<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>
+8
View File
@@ -4,6 +4,7 @@ import { getProfileOverview } from 'oj/api'
import { getProfile } from 'shared/api'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import type { Profile } from 'utils/types'
import AchievementCard from './components/AchievementCard.vue'
import KnowledgeMap from './components/KnowledgeMap.vue'
@@ -17,6 +18,7 @@ import VersusCard from './components/VersusCard.vue'
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
*/
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
@@ -47,6 +49,12 @@ async function load() {
}
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>
<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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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