Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:06:49 +08:00
xuyueandClaude Opus 5.5 da360e2f68 docs(部署): 开发机上的 runner 改成注册在 xuyue 用户下,和 IOT_SIM 共用(多一个 iot-deploy 标签)
Deploy / deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:08:14 +08:00
xuyueandClaude Opus 5.5 3beb039dda fix(部署): 迁移没通过时把镜像名指回原来的,之后再起容器不会换上没迁移的新代码
Deploy / deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:24:06 +08:00
xuyueandClaude Opus 5.5 49643719b0 fix(部署): 开发机上编的后端到线上起不来(缺 libicu),改用官方 bun 编
Deploy / deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:17:36 +08:00
xuyueandClaude Opus 5.5 f1c4e89ec5 chore(部署): push origin 就由开发机编好传上去,不再绕道海外,晚上也是一分钟左右上线
Deploy / deploy (push) Failing after 37s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:08:03 +08:00
xuyueandClaude Opus 5.5 bcadab40b2 chore(部署): 同步到服务器少传约 20%,线路断了自动续传,不再一直卡着
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:50:42 +08:00
161 changed files with 12060 additions and 155 deletions

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物 → rsync 到服务器 →
# 服务器上跑 docker/deploy.sh --prebuilt 起栈。
#
# 跑在哪:开发机上的 gitea-runner(host 模式,标签 oj2-deploy),不在服务器上。
# - 服务器只有 2 核,编一次要 5 分钟左右、光前端就 160 秒,编的时候判题会排队。
# - 原来在 GitHub 上编,海外 → 服务器晚高峰只有十几 KB/s,rsync 一次要五六分钟;
# 开发机 → 服务器实测 5MB 约 1 秒。
# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑。
# runner 的装法和配置见 docs/deploy.md「部署 runner」。
#
# host 模式直接用开发机自己的东西:PATH 里的 bun、~/.ssh/config 里的 `debian`(root@服务器)
# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout
# (它要去 GitHub 拉 action,在国内反而是最慢的一步)。
#
# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows;GitHub 那份改成了只能手动
# 触发的备用路线(开发机不在时用),push 不会在两边各部署一次。
name: Deploy
on:
push:
branches:
- main
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
# 工作流判红。这是有意的:那种改动得有人先确认备份。
workflow_dispatch:
inputs:
allow_destructive:
description: "放行破坏性迁移(DROP TABLE / DROP COLUMN / ALTER TYPE …)。勾之前先确认已备份数据库。"
type: boolean
default: false
# 两次 push 挨得近时,别让两个 deploy.sh 在服务器上同时起栈。
# runner 的 capacity 也是 1,这里再保一道。
concurrency:
group: deploy
cancel-in-progress: false
env:
REMOTE: debian # 开发机 ~/.ssh/config 里的主机名
REMOTE_DIR: /root/OJDeploy/OJ2
jobs:
deploy:
runs-on: oj2-deploy
timeout-minutes: 20
steps:
# 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件
# (node_modules 留着,bun install 会对齐它)。
- name: 取代码
env:
SHA: ${{ gitea.sha }}
REPO: ${{ gitea.repository }}
run: |
git init -q .
git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA"
git checkout -q --force FETCH_HEAD
git clean -ffdxq -e node_modules
git log --oneline -1
# 单二进制把运行时一起打进去:这里用的 bun 是哪个,线上跑的就是哪个。
#
# ⚠️ **不能用开发机 PATH 里的 bun。** pacman 装的 bun 是 Arch 自己编的,动态链接系统的
# ICU(libicui18n.so.78),编出来的 oj2-api 到容器里就报找不到这个库 —— 2026-11-03
# 第一次跑就撞上了,好在迁移那步先炸、容器没动。官方发布的 bun 自带 ICU,
# 只依赖 libc,和 GitHub 上 setup-bun 装的是同一个东西。
#
# 所以这里按 packageManager 的版本下载官方二进制(核 sha256),缓存在
# ~/.cache/oj2-deploy/ 下,同一个版本只下一次;再把它放到 PATH 最前面,
# 后面的 bun install / bunx 都走它。
- name: 装 bun
run: |
ver=$(sed -nE 's/.*"packageManager": *"bun@([^"]+)".*/\1/p' package.json)
[ -n "$ver" ] || { echo "::error::package.json 里读不到 packageManager 的 bun 版本"; exit 1; }
dir="$HOME/.cache/oj2-deploy/bun-v$ver"
if [ ! -x "$dir/bun" ]; then
tmp=$(mktemp -d)
base="https://github.com/oven-sh/bun/releases/download/bun-v$ver"
curl -fsSL --retry 3 -o "$tmp/bun-linux-x64.zip" "$base/bun-linux-x64.zip"
curl -fsSL --retry 3 -o "$tmp/SHASUMS256.txt" "$base/SHASUMS256.txt"
(cd "$tmp" && grep ' bun-linux-x64.zip$' SHASUMS256.txt | sha256sum -c -)
unzip -q "$tmp/bun-linux-x64.zip" -d "$tmp"
mkdir -p "$dir"
install -m755 "$tmp/bun-linux-x64/bun" "$dir/bun"
rm -rf "$tmp"
fi
[ "$("$dir/bun" --version)" = "$ver" ] || { echo "::error::$dir/bun 的版本不是 $ver"; exit 1; }
echo "$dir" >> "$GITHUB_PATH"
echo "用 $dir/bun($ver)"
- name: 装依赖
run: bun install --frozen-lockfile
# 输出到 dist/oj2-api,正是 Dockerfile 里 artifacts-prebuilt 阶段要的位置。
# 编完先看它链接了什么:官方 bun 编出来的只依赖 libc 那几个,多出任何一个库
# (比如 libicu)都说明用错了 bun,到容器里一定起不来。在这里拦下,
# 别等服务器上构建完镜像、迁移时才炸 —— 那时 oj2-api:latest 已经被换成坏镜像了。
- name: 编后端
run: |
bun run --filter '@oj2/api' build
ldd dist/oj2-api
extra=$(ldd dist/oj2-api | awk '{print $1}' | grep -vE '^(linux-vdso\.so\.1|libc\.so\.6|libpthread\.so\.0|libdl\.so\.2|libm\.so\.6|/lib64/ld-linux-x86-64\.so\.2)$' || true)
[ -z "$extra" ] || { echo "::error::dist/oj2-api 多链接了这些库,容器里没有:$extra"; exit 1; }
# 默认 production 模式,读 apps/web/.env.production(在版本库里)。
- name: 编前端
run: cd apps/web && bun run build
# 格式、lint、前后端类型检查、路由遮蔽、AST 节点,有一项不过就不部署。
# 必须排在「编前端」之后:前端类型检查要 unplugin 生成的 auto-imports.d.ts /
# components.d.ts,它们不进版本库,vite build 时才生成。
- name: 检查
run: bunx vp run verify
# 排除项和 --no-owner --no-group 的理由见 .github/workflows/deploy.yml 同名一步,
# 一条都不能少(少了 data 就是删库,少了 docker/.env* 下一步自检直接 die)。
# -c 按内容比:每次 checkout 出来的 mtime 都是「刚才」,按 mtime 比会把
# 600 多个没改的文件逐个走一遍增量。
# --delay-updates 全部传完才一起换上去,中途断了服务器上不会留下半新半旧的源码。
# --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用。
- name: 同步到服务器
timeout-minutes: 10
run: |
for attempt in 1 2 3; do
rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
--compress --compress-choice=zstd \
--timeout=60 --stats --human-readable \
-e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
--exclude node_modules \
--exclude .git \
--exclude data \
--exclude 'docker/.env*' \
./ "$REMOTE:$REMOTE_DIR/" && exit 0
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
sleep 10
done
exit 1
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出。
# 破坏性迁移会被拦下并退出 4,除非手动触发时勾了 allow_destructive。
#
# 放行与否在 shell 里按字符串 "true" 判:Gitea 的 inputs 是布尔还是字符串,
# "false" 这个非空字符串都不会被当成放行(GitHub 那份踩过 github.event.inputs 的坑)。
- name: 起栈
env:
ALLOW_INPUT: ${{ inputs.allow_destructive }}
run: |
allow=''
[ "$ALLOW_INPUT" = "true" ] && allow=1
echo "放行破坏性迁移:${allow:-否}"
ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" \
"cd $REMOTE_DIR && OJ2_ALLOW_DESTRUCTIVE='$allow' docker/deploy.sh --prebuilt"
+43 -26
View File
@@ -1,19 +1,20 @@
# 在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。 # 备用部署路线:**只能手动触发**(Actions 页面 → Deploy → Run workflow)。
# 平时的部署是 push origin 时由开发机上的 gitea-runner 跑 .gitea/workflows/deploy.yml,
# 这份只在开发机不在、又急着上线时用。
# #
# 为什么产物在这里编:服务器性能差,镜像 builder 阶段(1238 个包的 bun install + # 为什么不再 push 就跑:GitHub 的 runner 在海外,到服务器晚高峰只有十几 KB/s,
# bun compile + vite build)首次约 5 分钟,光前端就 160s。runner 编完传过去, # rsync 一次要五六分钟(后端改一行,--bytecode 那段约 6MB 字节码就整段重写,增量省不掉)。
# 服务器那边的镜像构建只剩几条 COPY。 # 而 origin 会镜像到 github,两边都 push 触发的话每次会部署两遍,两边的并发锁又不通。
#
# 流程:在 runner 上编好产物 → rsync 到服务器 → 服务器上跑 docker/deploy.sh 起栈。
# 产物不在服务器上编,是因为服务器性能差,镜像 builder 阶段(1238 个包的 bun install +
# bun compile + vite build)首次约 5 分钟,光前端就 160s。
# #
# 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的 # 服务器自己编的能力**没有砍掉**:docker/deploy.sh 不带 --prebuilt 就是原来的
# 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖这个工作流。 # 行为(ARTIFACTS=build),只要机器上有 docker 就能手动部署,不依赖任何工作流。
# #
# 触发的是 push 到 **github** 这个 remote: # job 上卡了 server_url:只在 GitHub 上跑。仓库有了 .gitea/workflows 之后 Gitea 本来就
# # 不再读这份,这道闸留着防万一。
# git push github main
#
# origin(git.xuyue.cc)是 Gitea,仓库里没有 .gitea/workflows 时它的 Actions 会退回来
# 读这份 .github/workflows,每次 push origin 都会把部署再跑一遍 —— 那边不需要。
# 所以下面 job 上卡了 server_url:只在 GitHub 上跑,Gitea 上直接跳过。
# #
# 仓库 Settings → Secrets 需要两个: # 仓库 Settings → Secrets 需要两个:
# HOST 服务器地址 # HOST 服务器地址
@@ -21,12 +22,8 @@
name: Deploy name: Deploy
on: on:
push: # 手动触发时可以放行破坏性迁移;不勾就停在闸门上把工作流判红。
branches: # 那种改动得有人先确认备份。
- main
# 手动触发时可以放行破坏性迁移。push 触发拿不到这个 input,值恒为空 ——
# 也就是说**自动部署永远不会执行 DROP TABLE 之类的迁移**,只会停在闸门上把
# 工作流判红。这是有意的:那种改动得有人先确认备份。
workflow_dispatch: workflow_dispatch:
inputs: inputs:
allow_destructive: allow_destructive:
@@ -97,9 +94,21 @@ jobs:
chmod 600 ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -p "$REMOTE_PORT" -H "${{ secrets.HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
# 源码和产物一起传。dist/oj2-api 有 77MB,但 rsync 对已存在的文件做增量, # 源码和产物一起传。dist/oj2-api 有 115MB,但 rsync 对已存在的文件做增量,
# 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。 # 重编后大部分(bun 运行时那段)字节不变,实际传输量远小于这个数。
# #
# 慢在线路上:runner 在海外,到服务器晚高峰只有十几 KB/s,传 3~4MB 就要五六分钟。
# 后端只要改了一行,--bytecode 那段字节码(约 6MB)就整段重写、增量也省不掉,
# 它占了每次传输量的七成多。下面几个开关都是为了少传、传不动时别干等:
# -c 按内容比,不看 mtime。checkout 出来每个文件的 mtime 都是「刚才」,
# 不加它 rsync 会把 600 多个没改的源码逐个走一遍增量、来回交换校验块。
# zstd 19 比默认的 zlib 再少传约 17%(实测 4.09MB → 3.38MB),压缩只花 runner 的 CPU。
# --delay-updates 全部传完才一次性换上去,中途断了服务器上不会留下半新半旧的源码。
# --timeout 60 秒没有任何数据进出就算断了,交给外面的循环重试;
# 重试时 --delay-updates 留在 .~tmp~ 里的半截文件会接着用,不从头传。
# ServerAlive 连接悄悄死掉时 ssh 自己能发现,不会一直挂着。
# --stats 日志里能看到这次到底传了多少字节。
#
# --delete 的排除项少一个就会出事,逐条说明: # --delete 的排除项少一个就会出事,逐条说明:
# docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。 # docker/.env 服务器上唯一一份,不在版本库里(.gitignore 排掉了 .env*)。
# 删了下一步 deploy.sh 自检就直接 die。 # 删了下一步 deploy.sh 自检就直接 die。
@@ -114,14 +123,22 @@ jobs:
# 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被 # 还是当初手动 clone 时的 root。两者对不上,之后在服务器上跑任何 git 命令都会被
# 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。 # 「检测到可疑的仓库所有权」挡下(git 2.35.2 起的 safe.directory 检查)。
- name: 同步到服务器 - name: 同步到服务器
timeout-minutes: 20
run: | run: |
rsync -az --no-owner --no-group --delete \ for attempt in 1 2 3; do
-e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT" \ rsync -a -c --no-owner --no-group --delete-delay --delay-updates \
--exclude node_modules \ --compress --compress-choice=zstd --compress-level=19 \
--exclude .git \ --timeout=60 --stats --human-readable \
--exclude data \ -e "ssh -i ~/.ssh/id_deploy -p $REMOTE_PORT -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
--exclude 'docker/.env*' \ --exclude node_modules \
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" --exclude .git \
--exclude data \
--exclude 'docker/.env*' \
./ "root@${{ secrets.HOST }}:$REMOTE_DIR/" && exit 0
echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试"
sleep 10
done
exit 1
# deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出, # deploy.sh 自检不过 / 迁移被拦 / 容器没起来 / 冒烟发现题目数是 0,都会非零退出,
# 这一步跟着红,日志里能看到具体是哪一条。 # 这一步跟着红,日志里能看到具体是哪一条。
+5 -4
View File
@@ -62,7 +62,8 @@ cd apps/web && bun run build # 前端构建(不在 verify 里
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它; 配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks` `semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
(`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在 (`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在
长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css` 来回改。前端自己的构建 长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css`、`apps/web/src/oj/learn/learn.css` 来回改
(`vp fmt --list-different` 能列出当前受影响的文件)。前端自己的构建
配置在 `apps/web/vite.config.ts`,和根目录那份无关。 配置在 `apps/web/vite.config.ts`,和根目录那份无关。
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了 - 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
@@ -85,9 +86,9 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
## 提交 ## 提交
**直接在 `main` 上干活,不开特性分支。** 注意 push 到 `github` 这个 remote 的 `main` **直接在 `main` 上干活,不开特性分支。** 注意 **push `origin` 的 `main` 就会部署**:
会触发 CI 部署;而 `origin` 会镜像到 `github`,**平时 push `origin` 通常几秒后也就部署了** 开发机上的 gitea-runner 跑 `.gitea/workflows/deploy.yml`,进度在 git.xuyue.cc 的 Actions 页面。
(偶尔不跟,要 `git ls-remote github` 核实)。见 `docs/deploy.md`。 GitHub 那份只剩手动触发的备用路线。见 `docs/deploy.md`。
提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如 提交信息用中文,格式 `type(范围): 从用户视角描述现象`,例如
`fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。 `fix(流程图): 「重新判题」从来没成功过,而且会把原来的评分清掉`。
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "mascot_event" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "mascot_event_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"kind" text NOT NULL,
"situation" text,
"line" text,
"action" text,
"path" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mascot_event" ADD CONSTRAINT "mascot_event_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mascot_event_user_kind_idx" ON "mascot_event" USING btree ("user_id","kind");--> statement-breakpoint
CREATE INDEX "mascot_event_created_at_idx" ON "mascot_event" USING btree ("created_at");
@@ -0,0 +1,184 @@
-- 成就惠及更多学生(2026-11-03)。已经拿到的一个不收回。
--
-- 这学期 375 个交过题的学生,成就数中位数 5 个、三分之一只有 0–2 个。三件事:
--
-- 1. 比赛里的提交也算成就(判题结算同步改了,见 services/achievements.ts 的 solvedCounts)。
-- 这里的比赛其实是单班测验,2612 班整个班都在比赛里做题,人均做对 17 道却一个成就也没有;
-- 「参加过几场比赛」以前判题时也不更新,只在后台重扫时刷新,所以连「初入赛场」都没拿到。
-- 2. 几条线补上低一档(10 道和 50 道之间、一次就对 1 次和 20 次之间……),外加一条新线
-- 「挑战成功」。括号里是这学期学生已经达到的比例。
-- 3. 档多了,「奖杯收藏家」从 15 个提到 20 个,不然人人都有。
--
-- 然后从提交记录把含比赛的指标重数一遍,按新配置给所有达标的人补发(backfilled,不显示日期)。
-- 编译错误次数和按天的连续不重数:前者上线前的历史本来就没有,后者已经下架。
INSERT INTO "achievement" ("name", "description", "icon", "rarity", "hidden", "metric", "operator", "threshold", "visible", "unlock_count", "order", "create_time") VALUES
('稳扎稳打', '通过 20 道题目', 'noto:orange-book', 'bronze', false, 'accepted_count', 'gte', 20, true, 0, 2, now()), -- 44%
('初露锋芒', '通过 1 道中等难度题目', 'noto:sunrise-over-mountains', 'bronze', false, 'mid_ac_count', 'gte', 1, true, 0, 6, now()), -- 21%
('迎难而上', '挑战成功 1 次', 'noto:puzzle-piece', 'bronze', false, 'challenge_count', 'gte', 1, true, 0, 10, now()), -- 60%
('精益求精', '挑战成功 5 次', 'noto:gem-stone', 'silver', false, 'challenge_count', 'gte', 5, true, 0, 10, now()), -- 12%
('勤学苦练', '累计提交 30 次', 'noto:pencil', 'bronze', false, 'submission_count', 'gte', 30, true, 0, 11, now()), -- 48%
('渐成习惯', '累计 5 天有提交记录', 'noto:spiral-calendar', 'bronze', false, 'active_days', 'gte', 5, true, 0, 15, now()), -- 59%
('手感火热', '累计 5 次首次提交即通过', 'noto:glowing-star', 'bronze', true, 'first_try_ac_count', 'gte', 5, true, 0, 25, now()), -- 67%
('再试一次', '同一道题失败 3 次后终于通过', 'noto:counterclockwise-arrows-button', 'bronze', true, 'max_wa_before_ac', 'gte', 3, true, 0, 32, now()), -- 54%
('一鼓作气', '一天之内通过 5 道题', 'noto:dashing-away', 'bronze', true, 'max_ac_in_one_day', 'gte', 5, true, 0, 34, now()); -- 57%
--> statement-breakpoint
UPDATE "achievement" SET "threshold" = 20, "description" = '解锁 20 个成就(不含白金)'
WHERE "metric" = 'achievement_unlocked_count' AND "threshold" = 15;
--> statement-breakpoint
-- 交过题、还没有 user_stat 的人先建一行(2612 班大多是这样:比赛提交以前根本不进结算)
INSERT INTO "user_stat" ("user_id", "metrics", "update_time")
SELECT DISTINCT s."user_id", '{}'::jsonb, now()
FROM "submission" s
JOIN "user" u ON u."id" = s."user_id"
ON CONFLICT ("user_id") DO NOTHING;
--> statement-breakpoint
-- 按判题结算的口径从提交记录重数(services/achievements.ts):含比赛;在判的(6/7)不算,
-- 它们判完时结算会自己加;做对 = ACCEPTED(0) / AST_CHECK_FAILED(10);日历按东八区。
-- 「新做对」= 这道题第一次做对的那条提交。
UPDATE "user_stat" AS target
SET "metrics" = target."metrics" || fresh.metrics, "update_time" = now()
FROM (
WITH subs AS (
SELECT s."id", s."user_id", s."problem_id", s."contest_id", s."result", s."language", s."code", s."create_time",
(s."create_time" AT TIME ZONE 'Asia/Shanghai') AS local, p."difficulty",
s."result" IN (0, 10) AS ac,
-- 这道题在这条之前交过几次(同一时刻的不算「之前」,和结算里的 create_time < 一致)
rank() OVER (PARTITION BY s."user_id", s."problem_id" ORDER BY s."create_time") - 1 AS before
FROM "submission" s
JOIN "user" u ON u."id" = s."user_id"
JOIN "problem" p ON p."id" = s."problem_id"
WHERE s."result" NOT IN (6, 7)
),
totals AS (
SELECT "user_id",
count(DISTINCT "problem_id") FILTER (WHERE ac)::int AS accepted,
count(DISTINCT "problem_id") FILTER (WHERE ac AND "difficulty" = 'Mid')::int AS mid,
count(DISTINCT "problem_id") FILTER (WHERE ac AND "difficulty" = 'High')::int AS hard,
count(*)::int AS submissions,
count(DISTINCT "contest_id")::int AS contests,
count(*) FILTER (WHERE extract(hour FROM local) < 5)::int AS midnight,
count(*) FILTER (WHERE extract(hour FROM local) >= 5 AND extract(hour FROM local) < 7)::int AS early,
jsonb_agg(DISTINCT "language") AS languages,
jsonb_agg(DISTINCT to_char(local, 'YYYY-MM-DD')) AS dates
FROM subs GROUP BY "user_id"
),
first_try AS (
SELECT "user_id", count(*) FILTER (WHERE ac)::int AS n
FROM (SELECT DISTINCT ON ("user_id", "problem_id") "user_id", ac FROM subs ORDER BY "user_id", "problem_id", "create_time", "id") f
GROUP BY "user_id"
),
code_lines AS (
SELECT "user_id", max((
SELECT count(*) FROM regexp_split_to_table("code", E'\n') AS line WHERE btrim(line, E' \t\r') <> ''
))::int AS n
FROM subs WHERE ac GROUP BY "user_id"
),
first_ac AS (
SELECT DISTINCT ON ("user_id", "problem_id") "user_id", "problem_id", local, before
FROM subs WHERE ac ORDER BY "user_id", "problem_id", "create_time", "id"
),
wa_before AS (
SELECT "user_id", max(before)::int AS n FROM first_ac GROUP BY "user_id"
),
per_day AS (
SELECT "user_id", jsonb_object_agg(day, n) AS days, max(n)::int AS best
FROM (SELECT "user_id", to_char(local, 'YYYY-MM-DD') AS day, count(*)::int AS n FROM first_ac GROUP BY 1, 2) d
GROUP BY "user_id"
),
weeks AS (
SELECT DISTINCT "user_id", date_trunc('week', local)::date AS w FROM first_ac
),
runs AS (
SELECT "user_id", count(*)::int AS len, max(w) AS last_w
FROM (SELECT "user_id", w, w - (row_number() OVER (PARTITION BY "user_id" ORDER BY w))::int * 7 AS g FROM weeks) i
GROUP BY "user_id", g
),
streak AS (
SELECT "user_id", max(len) AS best, (array_agg(len ORDER BY last_w DESC))[1] AS current, max(last_w) AS last_w
FROM runs GROUP BY "user_id"
),
challenges AS (
SELECT "user_id", count(*)::int AS n FROM "challenge_success" GROUP BY "user_id"
)
SELECT t."user_id",
jsonb_build_object(
'accepted_count', t.accepted,
'mid_ac_count', t.mid,
'hard_ac_count', t.hard,
'first_try_ac_count', coalesce(ft.n, 0),
'submission_count', t.submissions,
'contest_joined', t.contests,
'midnight_submissions', t.midnight,
'early_bird_submissions', t.early,
'_languages', t.languages,
'languages_used', jsonb_array_length(t.languages),
'_active_dates', t.dates,
'active_days', jsonb_array_length(t.dates),
'max_code_lines', coalesce(cl.n, 0),
'max_wa_before_ac', coalesce(wb.n, 0),
'_ac_per_day', coalesce(pd.days, '{}'::jsonb),
'max_ac_in_one_day', coalesce(pd.best, 0),
'challenge_count', coalesce(ch.n, 0)
) || CASE WHEN st."user_id" IS NULL THEN '{}'::jsonb ELSE jsonb_build_object(
'max_ac_week_streak', st.best,
'_current_ac_week_streak', st.current,
'_last_ac_week', to_char(st.last_w, 'YYYY-MM-DD')
) END AS metrics
FROM totals t
LEFT JOIN first_try ft ON ft."user_id" = t."user_id"
LEFT JOIN code_lines cl ON cl."user_id" = t."user_id"
LEFT JOIN wa_before wb ON wb."user_id" = t."user_id"
LEFT JOIN per_day pd ON pd."user_id" = t."user_id"
LEFT JOIN streak st ON st."user_id" = t."user_id"
LEFT JOIN challenges ch ON ch."user_id" = t."user_id"
) fresh
WHERE target."user_id" = fresh."user_id";
--> statement-breakpoint
-- 挑战成功了、却一条提交都没有的(不会有,保险)也给个数
UPDATE "user_stat" AS target
SET "metrics" = jsonb_set(target."metrics", '{challenge_count}', to_jsonb(c.n))
FROM (SELECT "user_id", count(*)::int AS n FROM "challenge_success" GROUP BY "user_id") c
WHERE target."user_id" = c."user_id" AND (target."metrics" -> 'challenge_count') IS DISTINCT FROM to_jsonb(c.n);
--> statement-breakpoint
-- 补发:上架的、非元成就,达标没发的都发(口径同 rescanAchievement:backfilled、待通知)
INSERT INTO "user_achievement" ("user_id", "achievement_id", "unlock_time", "backfilled", "notified")
SELECT st."user_id", a."id", now(), true, false
FROM "user_stat" st
JOIN "achievement" a ON a."visible" AND a."metric" <> 'achievement_unlocked_count'
WHERE jsonb_typeof(st."metrics" -> a."metric") = 'number'
AND CASE WHEN a."operator" = 'gte' THEN (st."metrics" ->> a."metric")::numeric >= a."threshold"
ELSE (st."metrics" ->> a."metric")::numeric <= a."threshold" END
ON CONFLICT ("user_id", "achievement_id") DO NOTHING;
--> statement-breakpoint
-- 已解锁数(上架的非白金)重数,口径同 refreshUnlockedCount
UPDATE "user_stat" AS target
SET "metrics" = jsonb_set(target."metrics", '{achievement_unlocked_count}', to_jsonb(fresh.value))
FROM (
SELECT s."id", coalesce(c.value, 0) AS value
FROM "user_stat" s
LEFT JOIN (
SELECT ua."user_id", count(*)::int AS value
FROM "user_achievement" ua
JOIN "achievement" a ON a."id" = ua."achievement_id"
WHERE a."rarity" <> 'platinum' AND a."visible"
GROUP BY ua."user_id"
) c ON c."user_id" = s."user_id"
) fresh
WHERE target."id" = fresh."id"
AND (target."metrics" -> 'achievement_unlocked_count') IS DISTINCT FROM to_jsonb(fresh.value);
--> statement-breakpoint
-- 第二轮:元成就(奖杯收藏家)
INSERT INTO "user_achievement" ("user_id", "achievement_id", "unlock_time", "backfilled", "notified")
SELECT st."user_id", a."id", now(), true, false
FROM "user_stat" st
JOIN "achievement" a ON a."visible" AND a."metric" = 'achievement_unlocked_count'
WHERE jsonb_typeof(st."metrics" -> a."metric") = 'number'
AND CASE WHEN a."operator" = 'gte' THEN (st."metrics" ->> a."metric")::numeric >= a."threshold"
ELSE (st."metrics" ->> a."metric")::numeric <= a."threshold" END
ON CONFLICT ("user_id", "achievement_id") DO NOTHING;
--> statement-breakpoint
UPDATE "achievement" AS a
SET "unlock_count" = coalesce(c.n, 0)
FROM (SELECT "achievement_id", count(*)::int AS n FROM "user_achievement" GROUP BY "achievement_id") c
WHERE c."achievement_id" = a."id" AND a."unlock_count" IS DISTINCT FROM c.n;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -302,6 +302,20 @@
"when": 1793632543220, "when": 1793632543220,
"tag": "0042_class_name_cleanup", "tag": "0042_class_name_cleanup",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1793693911394,
"tag": "0043_mascot_event",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1793695621303,
"tag": "0044_achievement_more_tiers",
"breakpoints": true
} }
] ]
} }
+43
View File
@@ -46,6 +46,7 @@ import type {
JudgeStatus, JudgeStatus,
LessonLanguage, LessonLanguage,
LessonMode, LessonMode,
MascotEventKind,
ProblemChallenges, ProblemChallenges,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
@@ -1411,3 +1412,45 @@ export const challengeSuccess = pgTable(
}).onDelete('cascade'), }).onDelete('cascade'),
], ],
) )
/**
* 看板娘的互动记录:点开气泡、点气泡里的按钮、她主动开口、摸头彩蛋、被拖走(种类见契约
* mascotEventSchema)。拿来看她说的话、给的按钮学生用不用,以后按学生的行为调整她也读这里;
* 「点过几次」(熟悉程度)直接按 user_id 数 open。
*
* 不是「学生做过什么」的证据,账号没了就没意义,user 外键 CASCADE(外键分档见 CLAUDE.md「数据库」)。
*/
export const mascotEvent = pgTable(
'mascot_event',
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: 'mascot_event_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
userId: integer('user_id').notNull(),
kind: text().$type<MascotEventKind>().notNull(),
situation: text(),
line: text(),
action: text(),
path: text().notNull(),
createdAt: timestamp('created_at', {
withTimezone: true,
mode: 'string',
})
.notNull()
.defaultNow(),
},
(table) => [
index('mascot_event_user_kind_idx').on(table.userId, table.kind),
index('mascot_event_created_at_idx').on(table.createdAt),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'mascot_event_user_id_fk_user_id',
}).onDelete('cascade'),
],
)
+2
View File
@@ -21,6 +21,7 @@ import { problemRoutes } from './routes/problem'
import { problemStatsRoutes } from './routes/problem-stats' import { problemStatsRoutes } from './routes/problem-stats'
import { submissionRoutes } from './routes/submission' import { submissionRoutes } from './routes/submission'
import { trialRunRoutes } from './routes/trial-run' import { trialRunRoutes } from './routes/trial-run'
import { mascotRoutes } from './routes/mascot'
import { siteRoutes } from './routes/site' import { siteRoutes } from './routes/site'
import { scanPendingChallenges } from './services/challenge' import { scanPendingChallenges } from './services/challenge'
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket' import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
@@ -44,6 +45,7 @@ app.route('/api', problemRoutes)
app.route('/api', problemStatsRoutes) app.route('/api', problemStatsRoutes)
app.route('/api', submissionRoutes) app.route('/api', submissionRoutes)
app.route('/api', trialRunRoutes) app.route('/api', trialRunRoutes)
app.route('/api', mascotRoutes)
app.route('/api/admin', adminRoutes) app.route('/api/admin', adminRoutes)
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出 // 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
+99 -17
View File
@@ -63,15 +63,74 @@ async function loadLanguage(language: string) {
return loaded return loaded
} }
function collectNodes(root: Node, type: string, result: Node[] = []) { type Match = (node: Node) => boolean
if (root.type === type) result.push(root)
for (const child of root.children) collectNodes(child, type, result) function collectNodes(root: Node, match: Match, result: Node[] = []) {
if (match(root)) result.push(root)
for (const child of root.children) collectNodes(child, match, result)
return result return result
} }
function hasNode(root: Node, type: string): boolean { function hasNode(root: Node, match: Match): boolean {
if (root.type === type) return true if (match(root)) return true
return root.children.some((child) => hasNode(child, type)) return root.children.some((child) => hasNode(child, match))
}
const ofType =
(type: string): Match =>
(node) =>
node.type === type
/** C / C++ 函数定义的函数名:`int *f()` 的名字在 pointer_declarator 里面一层,沿着 declarator 往里找 */
function functionName(definition: Node) {
let declarator = definition.childForFieldName('declarator')
while (declarator && declarator.type !== 'function_declarator') declarator = declarator.childForFieldName('declarator')
return declarator?.childForFieldName('declarator')?.text
}
/**
* 语法 target 认哪些节点。多数就是比节点类型,例外:
*
* C / C++ 的「函数定义」不算 `main` —— 每个 C 程序都有 main,算进去的话「必须使用函数定义」
* 永远通过、「不能使用」永远失败,老师选它要的是「自己写了函数」。所以 C 那边叫「自定义函数」。
*
* Python 的「赋值语句」连 `s += 1` 一起算 —— 和 C 一个口径(C 的 assignment_expression
* 本来就包括 `+=`),学生也是这么理解的。tree-sitter-python 里 `+=` 是单独的 augmented_assignment。
*/
function nodeMatch(target: string, language: string): Match {
const type = astTargetNodeType(target, language)
if (target === 'function_definition' && language !== 'Python') {
return (node) => node.type === type && functionName(node) !== 'main'
}
if (target === 'assignment' && language === 'Python') {
return (node) => node.type === type || node.type === 'augmented_assignment'
}
return ofType(type)
}
/**
* 运算符只认真在做运算的那一个 token。光比 token 的话,下面这些全都算「用了」:
* C 的 `scanf("%d", &n)` 算 `&`、`int *p` 算 `*`,C++ 的 `vector<int>` 算 `>`,
* Python 的 `*args` / `from m import *` 算 `*`、`**kw` 算 `**`、`not in` / `is not` 算 `not`、
* `case 1 | 2` 算 `|`。所以看它的父节点:二元运算、复合赋值、自增自减。
*
* 一元的只认取反(`!x` / `not x`)。负号 `-x` 不算「用了减号」,同理 `*p` 不算乘、`&n` 不算按位与。
*/
const OPERATOR_PARENTS: Record<string, string[]> = {
C: ['binary_expression', 'assignment_expression', 'update_expression'],
'C++': ['binary_expression', 'assignment_expression', 'update_expression'],
Python: ['binary_operator', 'boolean_operator', 'comparison_operator', 'augmented_assignment'],
}
const NOT_PARENTS: Record<string, string[]> = {
C: ['unary_expression'],
'C++': ['unary_expression'],
Python: ['not_operator'],
}
function operatorMatch(target: string, language: string): Match {
const type = astTargetNodeType(target, language)
const parents = (target === 'not' ? NOT_PARENTS : OPERATOR_PARENTS)[language] ?? []
return (node) => node.type === type && node.parent !== null && parents.includes(node.parent.type)
} }
function targetName(rule: AstRule, language?: string) { function targetName(rule: AstRule, language?: string) {
@@ -201,6 +260,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
if (!rule.keyword?.trim()) return `${at}:参数名不能为空` if (!rule.keyword?.trim()) return `${at}:参数名不能为空`
} }
if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字` if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字`
if ([rule.exact, rule.min, rule.max].some((n) => n !== undefined && n < 0)) return `${at}:次数不能是负数`
if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) {
return `${at}:至少 ${rule.min} 次比至多 ${rule.max} 次还多,怎么写都过不了`
}
} }
} }
return null return null
@@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record<string, string> = {
Python: 'call', Python: 'call',
} }
/** `import math` / `import numpy as np` 引进来的模块名。`import os.path` 绑定的是 `os` */
function importedModules(root: Node) {
const names = new Set<string>()
for (const statement of collectNodes(root, ofType('import_statement'))) {
for (const item of statement.namedChildren) {
const name = item.type === 'aliased_import' ? item.childForFieldName('alias') : item.type === 'dotted_name' ? item.namedChildren[0] : null
if (name) names.add(name.text)
}
}
return names
}
function functionCalls(root: Node, target: string, language: string) { function functionCalls(root: Node, target: string, language: string) {
const callType = CALL_NODE_TYPES[language] ?? 'call' const callType = CALL_NODE_TYPES[language] ?? 'call'
return collectNodes(root, callType).filter((call) => { // Python 的 `math.sqrt(4)` 也算调用了 sqrt() —— 和 `from math import sqrt` 后写 `sqrt(4)`
// 是一回事,老师配「必须调用 sqrt()」时想的就是它。只认 import 进来的模块,
// `s.count()` 这种对象上的方法还是归「调用方法」
const modules = language === 'Python' ? importedModules(root) : null
return collectNodes(root, ofType(callType)).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
if (!fn) return false if (!fn) return false
if (fn.type === 'identifier') return fn.text === target if (fn.type === 'identifier') return fn.text === target
if (modules && fn.type === 'attribute') {
const object = fn.childForFieldName('object')
return object?.type === 'identifier' && modules.has(object.text) && fn.childForFieldName('attribute')?.text === target
}
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于 // `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
// 有没有 using namespace std,两种都得认,所以末段也比一次 // 有没有 using namespace std,两种都得认,所以末段也比一次
if (language === 'C++' && fn.type === 'qualified_identifier') { if (language === 'C++' && fn.type === 'qualified_identifier') {
@@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) {
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」 // 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
// 会静默地永远失败 // 会静默地永远失败
if (language === 'C++') { if (language === 'C++') {
return collectNodes(root, 'call_expression').filter((call) => { return collectNodes(root, ofType('call_expression')).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target
}) })
} }
if (language !== 'Python') return [] if (language !== 'Python') return []
return collectNodes(root, 'call').filter((call) => { return collectNodes(root, ofType('call')).filter((call) => {
const fn = call.childForFieldName('function') const fn = call.childForFieldName('function')
return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target
}) })
@@ -279,20 +362,20 @@ function methodCalls(root: Node, target: string, language: string) {
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null { function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
const target = rule.target ?? '' const target = rule.target ?? ''
const nodeType = astTargetNodeType(target, language) const match = rule.engine === 'must_use_operator' ? operatorMatch(target, language) : nodeMatch(target, language)
switch (rule.engine) { switch (rule.engine) {
case 'must_exist_node': case 'must_exist_node':
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, match),
} }
case 'must_not_exist_node': { case 'must_not_exist_node': {
const nodes = collectNodes(root, nodeType) const nodes = collectNodes(root, match)
return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes) return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes)
} }
case 'count_node': { case 'count_node': {
const nodes = collectNodes(root, nodeType) const nodes = collectNodes(root, match)
return withLines( return withLines(
{ {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
@@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_use_operator': case 'must_use_operator':
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, match),
} }
case 'must_use_keyword': case 'must_use_keyword':
return { return {
@@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
case 'must_have_nesting': { case 'must_have_nesting': {
const outer = rule.outer ?? '' const outer = rule.outer ?? ''
const inner = rule.inner ?? '' const inner = rule.inner ?? ''
const outerType = astTargetNodeType(outer, language) const innerMatch = nodeMatch(inner, language)
const innerType = astTargetNodeType(inner, language) const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch)))
const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)))
return { description: describeAstRule(rule, language), passed } return { description: describeAstRule(rule, language), passed }
} }
default: default:
+176
View File
@@ -0,0 +1,176 @@
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware'
import { db, schema } from '../db'
import { parseBody, success } from '../http'
import { JudgeStatus } from '../judge/status'
import { calendarDay, dayNumber, dayStart, weekStart } from '../time'
export const mascotRoutes = new Hono<AppEnv>()
/**
* 每人每分钟最多记这么多条。正常点她一分钟到不了十条(连点只记开头、彩蛋和嫌烦那几下),
* 拿脚本刷的才会撞上;撞上了就静默丢掉,不报错 —— 前台本来也不等这个结果
*/
const EVENTS_PER_MINUTE = 30
const windows = new Map<number, { start: number; count: number }>()
function allow(userId: number) {
const now = Date.now()
const entry = windows.get(userId)
if (!entry || now - entry.start >= 60_000) {
windows.set(userId, { start: now, count: 1 })
// 顺手清掉过期的,免得 Map 跟着在线人数一直涨
if (windows.size > 2000) for (const [id, it] of windows) if (now - it.start >= 60_000) windows.delete(id)
return true
}
entry.count++
return entry.count <= EVENTS_PER_MINUTE
}
/** 记一条看板娘互动(种类见契约 mascotEventSchema) */
mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
const parsed = await parseBody(c, mascotEventSchema, '看板娘记录的格式不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
if (allow(user.id)) {
const { kind, situation, line, action, path } = parsed.data
await db.insert(schema.mascotEvent).values({ userId: user.id, kind, situation, line, action, path })
}
return success(c, null)
})
/** 跨天记得的事只翻这么多天以内的 */
const MEMORY_DAYS = 7
/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */
function daysAgo(at: string | null, saidAt: string | null) {
// 按时刻比,别按字符串:库里出来的带微秒、位数不一定一样
if (!at || (saidAt && Date.parse(saidAt) > Date.parse(at))) return null
const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at))
return days >= 1 && days <= MEMORY_DAYS ? days : null
}
/** 跨天记得的事(契约 mascotProfileSchema.memory),一条语句查完 */
async function memoryOf(userId: number) {
const today = dayStart()
const since = new Date(Date.parse(today) - MEMORY_DAYS * 86_400_000).toISOString()
const e = schema.mascotEvent
/** 7 天内最后一次。`beforeToday`:今天的事不算「记得」,只取今天以前的 */
const last = (kind: string, situation: string | null, beforeToday: boolean) => sql`(
select max(${e.createdAt}) from ${e}
where ${e.userId} = ${userId} and ${e.kind} = ${kind}
${situation ? sql`and ${e.situation} = ${situation}` : sql``}
and ${e.createdAt} >= ${since}
${beforeToday ? sql`and ${e.createdAt} < ${today}` : sql``}
)`
const s = schema.submission
const [row] = await db.execute<{
sulk_at: string | null
grudge_at: string | null
pat_at: string | null
pat_said_at: string | null
closer_at: string | null
solved_before: number
}>(sql`
select
${last('open', 'sulk', true)} as sulk_at,
${last('open', 'memory.grudge', false)} as grudge_at,
${last('pat', null, true)} as pat_at,
${last('open', 'memory.pat', false)} as pat_said_at,
${last('open', 'memory.closer', false)} as closer_at,
(
select count(distinct ${s.problemId})::int from ${s}
where ${s.userId} = ${userId} and ${s.result} in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})
and ${s.createTime} < ${today}
) as solved_before
`)
return {
grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null),
patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null),
solvedBeforeToday: row?.solved_before ?? 0,
closerSaidToday: !!row?.closer_at && Date.parse(row.closer_at) >= Date.parse(today),
}
}
/** 最近多少次题库提交拿来看学生常犯哪类错 */
const RECENT_SUBMISSIONS = 20
/** 看板娘按学生的行为挑话用的那几个数(契约 mascotProfileSchema) */
mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
const userId = c.get('user')!.id
const s = schema.submission
const week = weekStart()
const e = schema.mascotEvent
const [pokes, solved, memory, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
db
.select({ value: count() })
.from(schema.mascotEvent)
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
// 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数
db
.select({ value: sql<number>`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` })
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId)),
memoryOf(userId),
db
.select({ value: countDistinct(s.problemId) })
.from(s)
.where(and(eq(s.userId, userId), eq(s.result, 0), gte(s.createTime, dayStart()))),
db
.select({ result: s.result, errorType: sql<string | null>`${s.statisticInfo}->'runtime_error'->>'type'` })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId)))
.orderBy(desc(s.createTime))
.limit(RECENT_SUBMISSIONS),
db
.select({ value: count() })
.from(schema.reaction)
.where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))),
db
.select({ value: count() })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))),
db
.select({ kind: e.kind, value: count() })
.from(e)
.where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week)))
.groupBy(e.kind),
db
.select({ value: count() })
.from(schema.aiHint)
.innerJoin(s, eq(s.id, schema.aiHint.submissionId))
.where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))),
])
const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0
const types = new Map<string, number>()
for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1)
const topType = [...types.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
const tally = (...results: number[]) => recent.filter((row) => results.includes(row.result)).length
return success(c, {
pokes: pokes[0]?.value ?? 0,
solved: solved[0]?.value ?? 0,
memory,
todaySolved: today[0]?.value ?? 0,
tooHard: tooHard[0]?.value ?? 0,
week: {
submits: weekSubmits[0]?.value ?? 0,
trials: eventCount('trial'),
helps: eventCount('help'),
aiHints: weekHints[0]?.value ?? 0,
},
recent: {
total: recent.length,
wrongAnswer: tally(-1),
compileError: tally(-2),
runtimeError: tally(4, 3),
timeout: tally(1, 2),
runtimeErrorType: topType,
},
} satisfies MascotProfile)
})
+26 -5
View File
@@ -12,6 +12,7 @@ import {
type ProblemListItem, type ProblemListItem,
type ProblemProgress, type ProblemProgress,
type ProblemTypeFilter, type ProblemTypeFilter,
type SimilarProblem,
type Tag, type Tag,
} from '@oj2/contract' } from '@oj2/contract'
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm' import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
@@ -327,13 +328,25 @@ problemRoutes.get('/challenges', requireAuth, async (c) => {
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => { problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const [target] = await db const [target] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id, difficulty: schema.problem.difficulty })
.from(schema.problem) .from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId))) .where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId)))
.limit(1) .limit(1)
if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found') if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found')
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id)) // 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面
if (targetTags.length === 0) return success(c, []) // 只是为了「共同的标签」那行先写练的是什么
const targetTags = await db
.select({ id: schema.problemTags.problemtagId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTags.problemId, target.id))
// 同一类里大的在前,和题目列表「知识点」那列一样
.orderBy(
sql`${schema.problemTag.category} = 'knowledge' desc`,
sql`(select count(*) from ${schema.problemTags} pt where pt.problemtag_id = ${schema.problemTag.id}) desc`,
asc(schema.problemTag.name),
)
if (targetTags.length === 0) return success(c, [] satisfies SimilarProblem[])
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的 // 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)—— // 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
@@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const solvedIds = states ? [...states.solved] : [] const solvedIds = states ? [...states.solved] : []
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。 // High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
// 共同标签一样多时先给和原题难度最接近的:原来一律简单的在前,做对一道困难题,
// 五条推荐里三条多是简单题(本地库 35 道困难题:119 / 175)。再并列才先简单的,
// 照顾连错三次的人
const rankOf = (difficulty: string) => (difficulty === 'Low' ? 0 : difficulty === 'Mid' ? 1 : 2)
const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end` const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end`
const difficultyGap = sql`abs(${difficultyRank} - ${sql.raw(String(rankOf(target.difficulty)))})`
const sharedTags = count(schema.problemTags.problemtagId) const sharedTags = count(schema.problemTags.problemtagId)
const rows = await db const rows = await db
.select({ .select({
@@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
) )
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) .groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
// 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条 // 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
.orderBy(desc(sharedTags), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id)) .orderBy(desc(sharedTags), asc(difficultyGap), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id))
.limit(5) .limit(5)
const ids = rows.map((row) => row.problem.id) const ids = rows.map((row) => row.problem.id)
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)]) const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
return success( return success(
c, c,
rows.map((row) => listItem(row, tags, statuses, states, counts)), rows.map((row) => {
const own = new Set(tags.get(row.problem.id))
return { ...listItem(row, tags, statuses, states, counts), sharedTags: targetTags.map((tag) => tag.name).filter((name) => own.has(name)) }
}) satisfies SimilarProblem[],
) )
}) })
+12 -7
View File
@@ -17,22 +17,22 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'accepted_count', key: 'accepted_count',
name: '做对的题数', name: '做对的题数',
helpText: '去重后通过的题目数量(不含比赛)', helpText: '去重后通过的题目数量(含比赛题)',
}, },
{ {
key: 'mid_ac_count', key: 'mid_ac_count',
name: '做对的中等题', name: '做对的中等题',
helpText: '去重后通过的中等难度题目数(不含比赛)', helpText: '去重后通过的中等难度题目数(含比赛题)',
}, },
{ {
key: 'hard_ac_count', key: 'hard_ac_count',
name: '做对的困难题', name: '做对的困难题',
helpText: '去重后通过的困难题目数(不含比赛)', helpText: '去重后通过的困难题目数(含比赛题)',
}, },
{ {
key: 'submission_count', key: 'submission_count',
name: '提交次数', name: '提交次数',
helpText: '提交次数(不含比赛)', helpText: '提交次数(含比赛)',
}, },
{ key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' }, { key: 'active_days', name: '有提交的天数', helpText: '有过提交的累计天数' },
{ {
@@ -45,13 +45,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'max_ac_week_streak', key: 'max_ac_week_streak',
name: '连续有新做对的周数', name: '连续有新做对的周数',
helpText: '连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)', helpText: '连续几个自然周(周一起)每周都至少新通过一道题(含比赛题)',
}, },
{ key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' }, { key: 'languages_used', name: '用过几种语言', helpText: '用过多少种编程语言' },
{ {
key: 'contest_joined', key: 'contest_joined',
name: '参加比赛', name: '参加比赛',
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)', helpText: '交过题的比赛数量',
}, },
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' }, { key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
{ {
@@ -59,6 +59,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
name: '做完的专题', name: '做完的专题',
helpText: '完成的专题数量', helpText: '完成的专题数量',
}, },
{
key: 'challenge_count',
name: '挑战成功',
helpText: '做对之后的挑战,做到了几条(每道题的每条挑战算一次)',
},
{ {
key: 'first_try_ac_count', key: 'first_try_ac_count',
name: '一次就对的题', name: '一次就对的题',
@@ -92,7 +97,7 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ {
key: 'max_code_lines', key: 'max_code_lines',
name: '最长的代码行数', name: '最长的代码行数',
helpText: '做对的提交里最长的代码有多少行(空行不算,不含比赛)', helpText: '做对的提交里最长的代码有多少行(空行不算,含比赛)',
}, },
{ {
key: 'achievement_unlocked_count', key: 'achievement_unlocked_count',
+20 -10
View File
@@ -1,4 +1,4 @@
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, lt, ne, notInArray, sql } from 'drizzle-orm' import { and, count, countDistinct, eq, inArray, isNotNull, lt, ne, notInArray, sql } from 'drizzle-orm'
import { db, schema, type DbOrTx } from '../db' import { db, schema, type DbOrTx } from '../db'
import { publishAchievementNotification } from '../events' import { publishAchievementNotification } from '../events'
@@ -82,12 +82,17 @@ const ACCEPTED_RESULTS = sql.join([sql`${JudgeStatus.ACCEPTED}`, sql`${JudgeStat
/** /**
* 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。 * 「做对几道 / 中等几道 / 困难几道 / 一次就对几道」按定义从提交表现数,不再逐次 +1。
* 参加过几场比赛、挑战成功几次也在这里一起数。
* *
* 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题 * 原来是读一遍这道题之前的提交、判断「是不是第一次做对」再 +1,两处漏:同一个人同一道题
* 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条 * 两条提交挨着判完,两边都看见对方已经做对(都不加)或都还没做对(加两次);重判一条
* 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。 * 做对的提交又会再加一次。2026-10 的库里 372 人的做对题数比实际多 1–4 道。
* 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx, * 现数是幂等的,怎么并发、怎么重判都是同一个答案。走 submission_public_metrics_idx,
* 一个人的提交也就几百条。 * 一个人的提交也就几百条。
*
* **比赛里的提交也算**(2026-11-03 起):这里的比赛其实是单班测验,有的班整学期都在
* 比赛里做题,不算的话一个成就都拿不到。比赛题是单独的题目行,不会和题库里同一道题重复数。
* 个人主页 / 排名的做对题数(user_profile.accepted_number)仍然不含比赛,两边对不上是有意的。
*/ */
async function solvedCounts(tx: DbOrTx, userId: number) { async function solvedCounts(tx: DbOrTx, userId: number) {
const [row] = await tx.execute<{ const [row] = await tx.execute<{
@@ -95,13 +100,15 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
mid: number mid: number
hard: number hard: number
first_try: number first_try: number
contests: number
challenges: number
}>( }>(
sql` sql`
with mine as ( with mine as (
select s.problem_id, s.result, s.create_time, s.id, p.difficulty select s.problem_id, s.contest_id, s.result, s.create_time, s.id, p.difficulty
from ${schema.submission} s from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id join ${schema.problem} p on p.id = s.problem_id
where s.user_id = ${userId} and s.contest_id is null where s.user_id = ${userId}
) )
select select
count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted, count(distinct problem_id) filter (where result in (${ACCEPTED_RESULTS}))::int as accepted,
@@ -111,11 +118,13 @@ async function solvedCounts(tx: DbOrTx, userId: number) {
select count(*)::int from ( select count(*)::int from (
select distinct on (problem_id) result from mine order by problem_id, create_time, id select distinct on (problem_id) result from mine order by problem_id, create_time, id
) first where result in (${ACCEPTED_RESULTS}) ) first where result in (${ACCEPTED_RESULTS})
) as first_try ) as first_try,
count(distinct contest_id)::int as contests,
(select count(*)::int from ${schema.challengeSuccess} where user_id = ${userId}) as challenges
from mine from mine
`, `,
) )
return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0 } return row ?? { accepted: 0, mid: 0, hard: 0, first_try: 0, contests: 0, challenges: 0 }
} }
/** /**
@@ -130,7 +139,7 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)) .where(eq(schema.submission.id, submissionId))
.limit(1) .limit(1)
if (!row || row.submission.contestId !== null) return [] if (!row) return []
const rejudged = countedAs !== null const rejudged = countedAs !== null
const accepted = isAccepted(row.submission.result) const accepted = isAccepted(row.submission.result)
@@ -157,6 +166,8 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
value.mid_ac_count = solved.mid value.mid_ac_count = solved.mid
value.hard_ac_count = solved.hard value.hard_ac_count = solved.hard
value.first_try_ac_count = solved.first_try value.first_try_ac_count = solved.first_try
value.contest_joined = solved.contests
value.challenge_count = solved.challenges
if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1 if (!rejudged) value.submission_count = numberMetric(value, 'submission_count') + 1
if (firstAc) { if (firstAc) {
// 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」 // 只数这条之前的:重判一条老提交时,它后面的提交不算「做对之前错的」
@@ -167,7 +178,6 @@ export async function updateAchievementsForSubmission(submissionId: string, coun
and( and(
eq(schema.submission.userId, row.submission.userId), eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId), eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
lt(schema.submission.createTime, row.submission.createTime), lt(schema.submission.createTime, row.submission.createTime),
), ),
) )
@@ -301,7 +311,7 @@ export async function rescanAchievement(achievementId: number) {
const metric = findMetric(achievement.metric) const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 } if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) // contest_joined 判题结算从 2026-11-03 起才维护,那之后没再交过题的人还是旧值,扫之前刷新一遍
if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll() if (achievement.metric === 'contest_joined') await refreshContestJoinedForAll()
// max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算 // max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算
if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll() if (achievement.metric === 'max_ac_week_streak') await refreshAcWeekStreakForAll()
@@ -482,7 +492,7 @@ async function refreshContestJoinedForAll() {
* `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并, * `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并,
* 其余指标不动,写法同 refreshContestJoinedForAll)。 * 其余指标不动,写法同 refreshContestJoinedForAll)。
* *
* 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径; * 「新通过」= 这道题(含比赛题)第一次通过的那次,和判题结算里 firstAc 同一个口径;
* 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。 * 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。
*/ */
async function refreshAcWeekStreakForAll() { async function refreshAcWeekStreakForAll() {
@@ -495,7 +505,7 @@ async function refreshAcWeekStreakForAll() {
// 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有 // 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有
// 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败 // 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(isNull(schema.submission.contestId), inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))) .where(inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]))
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
.as('first_accepted') .as('first_accepted')
const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')` const week = sql<string>`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')`
+10
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants' import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types' import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
import ClassCard from './components/ClassCard.vue' import ClassCard from './components/ClassCard.vue'
@@ -59,6 +60,15 @@ async function load() {
onMounted(load) onMounted(load)
watch(name, load) watch(name, load)
// 看板娘:只在看自己的成就时说话。「快拿到了」里最近的一个能说成「再做对 3 道」的,报它的名字;
// 「还差 3 次」「一天做对 5 道」这种前面加不了「再」,往后找
useMascotGuide(() => {
if (!data.value || other.value || userStore.isTeacherOrAbove) return null
const item = near.value.find((it) => it.remain.startsWith('再'))
if (!item) return { situation: 'achievement' }
return { situation: 'achievement.near', vars: { name: item.achievement.name, need: item.remain.slice(1) } }
})
</script> </script>
<template> <template>
+6
View File
@@ -34,6 +34,12 @@ const METRICS: Record<string, MetricInfo> = {
unit: '道', unit: '道',
remain: (r) => `再做对 ${r} 道困难题`, remain: (r) => `再做对 ${r} 道困难题`,
}, },
challenge_count: {
group: '做题',
label: '挑战成功',
unit: '次',
remain: (r) => `再挑战成功 ${r} 次`,
},
max_ac_week_streak: { max_ac_week_streak: {
group: '坚持', group: '坚持',
label: '连着几周有做对', label: '连着几周有做对',
+4
View File
@@ -5,6 +5,7 @@ import AdminLink from 'shared/components/AdminLink.vue'
import NotFound from 'shared/components/NotFound.vue' import NotFound from 'shared/components/NotFound.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api' import { errorCode } from 'utils/api'
import { parseTime, zonedParts, zonedYear } from 'utils/functions' import { parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { Announcement, AnnouncementListItem } from 'utils/types' import type { Announcement, AnnouncementListItem } from 'utils/types'
@@ -27,6 +28,9 @@ const { isDesktop } = useBreakpoints()
const announcements = ref<AnnouncementListItem[]>([]) const announcements = ref<AnnouncementListItem[]>([])
const loaded = ref(false) const loaded = ref(false)
// 看板娘:只念叨一句,不给按钮
useMascotGuide(() => ({ situation: 'announcement' }))
onMounted(async () => { onMounted(async () => {
const res = await getAnnouncementList(0, LIMIT) const res = await getAnnouncementList(0, LIMIT)
announcements.value = res.results announcements.value = res.results
+4 -1
View File
@@ -41,6 +41,7 @@ import {
type ProblemRank, type ProblemRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemListItem, type ProblemListItem,
type SimilarProblem,
type ProblemStats, type ProblemStats,
type ProblemProgress, type ProblemProgress,
type CreateFlowchartResponse, type CreateFlowchartResponse,
@@ -412,7 +413,9 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
return api.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`).then((response) => response.map(toProblemRow)) return api
.get<SimilarProblem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map((item) => ({ ...toProblemRow(item), sharedTags: item.sharedTags })))
} }
// ==================== 挑战 ==================== // ==================== 挑战 ====================
+9
View File
@@ -12,6 +12,7 @@ import { usePagination } from 'shared/composables/pagination'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { ContestStatus } from 'utils/constants' import { ContestStatus } from 'utils/constants'
import ContestBadge from './components/ContestBadge.vue' import ContestBadge from './components/ContestBadge.vue'
@@ -115,6 +116,14 @@ function open(contest: Contest) {
router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`) router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`)
} }
// 看板娘:有正在比的就带进去
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const live = active.value.find((c) => c.status === ContestStatus.underway)
if (!live) return { situation: 'contests.none' }
return { situation: 'contests.live', actions: [goAction(router, '进比赛', `/contest/${live.id}`)] }
})
function mineText(contest: Contest) { function mineText(contest: Contest) {
const mine = contest.mine const mine = contest.mine
if (!mine) return '' if (!mine) return ''
+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 { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
import { getClassActivity } from 'shared/api' import { getClassActivity } from 'shared/api'
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue' import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
import { pickHeadline, type Headline } from 'oj/user/knowledge' import { levelName, MAX_LEVEL, pickHeadline, remainingToNext, tagLink, type Headline } from 'oj/user/knowledge'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { activityMode } from 'shared/store/lesson' import { activityMode } from 'shared/store/lesson'
import { focusJumpAction, goAction, randomProblemAction, useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants' import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions' import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types' import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, KnowledgeMap, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
interface Track { interface Track {
type: TutorialType type: TutorialType
@@ -36,6 +37,8 @@ const classActivity = ref<ClassActivity | null>(null)
const lastVisit = ref<LastVisit | null>(null) const lastVisit = ref<LastVisit | null>(null)
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */ /** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
const knowledgeHeadline = ref<Headline | null>(null) const knowledgeHeadline = ref<Headline | null>(null)
/** 地图原样留着给看板娘:她要知识点名、档位名、还差几道,不是拼好的那句话 */
const knowledge = ref<KnowledgeMap | null>(null)
const loaded = ref(false) const loaded = ref(false)
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */ /** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
@@ -123,7 +126,8 @@ async function loadAnnouncements() {
} }
async function loadKnowledge() { async function loadKnowledge() {
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap()) knowledge.value = await getKnowledgeMap()
knowledgeHeadline.value = pickHeadline(knowledge.value)
} }
async function loadClassActivity() { async function loadClassActivity() {
@@ -244,6 +248,86 @@ watch(
{ immediate: true }, { immediate: true },
) )
// 看板娘:今天有课(和「这节课」那张卡同一个条件)就催今天的题,没课就随便做一道
let greetedBack = false
useMascotGuide(() => {
if (!loaded.value) return null
const random = randomProblemAction(router)
const learn = goAction(router, '自学', '/learn')
const lesson = classToday.value && !!classActivity.value?.problems.length
const next = nextProblem.value
if (lesson && next)
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
const base: MascotGuide = lesson
? { situation: 'home.done', actions: [random, learn] }
: { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
// 好久没来:只在这次进来后第一次点她时说,之后照常
if (awayDays.value >= 14 && !greetedBack) {
greetedBack = true
return { ...base, situation: 'home.back', n: awayDays.value }
}
// 这节课做完了或者没课:从别的数据里挑能说的,每点一次随机说一件,点几下听到的不一样
const extras = extraGuides(random)
return extras.length ? extras[Math.floor(Math.random() * extras.length)]! : base
})
const DAY_MS = 864e5
function extraGuides(random: MascotAction): MascotGuide[] {
const list: MascotGuide[] = []
// 比赛:进行中,或者 24 小时内开始(那就只可能是今天或明天)
const now = Date.now()
const contest = contests.value.find((it) => it.status === ContestStatus.underway || Date.parse(it.startTime) - now < DAY_MS)
if (contest) {
const when = contest.status === ContestStatus.underway ? '现在' : dayKey(contest.startTime) === dayKey(new Date()) ? '今天' : '明天'
list.push({ situation: 'home.contestSoon', vars: { when }, actions: [goAction(router, '去看看', `/contest/${contest.id}`), random] })
}
// 公告:三天内发的,挑最新那条
const fresh = announcements.value.filter((it) => now - Date.parse(it.createTime) < 3 * DAY_MS).sort((a, b) => b.createTime.localeCompare(a.createTime))[0]
if (fresh) {
list.push({
situation: 'home.newAnnouncement',
actions: [{ label: '去看看', run: () => router.push({ path: '/announcement', query: { id: fresh.id } }) }, random],
})
}
// 知识点:和首页那一行同一个先后(本周升级 > 离下一档最近 > 班里在学、自己还没碰过),只说一件
const map = knowledge.value
if (map) {
const touched = new Set(map.classTouched)
const go = (name: string) => ({ label: '去做', run: () => router.push(tagLink(name)) })
const upgraded = map.tags.filter((tag) => tag.level > tag.levelAtWeekStart).sort((a, b) => b.level - a.level)[0]
const learning = map.tags
.filter((tag) => tag.level >= 1 && tag.level < MAX_LEVEL)
.sort((a, b) => Number(touched.has(b.name)) - Number(touched.has(a.name)) || remainingToNext(a)! - remainingToNext(b)!)[0]
const untouched = map.tags.find((tag) => tag.level === 0 && touched.has(tag.name))
if (upgraded)
list.push({ situation: 'home.knowledgeUp', vars: { tag: upgraded.name, level: levelName(upgraded.level) }, actions: [go(upgraded.name), random] })
else if (learning)
list.push({
situation: 'home.knowledgeNext',
n: remainingToNext(learning)!,
vars: { tag: learning.name, level: levelName(learning.level + 1) },
actions: [go(learning.name), random],
})
else if (untouched) list.push({ situation: 'home.knowledgeFresh', vars: { tag: untouched.name }, actions: [go(untouched.name), random] })
}
// 本周进步榜:第一就说第一;前一名在榜面上(只回前几名)、差得不多就报还要几道。
// 同分时提交少的在前,追平不一定超得过,所以按多做 1 道算
const me = weekly.value?.me
if (me) {
const rank = goAction(router, '去排名', '/rank?period=week')
const ahead = weekly.value!.results.find((row) => row.rank === me.rank - 1)
if (me.rank === 1) list.push({ situation: 'rank.top', actions: [rank, random] })
else if (ahead && ahead.solvedCount - me.solvedCount <= 5)
list.push({ situation: 'rank.gap', n: ahead.solvedCount - me.solvedCount + 1, actions: [rank, random] })
}
return list
}
function learnLink(track: Track) { function learnLink(track: Track) {
return tutorialPath(track.type, track.step) return tutorialPath(track.type, track.step)
} }
+27
View File
@@ -6,6 +6,7 @@ import { useThemeVars } from 'naive-ui'
import { getAnnouncementList, getClassToday } from 'oj/api' import { getAnnouncementList, getClassToday } from 'oj/api'
import { classLabel } from 'utils/classes' import { classLabel } from 'utils/classes'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { parseTime, zonedParts, zonedYear } from 'utils/functions' import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/** /**
@@ -66,6 +67,32 @@ function openTask(row: ClassRow) {
router.push({ name: 'task', query: { className: row.className } }) router.push({ name: 'task', query: { className: row.className } })
} }
/**
* 看板娘:今天有班布置了题,就带去那个班这节课的提交(和提交列表「这节课」同一组筛选:班、这几道题、今天),
* 有好几个班时挑正在上课的那个
*/
useMascotGuide(() => {
if (!today.value) return null
const assigned = today.value.classes.filter((row) => row.source === 'teacher' && row.problems.length)
const row = assigned.find((it) => stateOf(it).live) ?? assigned[0]
if (!row) return { situation: 'teacher.home.unassigned', actions: [goAction(router, '布置题目', '/task')] }
const query = { className: row.className, problem: row.problems.map((problem) => problem.problemDisplayId).join(','), today: '1' }
// 有人交过了就说这节课做完几个,还没人交就照旧那几句
const progress = row.activeCount
? {
situation: row.allDoneCount >= row.rosterCount ? ('teacher.home.progressDone' as const) : ('teacher.home.progress' as const),
vars: { cls: classLabel(row.className), done: row.allDoneCount, total: row.rosterCount, left: row.rosterCount - row.allDoneCount },
}
: { situation: 'teacher.home.assigned' as const }
return {
...progress,
actions: [
{ label: '这节课的提交', run: () => router.push({ name: 'submissions', query }) },
{ label: '课堂任务', run: () => openTask(row) },
],
}
})
const shortcuts = computed(() => const shortcuts = computed(() =>
[ [
{ label: '课堂任务', to: '/task', show: true }, { label: '课堂任务', to: '/task', show: true },
+18
View File
@@ -6,6 +6,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils' import { COURSES, continueStep, doneCount, groupByProject, tutorialPath, tutorialState, splitProject, splitTitle, type TutorialType } from './utils'
import TutorialRow from './components/TutorialRow.vue' import TutorialRow from './components/TutorialRow.vue'
import ProjectTile from './components/ProjectTile.vue' import ProjectTile from './components/ProjectTile.vue'
@@ -94,6 +95,23 @@ function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length return ids.filter((id) => tutorialState(course.progress[id]) === 'done').length
} }
// 看板娘:学过就带去「接着学」那一课,一课没学过就从当前页签那门课的第 1 课开始
useMascotGuide(() => {
if (!loaded.value || userStore.isTeacherOrAbove) return null
const it = resume.value
if (it) {
const next = goAction(router, '接着学', tutorialPath(it.course.type, it.step))
// 接着学的那课之前,离它最近的一课打开过、练一练却没全对(「在学」那一态):先叫回去看看
const { titles, progress } = it.course
for (let step = it.step - 1; step >= 1; step--) {
if (tutorialState(progress[titles[step - 1]!.id]) !== 'started') continue
return { situation: 'learn.wrongExercise', n: step, actions: [goAction(router, '去看看', tutorialPath(it.course.type, step)), next] }
}
return { situation: 'learn.continue', n: it.step, actions: [next] }
}
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
})
watch(() => userStore.isAuthed, load) watch(() => userStore.isAuthed, load)
onMounted(load) onMounted(load)
</script> </script>
+11
View File
@@ -10,6 +10,7 @@ import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils' import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
import TutorialBody from './components/TutorialBody.vue' import TutorialBody from './components/TutorialBody.vue'
import TutorialEnd from './components/TutorialEnd.vue' import TutorialEnd from './components/TutorialEnd.vue'
@@ -233,6 +234,16 @@ function jump(id: string) {
function jumpExercise(id: number) { function jumpExercise(id: number) {
jump(`exercise-${id}`) jump(`exercise-${id}`)
} }
// 看板娘:练一练没做完就带去第一道没做对的;做完了(或这课没有练一练)就去下一课
useMascotGuide(() => {
if (!tutorial.value || userStore.isTeacherOrAbove) return null
const to = next.value?.to
const nextLesson: MascotAction[] = to ? [{ label: '下一课', run: () => router.push(to) }] : []
const unsolved = firstUnsolved.value
if (!unsolved) return { situation: 'tutorial.passed', actions: nextLesson }
return { situation: 'tutorial', actions: [{ label: '练一练', run: () => jumpExercise(unsolved.id) }, ...nextLesson] }
})
</script> </script>
<template> <template>
+5
View File
@@ -6,6 +6,7 @@ import { classLabel, groupClassOptions } from 'utils/classes'
import { useAIStream } from 'shared/composables/aiStream' import { useAIStream } from 'shared/composables/aiStream'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import PkDuel from './components/PkDuel.vue' import PkDuel from './components/PkDuel.vue'
import PkGrid from './components/PkGrid.vue' import PkGrid from './components/PkGrid.vue'
import PkRace from './components/PkRace.vue' import PkRace from './components/PkRace.vue'
@@ -31,6 +32,10 @@ const { isDesktop } = useBreakpoints()
const MAX_CLASSES = 8 const MAX_CLASSES = 8
const teacher = computed(() => userStore.isTeacherOrAbove) const teacher = computed(() => userStore.isTeacherOrAbove)
// 看板娘:学生念叨一句班级对决,老师只打招呼
useMascotGuide(() => (teacher.value ? null : { situation: 'pk' }))
const pk = ref<ClassPk | null>(null) const pk = ref<ClassPk | null>(null)
const loading = ref(false) const loading = ref(false)
const battle = ref<ClassBattleItem[]>([]) const battle = ref<ClassBattleItem[]>([])
@@ -333,7 +333,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<Markdown html :content="problem.source" /> <Markdown html :content="problem.source" />
</section> </section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" /> <SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
</div> </div>
</template> </template>
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { ProblemRow } from 'utils/types' import type { ProblemRow } from 'utils/types'
type SimilarRow = ProblemRow & { sharedTags: string[] }
/** /**
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了 * 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
* 每个实例各一份 * 每个实例各一份
*/ */
const cache = new Map<string, Promise<ProblemRow[]>>() const cache = new Map<string, Promise<SimilarRow[]>>()
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
@@ -13,17 +15,26 @@ import { storeToRefs } from 'pinia'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { getSimilarProblems } from 'oj/api' import { getSimilarProblems } from 'oj/api'
import { useProblemStore } from 'oj/store/problem' import { useProblemStore } from 'oj/store/problem'
import { useTone } from 'shared/composables/tone'
import { problemTypes } from '../utils/problemType'
import ProblemStatus from './ProblemStatus.vue'
import ProblemTypeTag from './ProblemTypeTag.vue'
/** /**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后) * 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。 * 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里和「提示」「来源」一样带绿竖条,结果页签里和「还能挑战」对齐。
*
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)
*/ */
const props = defineProps<{ title: string }>() const props = defineProps<{ title: string; place: 'content' | 'result' }>()
const theme = useThemeVars() const theme = useThemeVars()
const tone = useTone()
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
const list = ref<ProblemRow[]>([]) const list = ref<SimilarRow[]>([])
watch( watch(
() => problem.value?._id, () => problem.value?._id,
@@ -41,30 +52,36 @@ watch(
{ immediate: true }, { immediate: true },
) )
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High // 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
function difficultyType(difficulty: string) { const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
if (difficulty === '简单') return 'success'
if (difficulty === '困难') return 'error' const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
return 'warning'
function difficultyStyle(row: SimilarRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
} }
</script> </script>
<template> <template>
<section v-if="list.length" class="similar"> <section v-if="list.length" class="similar" :class="props.place">
<h3 class="title">{{ props.title }}</h3> <h3 class="heading">{{ props.title }}</h3>
<n-list bordered> <div class="rows">
<n-list-item v-for="sp in list" :key="sp._id"> <router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<n-flex align="center" justify="space-between" :wrap="false"> <span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link"> <span class="id">{{ sp._id }}</span>
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag> <span class="name">
<span class="name">{{ sp.title }}</span> <span class="text">{{ sp.title }}</span>
</router-link> <ProblemTypeTag v-for="kind in problemTypes(sp)" :key="kind" :kind="kind" />
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)"> </span>
{{ sp.difficulty }} <span v-if="sp.difficulty" class="difficulty" :style="difficultyStyle(sp)">{{ sp.difficulty }}</span>
</n-tag> <span class="shared">{{ sp.sharedTags.join(' · ') }}</span>
</n-flex> <span class="people"
</n-list-item> ><b>{{ sp.solvedUsers }}</b> 人做对</span
</n-list> >
</router-link>
</div>
</section> </section>
</template> </template>
@@ -73,34 +90,135 @@ function difficultyType(difficulty: string) {
margin-top: 20px; margin-top: 20px;
} }
.title { .similar.result {
font-size: 16px; margin-top: 14px;
font-weight: 600; }
.heading {
margin: 0 0 8px; margin: 0 0 8px;
} }
/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */ /* 和 ProblemContent 的小节标题(提示 / 来源)一样 */
.similar :deep(.n-list-item__main) { .content .heading {
min-width: 0; font-size: 16px;
font-weight: 700;
line-height: 1.4;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
} }
.link { /* 和结果页签里的「还能挑战」一样 */
display: inline-flex; .result .heading {
font-size: 15px;
font-weight: 700;
}
.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; align-items: center;
gap: 8px; gap: 4px 12px;
min-width: 0; padding: 10px 16px;
color: inherit; color: v-bind('theme.textColor1');
text-decoration: none; text-decoration: none;
} }
.link:hover .name { .row.with-status {
grid-template-columns: 16px auto minmax(0, 1fr) auto;
grid-template-areas:
'status id name difficulty'
'. . shared people';
}
.row + .row {
border-top: 1px solid v-bind('theme.dividerColor');
}
.row:hover {
background: v-bind('theme.hoverColor');
}
.row:hover .text {
text-decoration: underline; text-decoration: underline;
} }
.status {
grid-area: status;
display: flex;
justify-content: center;
}
.id {
grid-area: id;
min-width: 36px;
font-size: 14px;
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
}
.name { .name {
grid-area: name;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.text {
min-width: 0;
font-size: 15px;
color: v-bind('theme.primaryColor'); color: v-bind('theme.primaryColor');
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.difficulty {
grid-area: difficulty;
justify-self: end;
height: 22px;
padding: 0 7px;
border-radius: 4px;
font-size: 13px;
display: inline-flex;
align-items: center;
}
.shared {
grid-area: shared;
min-width: 0;
font-size: 13px;
color: v-bind('theme.textColor3');
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.people {
grid-area: people;
justify-self: end;
font-size: 13px;
color: v-bind('theme.textColor3');
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.people b {
font-weight: 600;
color: v-bind('theme.textColor2');
}
</style> </style>
@@ -357,7 +357,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
:language="submission.language" :language="submission.language"
:refresh-key="submission.id" :refresh-key="submission.id"
/> />
<SimilarProblems v-if="showSimilar" title="再练几道相似的" /> <SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
</div> </div>
</template> </template>
<div v-show="!waitingReveal"> <div v-show="!waitingReveal">
@@ -0,0 +1,273 @@
import { useBreakpoints } from 'shared/composables/breakpoints'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { HINT_MIN_FAILURES, isFlowchartPass } from '@oj2/contract'
import { habitSituation, logMascotEvent, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
import { useChallengeStore } from 'oj/store/challenge'
import { useFlowchartStore } from 'oj/store/flowchart'
import type { MascotSituation } from 'shared/mascot/lines'
import { useCollabStore } from 'shared/store/collab'
import { useLessonStore } from 'shared/store/lesson'
import { useUserStore } from 'shared/store/user'
import { useCodeStore } from 'oj/store/code'
import { useProblemStore } from 'oj/store/problem'
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
import { SubmissionStatus } from 'utils/constants'
import type { ProblemDetail } from 'utils/types'
import { useProblemPageContext } from './problemPageContext'
import { useTeacherCollab } from './teacherCollab'
/** 同一道题错到这么多次,她就不怼了,劝去举手 */
const STUCK_FAILS = 5
/** 错过这么多次以后做对,算「终于」 */
const FINALLY_FAILS = 2
const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
[SubmissionStatus.wrong_answer]: 'problem.wrong',
[SubmissionStatus.partial_accepted]: 'problem.wrong',
[SubmissionStatus.ast_check_failed]: 'problem.wrong',
[SubmissionStatus.compile_error]: 'problem.compileError',
[SubmissionStatus.runtime_error]: 'problem.runtimeError',
[SubmissionStatus.memory_limit_exceeded]: 'problem.runtimeError',
[SubmissionStatus.cpu_time_limit_exceeded]: 'problem.timeout',
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
}
/** 一道题上这么久没交、没跑例子,她就来问一句(主动开口) */
const IDLE_MS = 15 * 60_000
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
const UNSAMPLED_SUBMITS = 3
/** 同班做对这么多人,就拿出来说「全班已经 N 个人做对了」 */
const CLASS_MANY = 5
/** 同班交过这么多人、做对的不超过 CLASS_FEW 个,算「全班都卡着」 */
const CLASS_TRIED = 5
const CLASS_FEW = 2
/** 全站交过这么多次,通过率才有意义 */
const RATE_MIN_SUBMISSIONS = 30
const HARD_RATE = 0.3
const EASY_RATE = 0.7
/** 几种「换个说法」各自的机会:不是每次都说同一件事 */
const CHANCE = { classMany: 0.3, classFew: 0.5, rate: 0.4, hint: 0.5, challenge: 0.4 } as const
const roll = (chance: number) => Math.random() < chance
const FINISHED = (result: number | undefined) =>
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
/**
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
*
* 另外几件事她会主动开口(只对学生,频率上限在 Mascot.vue):同一道题错到第 5 次、连着 3 次
* 没跑例子就交还错了、又犯了自己的老毛病(behavior.ts 的 habitSituation)、15 分钟没交没跑。
*
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
*/
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
const router = useRouter()
const userStore = useUserStore()
const problemStore = useProblemStore()
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const collabStore = useCollabStore()
const lessonStore = useLessonStore()
const ctx = useProblemPageContext()
const teacherCollab = useTeacherCollab()
const { isDesktop } = useBreakpoints()
const flowchartStore = useFlowchartStore()
const challengeStore = useChallengeStore()
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
function studentActions(problem: ProblemDetail) {
const language = codeStore.code.language
const entry = ctx.value
const runSamples: MascotAction | null =
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
const help: MascotAction | null =
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
: null
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
const whySamples: MascotAction = { label: '我错在哪', run: () => showResult('samples') }
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
const submit: MascotAction | null = busy
? null
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
return { runSamples, help, why, whySamples, submit }
}
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
situation,
actions: actions.filter((it): it is MascotAction => !!it),
})
useMascotGuide(() => {
const problem = problemStore.problem
if (!problem) return null
if (userStore.isTeacherOrAbove) {
// 协作中别把老师带离这一页:跳走协作就断了
if (teacherCollab.value) return null
const actions: MascotAction[] = [{ label: '全班这题的提交', run: () => router.push({ name: 'submissions', query: { problem: problem._id } }) }]
if (isDesktop.value && userStore.canEditProblem(problem)) {
actions.push({ label: '编辑这道题', run: () => window.open(router.resolve(`/admin/problem/edit/${problem.id}`).href, '_blank') })
}
return { situation: 'teacher.problem', actions }
}
const entry = ctx.value
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
if (codeStore.code.language === 'Flowchart') {
// 画过、评过分了:分低就说分低,拿了 S 夸一句
const grade = flowchartStore.latestRating.grade
if (grade === 'S') return guide('flowchart.good', help)
if (grade && !isFlowchartPass(grade)) return guide('flowchart.low', help)
return guide('problem.flowchart', help)
}
// 正在判、正在跑例子:没什么好说的,打个招呼
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
const stuck = problemStore.failCount >= STUCK_FAILS
if (submissionStore.syntaxErrorInfo) return stuck ? guide('problem.stuck', help, why) : guide('problem.compileError', why, help)
const runs = submissionStore.trial.sampleRuns.value
if (submissionStore.resultSegment === 'samples' && runs.length) {
return runs.every((run) => run.result === SubmissionStatus.accepted)
? guide('problem.samplesPassed', submit)
: guide('problem.samplesFailed', whySamples, help)
}
const result = submissionStore.submission?.result
const lesson = lessonStore.find(problem._id)
if (result === SubmissionStatus.accepted) {
const next = nextAction()
// 课堂条的数是拉的时候的,刚做对重拉回来可能已经把自己算进去了
if (lesson && lesson.acceptedCount <= (lesson.myStatus === 'accepted' ? 1 : 0)) return guide('class.firstSolver', next)
if (entry.challenges && challengeStore.current?.challenges.length && roll(CHANCE.challenge)) return guide('problem.challenge', next)
const fails = problemStore.failCount
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
}
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
if (failed) {
if (stuck) return guide('problem.stuck', help, why)
const actions = [why, failed === 'problem.wrong' ? runSamples : null, help]
// AI 提示开了还没要:提一句,按钮带去结果里(提示在那儿)
const hintOpen = !problem.contestId && problemStore.failCount >= HINT_MIN_FAILURES && !submissionStore.hint.hintContent.value
if (hintOpen && roll(CHANCE.hint)) return guide('problem.hintReady', ...actions)
const classGuide = classSituation(lesson)
if (classGuide) return { ...guide(classGuide.situation, ...actions), n: classGuide.n }
const rate = problem.submissionNumber >= RATE_MIN_SUBMISSIONS ? problem.acceptedNumber / problem.submissionNumber : null
if (rate !== null && rate < HARD_RATE && roll(CHANCE.rate)) return guide('problem.hardWrong', ...actions)
if (rate !== null && rate > EASY_RATE && failed === 'problem.wrong' && roll(CHANCE.rate)) return guide('problem.easyWrong', ...actions)
return guide(failed, ...actions)
}
// 这两种只说话、只留举手:上线第一天「做过了」的「下一道」没人点过,空着代码跑例子也没意义,
// 而空白页上「举手求助」是真有人点(不会下手)。没有按钮的气泡 5 秒自己收起,不一直挡着
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore')
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', help)
const classGuide = classSituation(lesson)
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
return guide('problem.unsubmitted', runSamples, help)
/** 这节课的题:同班多少人做对了(课堂条那份数),不在这节课里就没有 */
function classSituation(item: typeof lesson) {
if (!item || item.myStatus === 'accepted') return null
if (item.acceptedCount >= CLASS_MANY && roll(CHANCE.classMany)) return { situation: 'class.manyDone' as const, n: item.acceptedCount }
if (item.userCount >= CLASS_TRIED && item.acceptedCount <= CLASS_FEW && roll(CHANCE.classFew))
return { situation: 'class.fewDone' as const, n: item.acceptedCount }
return null
}
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
function nextAction(): MascotAction | null {
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
const next = lessonStore.nextAfter(problem!._id)?.next
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
}
})
// ---- 主动开口(只对学生;比赛里 Mascot.vue 不弹)----
const student = () => userStore.isAuthed && !userStore.isTeacherOrAbove && ctx.value.entry !== 'contest'
/** 这道题上连着几次没跑例子就交了。跑过一次例子就清零 */
let unsampledSubmits = 0
let sampledSinceSubmit = false
/** 上一次「动静」(进题、交、跑例子)的时刻,给「盯了好久」用 */
let lastActivity = Date.now()
watch(
() => problemStore.problem?.id,
() => {
unsampledSubmits = 0
sampledSinceSubmit = false
lastActivity = Date.now()
},
)
watch(
() => submissionStore.trial.samplesRunAt.value,
(at) => {
if (!at) return
sampledSinceSubmit = true
lastActivity = Date.now()
if (student()) logMascotEvent({ kind: 'trial', action: 'samples' })
},
)
// 「运行例子」「自己输入」「举手」别处都不存,记下来给她看一周的习惯(behavior.ts)
watch(
() => submissionStore.trial.customRunning.value,
(running) => {
if (!running) return
lastActivity = Date.now()
if (student()) logMascotEvent({ kind: 'trial', action: 'custom' })
},
)
watch(
() => collabStore.helpStatus,
(status, previous) => {
if (status === 'pending' && previous === 'idle' && student()) logMascotEvent({ kind: 'help' })
},
)
watch(
() => submissionStore.submission?.id,
(id) => {
if (!id) return
unsampledSubmits = sampledSinceSubmit ? 0 : unsampledSubmits + 1
sampledSinceSubmit = false
lastActivity = Date.now()
},
)
// 判完了:错了就看要不要说一句;不管对错都刷新行为数(最近的错、今天做对几道)
watch(
() => [submissionStore.submission?.id, submissionStore.submission?.result] as const,
([id, result]) => {
const problem = problemStore.problem
if (!id || !FINISHED(result) || !problem || !student()) return
refreshMascotProfile(true)
const failed = RESULT_SITUATION[result as SubmissionStatus]
if (!failed) return
const { runSamples, help, why } = studentActions(problem)
if (problemStore.failCount >= STUCK_FAILS) {
requestSpeech({ key: `stuck:${problem.id}`, ...guide('problem.stuck', help, why) })
} else if (unsampledSubmits >= UNSAMPLED_SUBMITS && runSamples) {
requestSpeech({ key: `unsampled:${problem.id}`, ...guide('habit.noSamples', runSamples) })
} else {
const habit = habitSituation(failed)
if (habit) requestSpeech({ key: `habit:${habit}`, ...guide(habit, why) })
}
},
)
// 盯着一道没做对的题 15 分钟,没交也没跑:问一句
useIntervalFn(() => {
const problem = problemStore.problem
if (!problem || !student() || Date.now() - lastActivity < IDLE_MS) return
if (problem.myStatus === SubmissionStatus.accepted || submissionStore.submission?.result === SubmissionStatus.accepted) return
if (codeStore.code.language === 'Flowchart') return
const { runSamples, help } = studentActions(problem)
requestSpeech({ key: `idle:${problem.id}`, ...guide('idle.stuck', help, runSamples) })
}, 30_000)
}
@@ -14,28 +14,36 @@ const EMOJI_SVGS = [
// 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用 // 将 emoji SVG 转成 confetti 可用的彩色位图 Shape,缓存后供重复调用复用
let emojiShapesPromise: Promise<confetti.Shape[]> | null = null let emojiShapesPromise: Promise<confetti.Shape[]> | null = null
/**
* 表情在屏幕上的边长(px)。位图就按这个大小画,放的时候 `scalar: 1`、矩阵不缩放。
*
* **别改回「位图画大、矩阵缩小、scalar 放大」**:canvas-confetti 画位图粒子是每帧
* `fillRect(位图边长 × scalar)` 再用图案填,填的矩形和表情实际多大无关。原来位图 64、
* scalar 5,表情显示 50px,每个粒子每帧却要填 320×320 —— 两团 32 个粒子一帧三百多万像素,
* 机房电脑(显卡弱、Chrome 常走软件渲染)放到这一种就卡(用户 2026-11 反馈)。
* 现在填的正好是 50×50,同样大小、同样轨迹,像素少到四十分之一。
*/
const EMOJI_PX = 50
function svgToBitmapShape(svg: string): Promise<confetti.Shape> { function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const blob = new Blob([svg], { type: 'image/svg+xml' }) const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const img = new Image() const img = new Image()
img.onload = async () => { img.onload = async () => {
const renderSize = 64
const baseUnit = 10
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = renderSize canvas.width = EMOJI_PX
canvas.height = renderSize canvas.height = EMOJI_PX
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, renderSize, renderSize) ctx.drawImage(img, 0, 0, EMOJI_PX, EMOJI_PX)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
try { try {
const bitmap = await createImageBitmap(canvas) const bitmap = await createImageBitmap(canvas)
const scale = baseUnit / renderSize
resolve({ resolve({
type: 'bitmap', type: 'bitmap',
bitmap, bitmap,
matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2], matrix: [1, 0, 0, 1, -EMOJI_PX / 2, -EMOJI_PX / 2],
} as unknown as confetti.Shape) } as unknown as confetti.Shape)
} catch (e) { } catch (e) {
reject(e) reject(e)
@@ -415,7 +423,8 @@ export function useFireworks() {
const options: confetti.Options = { const options: confetti.Options = {
shapes: emojiShapes, shapes: emojiShapes,
flat: true, flat: true,
scalar: 5, // 大小在位图里(EMOJI_PX),这里放大的话填的矩形跟着放大,见 EMOJI_PX
scalar: 1,
particleCount: 16, particleCount: 16,
spread: 360, spread: 360,
startVelocity: 40, startVelocity: 40,
+8
View File
@@ -19,6 +19,7 @@ import ContextBar from './components/ContextBar.vue'
import { useEditorMenu } from './composables/editorMenu' import { useEditorMenu } from './composables/editorMenu'
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext' import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab' import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
import { useProblemMascotGuide } from './composables/mascotGuide'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版, // 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
@@ -208,6 +209,13 @@ watch(
}, },
) )
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
useProblemMascotGuide((segment) => {
drawer.value = null
currentTab.value = 'result'
submissionStore.resultSegment = segment
})
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数 // 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' }) const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
+13
View File
@@ -18,6 +18,7 @@ import { usePagination } from 'shared/composables/pagination'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { focusJumpAction, useMascotGuide, type MascotAction } from 'shared/mascot/guide'
import ProblemStatus from './components/ProblemStatus.vue' import ProblemStatus from './components/ProblemStatus.vue'
import ProblemTypeTag from './components/ProblemTypeTag.vue' import ProblemTypeTag from './components/ProblemTypeTag.vue'
import ProgressRow from './components/ProgressRow.vue' import ProgressRow from './components/ProgressRow.vue'
@@ -272,6 +273,18 @@ async function pickRandom() {
} }
} }
// 看板娘:学生给找题的几条路(「随便来一道」按这页选着的知识点和类型挑),老师给新建题目
useMascotGuide(() => {
if (teacher.value) {
return {
situation: 'teacher.problems',
actions: userStore.hasProblemPermission ? [{ label: '新建题目', run: () => router.push('/admin/problem/create') }] : [],
}
}
const undone: MascotAction | null = userStore.isAuthed && !undoneOn.value ? { label: '只看没做完', run: () => (undoneOn.value = true) } : null
return { situation: 'problems', actions: [focusJumpAction(), ...(undone ? [undone] : []), { label: '随便来一道', run: pickRandom }] }
})
// ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ---- // ---- 「只看没做完」下一道都没有了:说清楚为什么是空的,给两条路 ----
const nextTag = computed(() => { const nextTag = computed(() => {
const p = mine.value const p = mine.value
+15
View File
@@ -8,6 +8,7 @@ import { parseTime } from 'utils/functions'
import type { ProblemSet, ProblemSetProblem } from 'utils/types' import type { ProblemSet, ProblemSetProblem } from 'utils/types'
import { useAuthModalStore } from 'shared/store/authModal' import { useAuthModalStore } from 'shared/store/authModal'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { useFireworks } from '../problem/composables/useFireworks' import { useFireworks } from '../problem/composables/useFireworks'
import NotFound from 'shared/components/NotFound.vue' import NotFound from 'shared/components/NotFound.vue'
import BadgeLadder from './components/BadgeLadder.vue' import BadgeLadder from './components/BadgeLadder.vue'
@@ -122,6 +123,20 @@ async function join() {
} }
} }
// 看板娘:老师看全班,学生还差几道必做题、带去下一道;没加入就只打招呼
useMascotGuide(() => {
if (!set.value) return null
if (userStore.isTeacherOrAbove) return { situation: 'teacher.problemset', actions: [{ label: '全班情况', run: () => (tab.value = 'class') }] }
if (!joined.value) return null
const todo = problems.value.filter((item) => item.isRequired && !item.isCompleted)
const next = todo[0]
if (progress.value?.isCompleted || !next) return { situation: 'problemset.done', actions: [goAction(router, '专题列表', '/problemset')] }
const actions = [{ label: '去做下一道', run: () => router.push(problemLink(next)) }]
// 只剩最后一道、做完还有奖章没拿:说「就差最后 1 道」比「还差 1 道」更想让人做完
if (todo.length === 1 && set.value.badges.some((badge) => !badge.isEarned)) return { situation: 'problemset.lastOne', actions }
return { situation: 'problemset.unfinished', n: todo.length, actions }
})
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */ /** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id)) const canEdit = computed(() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id))
+11
View File
@@ -3,6 +3,7 @@ import { useThemeVars } from 'naive-ui'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import { getProblemSetList } from 'oj/api' import { getProblemSetList } from 'oj/api'
import { rgba, useTone } from 'shared/composables/tone' import { rgba, useTone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
@@ -55,6 +56,16 @@ const others = computed(() =>
}), }),
) )
// 看板娘:加入了还没做完的专题,布置中的优先,其次最近加入的
useMascotGuide(() => {
if (userStore.isTeacherOrAbove) return null
const pending = sets.value.filter((set) => set.userProgress.isJoined && !set.userProgress.isCompleted)
const latest = (set: ProblemSet) => Date.parse(set.userProgress.joinTime ?? '') || 0
const target = pending.find((set) => set.assigning) ?? [...pending].sort((a, b) => latest(b) - latest(a))[0]
if (!target) return null
return { situation: 'problemsets.unfinished', actions: [goAction(router, '接着做', `/problemset/${target.id}`)] }
})
function description(set: ProblemSet) { function description(set: ProblemSet) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示 // 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : '' return set.description && set.description !== set.title ? set.description : ''
+18
View File
@@ -7,6 +7,7 @@ import UserName from 'shared/components/UserName.vue'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { errorCode } from 'utils/api' import { errorCode } from 'utils/api'
import { displayName } from '@oj2/contract' import { displayName } from '@oj2/contract'
import { STORAGE_KEY } from 'utils/constants' import { STORAGE_KEY } from 'utils/constants'
@@ -271,6 +272,23 @@ function openDetail(className: string) {
showDetail.value = true showDetail.value = true
} }
// 看板娘:自己在榜上就把自己那一行滚到眼前(领奖台上是「你」那个标签,赛道上是高亮的那一行)
useMascotGuide(() => {
if (teacher.value || !board.value) return null
const listed = rows.value.some((row) => row.user.id === meId.value)
const findMe = () => {
const el = document.querySelector('.track-card .entry.me, .track-card .prow.me, .track-card .tag.me')
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const actions = listed ? [{ label: '我在哪', run: findMe }] : []
const { me: mine, ahead } = board.value
if (mine?.solved && mine.rank === 1) return { situation: 'rank.top', actions }
// 同分按先做到排,追平还在后面,所以要比前一名多做 1 道
const gap = mine && ahead ? ahead.solved - mine.solved : null
if (gap !== null && gap <= 5) return { situation: 'rank.gap', n: gap + 1, actions }
return { situation: 'rank', actions }
})
function open(username: string) { function open(username: string) {
router.push({ path: '/user', query: { name: username } }) router.push({ path: '/user', query: { name: username } })
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { useDark } from '@vueuse/core' import { useDark } from 'shared/composables/dark'
/** /**
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、 * 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import { useMascotGuide } from 'shared/mascot/guide'
import StatisticsView from './components/StatisticsView.vue' import StatisticsView from './components/StatisticsView.vue'
import { DEFAULT_PERIOD } from './period' import { DEFAULT_PERIOD } from './period'
@@ -19,6 +20,8 @@ const period = useRouteQuery<string>('period', DEFAULT_PERIOD, { mode: 'replace'
const from = useRouteQuery<string>('from', '', { mode: 'replace' }) const from = useRouteQuery<string>('from', '', { mode: 'replace' })
const to = useRouteQuery<string>('to', '', { mode: 'replace' }) const to = useRouteQuery<string>('to', '', { mode: 'replace' })
useMascotGuide(() => ({ situation: 'teacher.statistics' }))
function openList(query: Record<string, string>) { function openList(query: Record<string, string>) {
window.open(router.resolve({ name: 'submissions', query }).href, '_blank') window.open(router.resolve({ name: 'submissions', query }).href, '_blank')
} }
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useDark } from '@vueuse/core' import { useDark } from 'shared/composables/dark'
/** /**
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。 * 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
@@ -23,6 +23,8 @@ import UserName from 'shared/components/UserName.vue'
*/ */
const props = defineProps<{ const props = defineProps<{
row: SubmissionListItem row: SubmissionListItem
/** 按 ↑ ↓ 会落到的上一条、下一条:这条加载完顺手把它们也拉回来,换条时不用等网络 */
neighbors?: SubmissionListItem[]
/** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */ /** 老师那套:重新判题、逐个测试点、复制两项收进「⋯」 */
teacher: boolean teacher: boolean
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */ /** 在比赛的提交列表里(不能重判,题目链接走比赛) */
@@ -61,7 +63,42 @@ const detail = ref<Submission | null>(null)
const loading = ref(false) const loading = ref(false)
const failed = ref(false) const failed = ref(false)
const judging = computed(() => props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging) function isJudging(row: SubmissionListItem) {
return row.result === SubmissionStatus.pending || row.result === SubmissionStatus.judging
}
const judging = computed(() => isJudging(props.row))
/**
* 同一条只发一次请求:预取的还在路上时按到了它,接着等那一个,不再发第二个
*/
const inflight = new Map<string, Promise<Submission>>()
function fetchDetail(id: string) {
let request = inflight.get(id)
if (!request) {
request = getSubmission(id).finally(() => inflight.delete(id))
inflight.set(id, request)
}
return request
}
/**
* 预取上一条、下一条。只在这一条落定之后才做(按住 ↓ 一路划过去的那些行不预取),
* 进缓存的口径和 load() 一样:还在判的不要,列表和详情的结果对不上也不要。
*
* 没预取的时候,换到没看过的一条要先把代码区卸掉、转圈、再整个重新挂上 ——
* 2026-11 实测(生产构建、CPU 降速 6 倍)每次卡 60–100ms;走缓存的只换内容,不卡。
*/
function prefetch() {
for (const row of props.neighbors ?? []) {
if (!row.showLink || isJudging(row) || cache.get(row.id)?.result === row.result) continue
fetchDetail(row.id).then(
(res) => {
if (res.result === row.result) cache.set(row.id, res)
},
() => {},
)
}
}
// 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的 // 每次加载一个序号:同一条先按「判题中」拉了一次、判完又拉一次时,晚回来的旧请求不能盖掉新的
let loadSeq = 0 let loadSeq = 0
@@ -78,15 +115,17 @@ async function load() {
const hit = cache.get(row.id) const hit = cache.get(row.id)
if (hit && hit.result === row.result) { if (hit && hit.result === row.result) {
detail.value = hit detail.value = hit
prefetch()
return return
} }
loading.value = true loading.value = true
try { try {
const res = await getSubmission(row.id) const res = await fetchDetail(row.id)
if (seq !== loadSeq) return if (seq !== loadSeq) return
// 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上 // 列表那边还在判、详情这边已经判完了(或反过来)都不缓存,等两边对上
if (!judging.value && res.result === props.row.result) cache.set(row.id, res) if (!judging.value && res.result === props.row.result) cache.set(row.id, res)
detail.value = res detail.value = res
prefetch()
} catch { } catch {
if (seq === loadSeq) failed.value = true if (seq === loadSeq) failed.value = true
} finally { } finally {
@@ -35,6 +35,13 @@ export function useRowCursor(options: {
const index = navigable.value.findIndex((row) => row.id === selectedId.value) const index = navigable.value.findIndex((row) => row.id === selectedId.value)
return index === -1 ? null : { index: index + 1, count: navigable.value.length } return index === -1 ? null : { index: index + 1, count: navigable.value.length }
}) })
/** 按 ↑ ↓ 会落到的上一条、下一条(本页之内),右栏拿去预取 */
const neighbors = computed(() => {
const list = navigable.value
const index = list.findIndex((row) => row.id === selectedId.value)
if (index === -1) return []
return [list[index + 1], list[index - 1]].filter((row): row is Row => row !== undefined)
})
// 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选 // 翻页是异步的,先记下「落地后停在哪一头」,新一页回来再选
let pendingJump: 'first' | 'last' | null = null let pendingJump: 'first' | 'last' | null = null
@@ -125,5 +132,5 @@ export function useRowCursor(options: {
move(e.key === 'ArrowUp' ? -1 : 1) move(e.key === 'ArrowUp' ? -1 : 1)
}) })
return { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } return { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump }
} }
+34 -1
View File
@@ -1,8 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSubmission } from 'oj/api'
import { useMascotGuide } from 'shared/mascot/guide'
import { useUserStore } from 'shared/store/user'
import type { Submission } from 'utils/types'
import SubmissionView from './components/SubmissionView.vue' import SubmissionView from './components/SubmissionView.vue'
/** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */ /** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>() const props = defineProps<{ submissionID: string }>()
const router = useRouter()
const userStore = useUserStore()
/**
* 看板娘:学生看自己的提交时带回这道题。SubmissionView 里的数据拿不出来(弹层也用它,不挂看板娘),
* 这里另取一份不带「这道题交的几次」的,只给学生取
*/
const mine = ref<Submission | null>(null)
watch(
() => [props.submissionID, userStore.isAuthed && !userStore.isTeacherOrAbove] as const,
async ([id, student]) => {
mine.value = null
if (!student) return
const res = await getSubmission(id).catch(() => null)
if (res && id === props.submissionID && res.userId === userStore.user?.id) mine.value = res
},
{ immediate: true },
)
useMascotGuide(() => {
const s = mine.value
if (!s || userStore.isTeacherOrAbove) return null
// 和页面上题目那个链接同一个去处:比赛里交的回比赛里的题
const to = s.contestId
? { name: 'contest problem', params: { contestID: String(s.contestId), problemID: s.problemDisplayId } }
: '/problem/' + s.problemDisplayId
return { situation: 'submission.detail', actions: [{ label: '回到这道题', run: () => router.push(to) }] }
})
</script> </script>
<template> <template>
+16 -1
View File
@@ -11,6 +11,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { usePagination } from 'shared/composables/pagination' import { usePagination } from 'shared/composables/pagination'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { useMascotGuide } from 'shared/mascot/guide'
import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants' import { LANGUAGE_SHOW_VALUE, SubmissionStatus } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types' import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from 'utils/types'
@@ -263,7 +264,7 @@ watch(
// ==================== 选中与翻阅 ==================== // ==================== 选中与翻阅 ====================
const { selectedId, selectedRow, position, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({ const { selectedId, selectedRow, position, neighbors, maxPage, select, move, settleSelection, forgetJump } = useRowCursor({
rows, rows,
total, total,
query, query,
@@ -427,6 +428,18 @@ const { lesson, lessonActive, lessonLoading, toggleLesson } = useLessonFilter({
info: (text) => message.info(text), info: (text) => message.info(text),
}) })
// 看板娘:学生带去看自己的;「这节课」只有老师那一版有,老师给它
useMascotGuide(() => {
if (inContest.value) return null
if (!teacher.value) return { situation: 'submissions', actions: mine.value ? [] : [{ label: '只看我的', run: () => (scope.value = 'mine') }] }
const toLesson = () => {
// 「我的」时这节课的按钮是藏着的,先切回全部
if (mine.value) scope.value = 'all'
toggleLesson()
}
return { situation: 'teacher.submissions', actions: lessonActive.value ? [] : [{ label: '这节课', run: toLesson }] }
})
const activeFilters = computed( const activeFilters = computed(
() => () =>
[query.username, query.className, query.result, query.problem].filter(Boolean).length + [query.username, query.className, query.result, query.problem].filter(Boolean).length +
@@ -858,6 +871,7 @@ function dayBreak(index: number) {
<SubmissionPane <SubmissionPane
v-else v-else
:row="selectedRow as SubmissionListItem" :row="selectedRow as SubmissionListItem"
:neighbors="neighbors as SubmissionListItem[]"
:teacher="teacher" :teacher="teacher"
:class-name="query.className" :class-name="query.className"
:contest="inContest" :contest="inContest"
@@ -893,6 +907,7 @@ function dayBreak(index: number) {
<SubmissionPane <SubmissionPane
v-else v-else
:row="selectedRow as SubmissionListItem" :row="selectedRow as SubmissionListItem"
:neighbors="neighbors as SubmissionListItem[]"
:teacher="teacher" :teacher="teacher"
:class-name="query.className" :class-name="query.className"
:contest="inContest" :contest="inContest"
@@ -92,6 +92,9 @@ function openPicker() {
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) })) pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true picking.value = true
} }
// 看板娘气泡里的「选题」:课堂任务页没布置时替老师打开选题弹框
defineExpose({ openPicker })
function pickerConfirm(ids: string[]) { function pickerConfirm(ids: string[]) {
lessonInput.value = ids.join(' ') lessonInput.value = ids.join(' ')
saveLesson() saveLesson()
+167 -1
View File
@@ -10,6 +10,8 @@ import { useLeaveStudents } from 'shared/composables/leaveStudents'
import { useTeacherHelp } from 'shared/composables/teacherHelp' import { useTeacherHelp } from 'shared/composables/teacherHelp'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { requestSpeech } from 'shared/mascot/behavior'
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
@@ -511,6 +513,161 @@ function submissionsHref(
}).href }).href
} }
/**
* 看板娘:布置了就带去这节课的提交,筛法同提交列表「这节课」(班、这几道题、今天)。
* 语言和格子一致:画流程图的课看流程图,选了语言的只看它 —— 夹着 SQL 题的不筛语言,免得把 SQL 交的藏掉。
* 还没布置时填题号的框就是这页最大的东西,不给按钮
*/
const startForm = useTemplateRef<{ openPicker: () => void }>('startForm')
// ---------- 看板娘:点她时挑全班这一刻最要紧的一件说;卡住的多起来、全班做完时她自己开口 ----------
/** 同一种错有这么多人才算「扎堆」 */
const PILE_MIN = 3
/** 布置这么久了,人在线却一道没交,才算「没动」 */
const IDLE_AFTER_MS = 10 * 60_000
/** 一道题卡住的人涨到这么多,她主动说一声 */
const STUCK_RISE = 3
/** 人最多的那一堆错(灰底那几堆不算:不是今天的错,没得讲) */
function biggestPile() {
let best: { index: number; kind: ErrorKind; people: WrongPerson[] } | null = null
const count = board.value?.problems.length ?? 0
for (let index = 0; index < count; index++) {
const groups = new Map<string, WrongPerson[]>()
for (const person of wrongOf(index)) {
if (QUIET_KINDS.has(person.kind.key)) continue
groups.set(person.kind.key, [...(groups.get(person.kind.key) ?? []), person])
}
for (const people of groups.values()) {
if (!best || people.length > best.people.length) best = { index, kind: people[0]!.kind, people }
}
}
return best && best.people.length >= PILE_MIN ? best : null
}
/** 卡住的人(cells.ts 的 isStuck),交得多的在前 */
function stuckPeople() {
return present.value
.flatMap((student) => student.cells.map((cell, index) => ({ student, cell, index })))
.filter(({ cell, index }) => isStuck(cell, modeAt(index)))
.sort((a, b) => b.cell.attempts - a.cell.attempts)
}
/** 在线、今天一道没交、也不是早就全做完了的人 */
function idlePeople() {
const assignedAt = board.value?.assignedAt
if (!assignedAt || Date.now() - Date.parse(assignedAt) < IDLE_AFTER_MS) return []
return present.value.filter(
(student) => student.online && student.cells.every((cell) => cell.attempts === 0) && student.cells.some((cell) => cell.status !== 'accepted'),
)
}
const allDone = () => present.value.length > 0 && present.value.every((student) => student.cells.every((cell) => cell.status === 'accepted'))
/** 点名字:能讲的打开讲错误的弹层,不能讲的展开那道题的错法 */
function lookAt(index: number, student: TaskBoardStudent): MascotAction {
const cell = student.cells[index]!
const problem = board.value!.problems[index]!
return {
label: `看 ${displayName(student.username)} 的`,
run: () => (explainable(cell, index) ? openExplain(index, student.userId) : (selectedId.value = problem.problemId)),
}
}
useMascotGuide((): MascotGuide | null => {
const b = board.value
if (!b?.className) return null
if (needLesson.value)
return { situation: 'teacher.task.unassigned', actions: startForm.value ? [{ label: '选题', run: () => startForm.value?.openPicker() }] : [] }
const language = b.mode === 'flowchart' ? 'Flowchart' : b.problems.some((problem) => problem.isSql) ? null : b.language
const query = {
className: b.className,
problem: b.problems.map((problem) => problem.problemDisplayId).join(','),
today: '1',
...(language ? { language } : {}),
}
// 新标签打开,和点格子一样:任务页一般开着不关
const submissions: MascotAction = { label: '这节课的提交', run: () => window.open(router.resolve({ name: 'submissions', query }).href, '_blank') }
if (allDone())
return { situation: 'teacher.task.allDone', actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }] }
const pile = biggestPile()
if (pile) {
const problem = b.problems[pile.index]!
const first = pile.people.find((person) => person.explain)
const actions: MascotAction[] = [{ label: '看这堆', run: () => (selectedId.value = problem.problemId) }]
if (first) actions.push({ label: '讲第一个', run: () => openExplain(pile.index, first.student.userId) })
return {
situation: 'teacher.task.errorPile',
n: pile.people.length,
vars: { pid: problem.problemDisplayId, kind: pile.kind.label },
actions: [...actions, submissions],
}
}
const stuck = stuckPeople()
if (stuck.length) {
const top = stuck[0]!
return {
situation: 'teacher.task.stuck',
n: new Set(stuck.map((it) => it.student.userId)).size,
vars: { name: displayName(top.student.username), k: top.cell.attempts, pid: b.problems[top.index]!.problemDisplayId },
actions: [lookAt(top.index, top.student), submissions],
}
}
const idle = idlePeople()
if (idle.length) {
const name = displayName(idle[0]!.username)
return idle.length === 1 ? { situation: 'teacher.task.idleOne', vars: { name } } : { situation: 'teacher.task.idle', n: idle.length, vars: { name } }
}
const challenging = present.value.filter((student) => student.cells.some((cell) => cell.challenge === 'doing')).length
if (challenging) return { situation: 'teacher.task.challenging', n: challenging, actions: [submissions] }
return { situation: 'teacher.task.assigned', actions: [submissions] }
})
/**
* 主动开口:每 15 秒刷新一次,和上一次比 —— 某道题卡住的人从不到 3 个涨到 3 个、全班刚做完。
* 刚打开页面(或换了班、换了一节)的那一次只记数不说,不然一刷新就把已经知道的事再说一遍
*/
let seen: { key: string; stuck: Map<number, number>; done: boolean } | null = null
watch(board, (b) => {
if (!b?.className || needLesson.value) return
const key = `${b.className}|${b.day}|${b.lessonId ?? ''}|${b.problems.map((problem) => problem.problemId).join(',')}`
const stuck = new Map<number, number>()
for (const { index } of stuckPeople()) {
const id = b.problems[index]!.problemId
stuck.set(id, (stuck.get(id) ?? 0) + 1)
}
const done = allDone()
const before = seen?.key === key ? seen : null
seen = { key, stuck, done }
if (!before) return
if (done && !before.done) {
requestSpeech({
key: `done:${key}`,
situation: 'teacher.task.allDone',
actions: [{ label: b.source === 'teacher' ? '改题' : '布置', run: () => (editing.value = 'edit') }],
})
return
}
for (const [id, n] of stuck) {
if (n < STUCK_RISE || (before.stuck.get(id) ?? 0) >= STUCK_RISE) continue
const problem = b.problems.find((it) => it.problemId === id)!
requestSpeech({
key: `stuck:${key}:${id}`,
situation: 'teacher.task.stuckRise',
n,
vars: { pid: problem.problemDisplayId },
actions: [{ label: '看这道', run: () => (selectedId.value = id) }],
})
return
}
})
function lastText(student: TaskBoardStudent) { function lastText(student: TaskBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm') if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) { if (oftenIdle(student)) {
@@ -578,7 +735,16 @@ function lastText(student: TaskBoardStudent) {
<!-- 刚上课还没有题:填题号的框是这页最大的东西 --> <!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start"> <section v-if="needLesson" class="start">
<div class="start-title">这节课做哪几道题?</div> <div class="start-title">这节课做哪几道题?</div>
<LessonForm v-if="className" :board="board" :class-name="className" :lesson-id="null" variant="start" class="start-row" @saved="lessonSaved" /> <LessonForm
v-if="className"
ref="startForm"
:board="board"
:class-name="className"
:lesson-id="null"
variant="start"
class="start-row"
@saved="lessonSaved"
/>
<div v-if="staleLesson" class="earlier"> <div v-if="staleLesson" class="earlier">
<span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span> <span>上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span> <span v-for="problem in board.problems" :key="problem.problemId" class="chip">{{ problem.problemDisplayId }}</span>
+8
View File
@@ -4,6 +4,7 @@ import { getProfileOverview } from 'oj/api'
import { getProfile } from 'shared/api' import { getProfile } from 'shared/api'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
import { goAction, useMascotGuide } from 'shared/mascot/guide'
import type { Profile } from 'utils/types' import type { Profile } from 'utils/types'
import AchievementCard from './components/AchievementCard.vue' import AchievementCard from './components/AchievementCard.vue'
import KnowledgeMap from './components/KnowledgeMap.vue' import KnowledgeMap from './components/KnowledgeMap.vue'
@@ -17,6 +18,7 @@ import VersusCard from './components/VersusCard.vue'
* /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。 * /profiles/:username/overview),这里只管摆。原来的「我的设置」并进了名片。
*/ */
const route = useRoute() const route = useRoute()
const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -47,6 +49,12 @@ async function load() {
} }
watch(username, load, { immediate: true }) watch(username, load, { immediate: true })
// 看板娘:只在看自己的主页时说话
useMascotGuide(() => {
if (!isSelf.value || !profile.value || userStore.isTeacherOrAbove) return null
return { situation: 'profile', actions: [goAction(router, '我的成就', '/achievement'), goAction(router, '我的提交', '/submission?myself=1')] }
})
</script> </script>
<template> <template>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sutera-Diffusus
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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

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: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 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: 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: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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