Compare commits

...

49 Commits

Author SHA1 Message Date
8cf98ed087 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 18:42:42 -06:00
235d11ecdd fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 18:35:24 -06:00
8be96e178e fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 12:05:37 -06:00
ec54fb430e update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 11:56:07 -06:00
56b5dc2e36 dota2 like
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 11:50:45 -06:00
39f9bb9d38 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 10:15:16 -06:00
34aff29048 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 08:44:35 -06:00
d7b57074d3 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 08:36:04 -06:00
280e360506 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 08:25:53 -06:00
5b8e0f866b refactor(reaction): 收敛单选提交交互
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-06 07:53:06 -06:00
9f89be3876 feat(reaction): 评价改为先勾选再提交,提交后不可修改
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 06:15:34 -06:00
1fb93e6b16 refactor(reaction): 后台反馈统计独立成 reaction 目录,去掉排序、加最近评价时间
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 06:15:34 -06:00
b3d0c3fcc7 题目点评按钮改成大图标按钮
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
七个按钮放大到 48px 高、图标 28px,去掉按钮内的中文标签(移到 hover
tooltip 里),计数改成 ×N 形式。不再用 n-button,改成原生 button 自己写
样式。窄面板下从横向滚动改成换行,避免按钮被滚动条藏起来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 05:55:29 -06:00
c4d55bb88e fix(reaction): fix disabled-tooltip hover and post-AC dialog copy
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
- SubmitCode.vue: AC dialog title still told students to '打星
  评分' after the reaction UI moved to emoji buttons. Rewritten to
  match the emoji-based interaction, with no star/rating language.
- ProblemReaction.vue: n-tooltip's hover trigger merges its
  mouseenter/mouseleave onto the single root element of the trigger
  slot. That root was an n-button, and disabled form controls never
  dispatch mouse events, so the two disabled-state tooltips ('完成本题后可以评价'
  and the max-selection message) never appeared. Wrap each button in
  a non-disabled <span> and make that the tooltip root so hover
  events fire regardless of the button's disabled state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:12:55 -06:00
c91c923fd6 refactor(reaction): 清理旧的评论代码
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:55:11 -06:00
9c6c6e1784 fix(reaction): dedupe sort request when not on page 1
handleSorter previously mutated query.page then called listStats()
synchronously, while a separate page/limit watcher also fired on the
page reset, producing two identical requests whenever sorting was
triggered from page 2+. ordering is now part of the watched sources
so page+ordering mutations from handleSorter batch into a single
watcher run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:47:19 -06:00
a8cc7c855a feat(reaction): 后台题目反馈统计页
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:42:25 -06:00
e153064d87 fix(reaction): guard toggle against out-of-order responses
用请求代号确保只有最新一次点击的响应会写回本地状态,乐观更新的
回滚路径同样受保护,避免旧请求晚到覆盖新状态。不引入防抖、不禁用按钮。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:38:24 -06:00
0af91b1e20 feat(reaction): 学生端表情条组件
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:27:45 -06:00
d16e3dddb8 feat(reaction): 前端表情常量、类型与 API
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:19:39 -06:00
8a46bae27d feat(admin): 班级号位数校验,规则收敛到 constants
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
后端现在会拒绝位数不对的班级号,前端提前拦一道并给出明确提示。

- generate.vue 导入前校验班级号范围
- config.vue 校验班级列表每一项,输入框下方补说明
- 位数规则收敛到 utils/constants 的 CLASS_NAME_* 常量,
  三个使用方都改为引用

顺带两处修正:

- config.vue 和 list.vue 的保存都没有 try/catch。http 拦截器只对
  login-required / permission-denied 自动弹提示,其余业务错误不接住
  就什么都不显示,连"用户名已存在"这类老错误也是静默的
- ButtonWithSearch 原来用 startsWith("ks") 决定显不显示按班级筛选,
  kstest 这类账号会显示按钮但点了没反应;改用同一个正则判断

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:15:52 -06:00
0e2cc28235 refactor(signup): 去掉注册验证码
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
后端已移除验证码功能。

- Signup.vue 删表单项、验证码图、getCaptchaSrc、打开弹窗拉图的 watch、
  "Invalid captcha" 错误分支和 .captcha 样式
- authModal store 删 signupForm.captcha、captchaSrc、setCaptchaSrc
- api.ts 删 getCaptcha(),signup() 参数去掉 captcha

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:48:13 -06:00
e777a94568 refactor(admin): 去掉 two_factor_auth 残留
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
后端已删除该 model 字段,2FA 功能整体移除。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:00:48 -06:00
56f1431a10 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 14:01:14 -06:00
7b2a4a9094 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:59:31 -06:00
b559c93476 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:57:31 -06:00
42de79ee60 revert(achievement): 去掉进度条动画
保留奖章的题单来源,只回退 0 → 真值那套触发动画的处理和拉长的过渡时长,
圆环和进度条回到直接显示终值。
2026-08-05 13:56:13 -06:00
8cfa90ca16 feat(achievement): 奖章标出来自哪个题单 + 进度条动画
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
奖章卡片只有名字和描述,看不出是哪个题单发的,补上题单名并链到详情页。
进度是异步加载的,卡片挂载时百分比已是终值,naive 自带的过渡触发不了,
先按 0 渲染一帧再填真值,同时把时长从 .3s/.2s 拉到 .9s,
圆环数字换成 n-number-animation 同步滚动。
2026-08-05 13:52:58 -06:00
4b796650ff fix(achievement): 拉开青铜和黄金的配色
青铜和黄金原来都落在橙棕区(色相 30 / 44),扫一眼分不出来。
青铜往红铜偏、黄金往纯黄推,边框色和明暗两套文字色一起调整,
对比度全部保持 4.5:1 以上。顺带修浅色模式白金只有 4.10:1。
2026-08-05 13:52:58 -06:00
ade6034316 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:44:16 -06:00
a1bea7182f fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:43:21 -06:00
425bab1c15 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:38:52 -06:00
17dbd7a4a7 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:34:46 -06:00
8db26ef7a4 rewind
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:33:32 -06:00
508267bb59 fix(成就): 圆环百分比在移动端撑出 + 卡片边框加粗
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
- 圆环改用默认插槽渲染百分比,字号跟断点走(naive 自带指示器字号固定,76px 圆环放不下)
- 卡片边框 1px → 2px
- 置灰从整张卡挪到内容上,未获得的卡片不再把稀有度边框一起洗成灰

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:30:51 -06:00
a4fdd377f2 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:23:45 -06:00
da2334ed3a update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:15:01 -06:00
a626160f29 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 13:13:07 -06:00
bf028b3fb1 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 11:50:51 -06:00
b7f82adb7c fix(achievement): 抬头四条稀有度进度固定单列
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
auto-fit 两列在宽屏会折成 2x2,进度条被压短看不出差别。
改成一列四行,和定稿一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:45:15 -06:00
05ce9cbb9e revert(achievement): 抬头回到环形进度版,保留白金优先的排序
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
PSN 那版奖杯陈列观感不佳,回退到环形 + 四色进度条。稀有度顺序
沿用 PSN 的做法:白金、黄金、白银、青铜,稀有的排前面。

顺带保留卡片网格的 min() 修复,避免 320 宽机型横向滚动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:40:55 -06:00
4072abe199 feat(achievement): 抬头改成 PlayStation 奖杯陈列
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
参照 PSN 的奖杯系统重做:六边形铭牌放完成度,四个奖杯按稀有度
倒序陈列(白金在最左),同一个杯型只换颜色,某档一个没有就置灰。

底部一条总进度分成四段,每段宽度是该档已获得占总数的比例,
拼起来正好等于总完成度——条的长度和构成都对得上数据。

移动端走全站的 useBreakpoints(tailwind md),竖排;顺手修了
卡片网格 minmax(300px) 在 320 宽机型上撑出横向滚动的问题。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:34:49 -06:00
0d7336bce8 refactor(achievement): 稀有度配色收进 constants,文字色适配浅色模式
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
四份重复的 RARITY_COLOR / RARITY_LABEL 合并到 utils/constants。
拆成两套:边框色块继续用饱和的原色,文字改走 useRarityColor,
按明暗主题切换,两套都压到 4.5:1 以上——原色是照深色底调的,
白底上白金只有 1.7:1、黄金 2.2:1,卡片上的稀有度 tag 看不清。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:24:04 -06:00
4db0dc7e83 fix(achievement): 抬头稀有度配色适配浅色模式
原色值是照深色底调的,白底上白金 1.7:1、黄金 2.2:1 看不清。
改成按 useDark 切换明暗两套色值。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:21:03 -06:00
56d06eb456 style(achievement): 成就页抬头改为环形进度 + 四色稀有度条
左侧环形显示总完成度,右侧四行稀有度各配一条对应颜色的进度条,
配色复用 AchievementCard 的 RARITY_COLOR。窄屏自动塌成一列。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:19:57 -06:00
b79dd6c721 feat(admin): 成就管理标题显示条数
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
有下架的成就时一并显示下架条数——列表里只有"上架:否"那一列能看出来,
扫一眼容易漏。数据未加载完时退回纯标题,避免闪一下"0 条"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:13:48 -06:00
15d823aa20 refactor: 个人主页移除徽章卡片
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
徽章已在成就页的"题单奖章" tab 展示,主页重复且与旁边的成就摘要
风格不一致。连带删掉无引用的 GroupedUserBadge 组件。

成就图标那排补上"最近获得"标签,避免被误读成成就总数。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:27:42 -06:00
4ae25b09c0 perf: skulpt 改为按需加载,client-zip 换成 fflate
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
skulpt 此前被 SubmitCode.vue 静态引用,945K 的 chunk 落在做题页首屏,
学生打开任何一道题都要下 233KB gzip,只为提交时拿一个语法错误行号。
改成 import() 懒加载并在语言切到 Python3 时预取,加载失败放行提交、
交给后端判题兜底。构建产物确认该 chunk 只剩一处 import() 引用,
index.html 不再引用。

client-zip 的能力 fflate 已经覆盖(utils/functions.ts 本就在用 zlibSync),
新增 createZipBlob 统一两处测试用例打包,删掉该依赖。压缩方式由 store
变为 deflate,Python zipfile 透明解压,后端不受影响。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:11:58 -06:00
ea691834b0 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-08-05 06:46:44 -06:00
39 changed files with 1824 additions and 1460 deletions

1021
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,47 +20,46 @@
"@vue-flow/minimap": "^1.5.4", "@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1", "@vue-flow/node-resizer": "^1.5.1",
"@vue-flow/node-toolbar": "^1.1.1", "@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"@vueuse/router": "^14.3.0", "@vueuse/router": "^14.4.0",
"@wangeditor-next/editor": "^6.0.2", "@wangeditor-next/editor": "^6.2.0",
"@wangeditor-next/editor-for-vue": "^5.1.14", "@wangeditor-next/editor-for-vue": "^6.2.0",
"axios": "^1.18.1", "axios": "^1.19.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5", "chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"md-editor-v3": "^6.5.4", "md-editor-v3": "^6.5.6",
"mermaid": "^11.16.0", "mermaid": "^11.16.1",
"mermaid-legacy": "npm:mermaid@^9.4.3", "mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"nanoid": "^6.0.0", "nanoid": "^6.0.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"skulpt": "^1.2.0", "skulpt": "^1.2.0",
"vue": "^3.5.40", "vue": "^3.5.41",
"vue-chartjs": "^5.3.4", "vue-chartjs": "^5.3.4",
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
"vue-router": "^5.2.0", "vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.5", "y-codemirror.next": "^0.3.5",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.31" "yjs": "^13.6.32"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.1", "@iconify/vue": "^5.0.1",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^26.1.1", "@types/node": "^26.1.2",
"@vitejs/plugin-legacy": "^8.2.2", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5" "vite": "^8.2.1"
} }
} }

View File

@@ -7,6 +7,12 @@ import {
type MetricOption, type MetricOption,
} from "admin/api" } from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_LABEL } from "utils/constants"
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
label,
value,
}))
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
@@ -118,15 +124,7 @@ async function save() {
</n-flex> </n-flex>
</n-form-item> </n-form-item>
<n-form-item label="稀有度"> <n-form-item label="稀有度">
<n-select <n-select v-model:value="form.rarity" :options="rarityOptions" />
v-model:value="form.rarity"
:options="[
{ label: '青铜', value: 'bronze' },
{ label: '白银', value: 'silver' },
{ label: '黄金', value: 'gold' },
{ label: '白金', value: 'platinum' },
]"
/>
</n-form-item> </n-form-item>
<n-form-item label="指标" required> <n-form-item label="指标" required>
<n-select <n-select

View File

@@ -7,6 +7,7 @@ import {
} from "admin/api" } from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue" import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
@@ -16,12 +17,14 @@ const loading = ref(false)
const showModal = ref(false) const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null) const editing = ref<AdminAchievement | null>(null)
const RARITY_LABEL: Record<string, string> = { // 下架的只在有的时候才提,全部上架时标题不啰嗦
bronze: "青铜", const title = computed(() => {
silver: "白银", if (!list.value.length) return "成就管理"
gold: "黄金", const offline = list.value.filter((a) => !a.visible).length
platinum: "白金", return offline
} ? `成就管理(${list.value.length} 条,${offline} 条下架)`
: `成就管理(${list.value.length} 条)`
})
async function load() { async function load() {
loading.value = true loading.value = true
@@ -115,7 +118,7 @@ onMounted(load)
</script> </script>
<template> <template>
<n-card title="成就管理"> <n-card :title="title">
<template #header-extra> <template #header-extra>
<n-button type="primary" @click="create">新建成就</n-button> <n-button type="primary" @click="create">新建成就</n-button>
</template> </template>

View File

@@ -287,16 +287,6 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
return http.post("admin/announcement", announcement) return http.post("admin/announcement", announcement)
} }
export function getCommentList(offset = 0, limit = 10, problem: string) {
return http.get("admin/comment", {
params: { offset, limit, problem },
})
}
export function deleteComment(id: number) {
return http.delete("admin/comment", { params: { id } })
}
export async function getTutorialList() { export async function getTutorialList() {
const res = await http.get<Tutorial[]>("admin/tutorial") const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data return res.data

View File

@@ -1,114 +0,0 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">评论列表只列出有内容的</h2>
<div>
<n-input
v-model:value="query.problem"
clearable
placeholder="输入题目序号"
/>
</div>
</n-flex>
<n-data-table striped :columns="columns" :data="comments" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<script lang="ts" setup>
import { NButton } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import type { Comment } from "utils/types"
import { getCommentList } from "../api"
import CommentActions from "./components/CommentActions.vue"
const comments = ref<Comment[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
problem: "",
})
const columns: DataTableColumn<Comment>[] = [
{
title: "题目",
key: "problem",
width: 100,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/problem/" + row.problem, "_blank"),
},
() => row.problem,
),
},
{
title: "提交",
key: "submission",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => window.open("/submission/" + row.submission, "_blank"),
},
() => row.submission.slice(0, 12),
),
},
{ title: "描述评分", key: "description_rating", width: 100 },
{ title: "难度评分", key: "difficulty_rating", width: 100 },
{ title: "综合评分", key: "comprehensive_rating", width: 100 },
{ title: "用户", key: "user.username", width: 150 },
{
title: "时间",
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
width: 200,
},
{
title: "内容",
key: "content",
minWidth: 200,
maxWidth: 300,
ellipsis: true,
},
{
title: "选项",
key: "action",
width: 100,
render: (row) =>
h(CommentActions, { commentID: row.id, onDeleted: listComments }),
},
]
async function listComments() {
const offset = (query.page - 1) * query.limit
const res = await getCommentList(offset, query.limit, query.problem)
comments.value = res.data.results
total.value = res.data.total
}
onMounted(listComments)
watch(() => [query.page, query.limit], listComments)
watchDebounced(() => query.problem, listComments, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -1,22 +0,0 @@
<script lang="ts" setup>
import { deleteComment } from "admin/api"
const props = defineProps<{ commentID: number }>()
const emit = defineEmits(["deleted"])
const message = useMessage()
async function submit() {
await deleteComment(props.commentID)
message.success("成功删除")
emit("deleted")
}
</script>
<template>
<n-popconfirm @positive-click="submit">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这条评论吗
</n-popconfirm>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types" import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue" import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import { import {
generateSQLTestcase, generateSQLTestcase,
@@ -156,15 +156,13 @@ async function preview() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = scripts.value const data = scripts.value
.filter((s) => s.sql.trim()) .filter((s) => s.sql.trim())
.map((s, i) => ({ .map((s, i) => ({
name: `${i + 1}.sql`, name: `${i + 1}.sql`,
input: s.sql, content: s.sql,
lastModified: now,
})) }))
const blob = await downloadZip(data).blob() const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true }) const res = await uploadTestcases(file, { sql: true })

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, Testcase } from "utils/types" import type { LANGUAGE, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api" import { uploadTestcases } from "../../api"
@@ -158,15 +158,14 @@ async function run() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = files.value const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error) .filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [ .flatMap((f, i) => [
{ name: `${i + 1}.in`, input: f.in, lastModified: now }, { name: `${i + 1}.in`, content: f.in },
{ name: `${i + 1}.out`, input: f.out, lastModified: now }, { name: `${i + 1}.out`, content: f.out },
]) ])
const blob = await downloadZip(data).blob() const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file) const res = await uploadTestcases(file)

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { NFlex, NSwitch, NTag } from "naive-ui" import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions" import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types" import type { AdminProblemFiltered } from "utils/types"
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api" import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue" import Modal from "./components/Modal.vue"
@@ -135,6 +135,21 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
: null, : null,
]), ]),
}, },
{
title: "反馈",
key: "top_reaction",
width: 60,
render: (row) => {
const top = row.top_reaction
if (!top) return null
const reaction = REACTIONS.find((it) => it.key === top.type)
if (!reaction) return null
return h(NTooltip, null, {
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
default: () => `${reaction.label} ${top.count}`,
})
},
},
{ title: "出题人", key: "username", width: 120 }, { title: "出题人", key: "username", width: 120 },
{ {
title: "创建时间", title: "创建时间",
@@ -167,9 +182,10 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
}, },
] ]
// 比赛题目接口不返回 top_reaction这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() => const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
isContestProblemList.value isContestProblemList.value
? baseColumns ? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
: [{ type: "selection" }, ...baseColumns], : [{ type: "selection" }, ...baseColumns],
) )

View File

@@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Server } from "utils/types" import type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
} }
async function saveWebsiteConfig() { async function saveWebsiteConfig() {
await editWebsite(websiteConfig) // 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
if (invalid.length) {
message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return
}
try {
await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功") message.success("网站配置保存成功")
getWebsiteConfig() getWebsiteConfig()
configStore.getConfig() configStore.getConfig()
@@ -214,7 +233,13 @@ onMounted(() => {
</n-form> </n-form>
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="班级列表"> <n-form-item label="班级列表">
<n-dynamic-tags v-model:value="websiteConfig.class_list" /> <n-flex vertical size="small">
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-flex align="center"> <n-flex align="center">

View File

@@ -14,5 +14,7 @@ export function toProblemListItem(result: AdminProblem) {
has_ast_rules: result.has_ast_rules, has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart, allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart, show_flowchart: result.show_flowchart,
// 比赛题目列表接口不返回这个字段
top_reaction: result.top_reaction ?? null,
} }
} }

View File

@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api" import { importUsers } from "../api"
const message = useMessage() const message = useMessage()
@@ -12,6 +18,17 @@ function generateUsers() {
message.info("请填写相关内容") message.info("请填写相关内容")
return false return false
} }
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入,
// 这里先拦一道,省得填完一屏用户名才被打回来
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
}
let className = !!prefix.value ? `ks${prefix.value}` : "" let className = !!prefix.value ? `ks${prefix.value}` : ""
rawInput.value = rawInput.value.trim() rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n") const inputs = rawInput.value.split("\n")
@@ -64,9 +81,9 @@ async function submit() {
style="width: 170px" style="width: 170px"
v-model:value="prefix" v-model:value="prefix"
clearable clearable
:max="9999" :max="CLASS_NAME_MAX_VALUE"
:min="0" :min="0"
placeholder="班级号" :placeholder="`班级号${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
/> />
</n-flex> </n-flex>
<n-input <n-input

View File

@@ -172,7 +172,6 @@ function createNewUser() {
problem_permission: "None", problem_permission: "None",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
two_factor_auth: false,
open_api: false, open_api: false,
is_disabled: false, is_disabled: false,
password: "", password: "",
@@ -197,20 +196,29 @@ async function handleEditUser() {
message.error("密码长度不得小于 6") message.error("密码长度不得小于 6")
return return
} }
if (create.value) { // http 拦截器只对 login-required / permission-denied 自动弹提示,
const newUser = [ // 其余业务错误(比如班级号位数不对)不接住就什么都不显示
[ try {
userEditing.value.username, if (create.value) {
password.value, const newUser = [
userEditing.value.email, [
userEditing.value.real_name, userEditing.value.username,
], password.value,
] userEditing.value.email,
await importUsers(newUser) userEditing.value.real_name,
listUsers() ],
} else { ]
const user = Object.assign(userEditing.value, { password: password.value }) await importUsers(newUser)
await editUser(user) listUsers()
} else {
const user = Object.assign(userEditing.value, {
password: password.value,
})
await editUser(user)
}
} catch (err: any) {
message.error("保存失败:" + err.data)
return
} }
userEditing.value = null userEditing.value = null
password.value = "" password.value = ""

View File

@@ -1,22 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { Achievement } from "utils/types" import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>() const props = defineProps<{ achievement: Achievement }>()
const RARITY_COLOR: Record<string, string> = { // 边框用原色tag 里的文字用跟主题走的那套
bronze: "#b87333", const rarityTextColor = useRarityColor()
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
const RARITY_LABEL: Record<string, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null // 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条 // 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
@@ -67,96 +58,70 @@ const unlockDate = computed(() => {
:class="{ locked: !achievement.unlocked, rare: isRare }" :class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }" :style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
> >
<div class="row"> <n-thing>
<div class="icon"> <template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" /> <AchievementIcon :icon="achievement.icon" :size="32" />
</div> </template>
<div class="body">
<div class="title"> <template #header>
<span class="name">{{ achievement.name }}</span> <n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag <n-tag
size="tiny" size="tiny"
:color="{ :color="{
borderColor: RARITY_COLOR[achievement.rarity], borderColor: RARITY_COLOR[achievement.rarity],
textColor: RARITY_COLOR[achievement.rarity], textColor: rarityTextColor[achievement.rarity],
}" }"
> >
{{ RARITY_LABEL[achievement.rarity] }} {{ RARITY_LABEL[achievement.rarity] }}
</n-tag> </n-tag>
</div> </n-flex>
<div class="desc">{{ achievement.description }}</div> </template>
<div v-if="achievement.unlocked" class="meta"> <template #description>
<span>{{ unlockDate }}</span> <n-text depth="3">{{ achievement.description }}</n-text>
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span> </template>
</div>
<div v-else-if="showProgressBar" class="meta"> <n-flex align="center" :size="8" :wrap="false">
<template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</template>
<template v-else-if="showProgressBar">
<n-progress <n-progress
style="flex: 1"
type="line" type="line"
:percentage="percent" :percentage="percent"
:height="6" :height="6"
:show-indicator="false" :show-indicator="false"
/> />
<span class="progress-text"> <n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }} {{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</span> </n-text>
</div> </template>
<div v-else-if="showBestSoFar" class="meta"> <template v-else-if="showBestSoFar">
<span class="progress-text"> <n-text depth="3" class="nowrap">
目标 {{ achievement.threshold }} 目标 {{ achievement.threshold }}
</span> </n-text>
<span v-if="achievement.progress !== null" class="rate"> <n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
当前最好 {{ achievement.progress }} 当前最好 {{ achievement.progress }}
</span> </n-text>
</div> </template>
<div v-else class="meta"> <n-text v-else depth="3" class="nowrap">
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span> {{ achievement.unlock_rate }}% 的人获得
</div> </n-text>
</div> </n-flex>
</div> </n-thing>
</n-card> </n-card>
</template> </template>
<style scoped> <style scoped>
.row { .nowrap {
display: flex;
gap: 12px;
align-items: flex-start;
}
.icon {
font-size: 32px;
line-height: 1;
}
.body {
flex: 1;
min-width: 0;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.desc {
margin-top: 4px;
font-size: 13px;
opacity: 0.75;
}
.meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.65;
}
.meta :deep(.n-progress) {
flex: 1;
}
.progress-text {
white-space: nowrap; white-space: nowrap;
} }
.locked { .locked {

View File

@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { getAchievements, getAchievementSummary } from "oj/achievement/api" import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api" import { getUserBadges } from "oj/api"
import type { Achievement, AchievementSummary } from "utils/types" import { useBreakpoints } from "shared/composables/breakpoints"
import { useRarityColor } from "shared/composables/rarity"
import type {
Achievement,
AchievementRarity,
AchievementSummary,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue" import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge { interface UserBadge {
@@ -13,14 +19,37 @@ interface UserBadge {
description: string description: string
icon: string icon: string
} }
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
} }
const route = useRoute() const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined) const name = computed(() => (route.query.name as string) || undefined)
// 标签和进度条同色,整行读作一个单位
const rarityColor = useRarityColor()
const achievements = ref<Achievement[]>([]) const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null) const summary = ref<AchievementSummary | null>(null)
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
const RARITY_RANK: Record<AchievementRarity, number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
}
const rarities = computed(() =>
[...(summary.value?.rarity ?? [])].sort(
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
),
)
const badges = ref<UserBadge[]>([]) const badges = ref<UserBadge[]>([])
const { isDesktop } = useBreakpoints()
const tab = ref("all") const tab = ref("all")
const loading = ref(true) const loading = ref(true)
@@ -55,26 +84,52 @@ watch(name, load)
<template> <template>
<div class="hall"> <div class="hall">
<n-spin :show="loading"> <!-- delay 50ms缓存命中时数据几乎立刻回来不闪一下转圈 -->
<n-card v-if="summary" class="overview"> <n-spin :show="loading" :delay="50" style="min-height: 240px">
<div class="overview-row"> <n-card v-if="summary">
<div class="percent"> <n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
<div class="big">{{ summary.percent }}%</div> <n-flex vertical align="center" :size="6">
<div class="sub"> <n-progress
{{ summary.unlocked }} / {{ summary.total }} 已获得 type="circle"
</div> :percentage="summary.percent"
</div> :stroke-width="8"
<div class="rarity">
<div
v-for="r in summary.rarity"
:key="r.rarity"
class="rarity-item"
> >
<div class="rarity-label">{{ r.label }}</div> <n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
<div class="rarity-count">{{ r.unlocked }} / {{ r.total }}</div> {{ summary.percent }}%
</div> </n-text>
</div> </n-progress>
</div> <n-text depth="3" class="nowrap">
已获得 {{ summary.unlocked }} / {{ summary.total }}
</n-text>
</n-flex>
<n-flex vertical :size="8" class="rarity">
<n-flex
v-for="r in rarities"
:key="r.rarity"
align="center"
:wrap="false"
:size="10"
>
<n-text strong :style="{ color: rarityColor[r.rarity] }">
{{ r.label }}
</n-text>
<n-progress
style="flex: 1"
type="line"
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
:height="6"
:border-radius="3"
:fill-border-radius="3"
:color="rarityColor[r.rarity]"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ r.unlocked }} / {{ r.total }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card> </n-card>
<n-tabs v-model:value="tab" type="line" class="tabs"> <n-tabs v-model:value="tab" type="line" class="tabs">
@@ -84,23 +139,61 @@ watch(name, load)
<n-tab name="badges">题单奖章</n-tab> <n-tab name="badges">题单奖章</n-tab>
</n-tabs> </n-tabs>
<div v-if="tab !== 'badges'" class="grid"> <template v-if="tab !== 'badges'">
<AchievementCard v-for="a in filtered" :key="a.id" :achievement="a" /> <n-grid
<n-empty v-if="!filtered.length" description="这里还什么都没有" /> v-if="filtered.length"
</div> responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="a in filtered" :key="a.id">
<AchievementCard :achievement="a" />
</n-gi>
</n-grid>
<!-- 加载中不显示空态不然首屏会闪一下"什么都没有" -->
<n-empty v-else-if="!loading" description="这里还什么都没有" />
</template>
<div v-else class="grid"> <template v-else>
<n-card v-for="b in badges" :key="b.id" size="small"> <n-grid
<div class="badge-row"> v-if="badges.length"
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" /> responsive="screen"
<div> cols="1 s:2 l:3"
<div class="badge-name">{{ b.badge?.name }}</div> :x-gap="12"
<div class="badge-desc">{{ b.badge?.description }}</div> :y-gap="12"
</div> >
</div> <n-gi v-for="b in badges" :key="b.id">
</n-card> <n-card size="small">
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" /> <n-thing
</div> :title="b.badge?.name"
:description="b.badge?.description"
>
<template #avatar v-if="b.badge?.icon">
<n-avatar
:size="40"
:src="b.badge.icon"
color="transparent"
object-fit="contain"
/>
</template>
<n-text v-if="b.problemset" depth="3" class="source">
来自题单
<router-link
:to="{
name: 'problemset',
params: { problemSetId: b.problemset.id },
}"
>
{{ b.problemset.title }}
</router-link>
</n-text>
</n-thing>
</n-card>
</n-gi>
</n-grid>
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
</template>
</n-spin> </n-spin>
</div> </div>
</template> </template>
@@ -111,60 +204,24 @@ watch(name, load)
margin: 0 auto; margin: 0 auto;
padding: 16px; padding: 16px;
} }
.overview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.big {
font-size: 40px;
font-weight: 700;
line-height: 1;
}
.sub {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
}
.rarity { .rarity {
display: flex; flex: 1;
gap: 20px; max-width: 420px;
} }
.rarity-item { .nowrap {
text-align: center; white-space: nowrap;
}
.rarity-label {
font-size: 12px;
opacity: 0.7;
}
.rarity-count {
font-weight: 600;
margin-top: 2px;
} }
.tabs { .tabs {
margin: 16px 0; margin: 16px 0;
} }
.grid { .source {
display: grid; display: block;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-top: 6px;
gap: 12px; font-size: 13px;
} }
.badge-row { .source a {
display: flex; color: inherit;
gap: 12px; text-decoration: underline;
align-items: center; text-underline-offset: 2px;
}
.badge-icon {
width: 40px;
height: 40px;
}
.badge-name {
font-weight: 600;
}
.badge-desc {
font-size: 12px;
opacity: 0.7;
} }
</style> </style>

View File

@@ -3,6 +3,8 @@ import { filterResult } from "oj/transforms"
import type { import type {
Exercise, Exercise,
Problem, Problem,
ReactionKey,
ReactionState,
Submission, Submission,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
@@ -223,22 +225,17 @@ export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } }) return http.get("message", { params: { limit, offset } })
} }
export function createComment(data: { export function getReaction(problemID: number) {
problem_id: number return http.get<ReactionState>("reaction", {
description_rating: number params: { problem_id: problemID },
difficulty_rating: number })
comprehensive_rating: number
content: string
}) {
return http.post("comment", data)
} }
export function getComment(problemID: number) { export function setReaction(problemID: number, type: ReactionKey) {
return http.get("comment", { params: { problem_id: problemID } }) return http.post<ReactionState>("reaction", {
} problem_id: problemID,
type,
export function getCommentStatistics(problemID: number) { })
return http.get("comment/statistics", { params: { problem_id: problemID } })
} }
// TODO: 这个API有问题 // TODO: 这个API有问题

View File

@@ -1,197 +0,0 @@
<template>
<n-alert type="error" v-if="!userStore.isAuthed" title="请先登录" />
<div v-else>
<n-alert
v-if="problem?.my_status !== 0"
class="title"
type="error"
title="请先完成该题"
></n-alert>
<div v-else>
<n-alert class="title" type="success" :title="title"></n-alert>
<n-form>
<n-form-item
label-width="220"
label-align="left"
label="题目是否描述清楚"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in description_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="description_rating" />
</n-form-item>
<n-form-item
label-width="220"
label-align="left"
:label="
'难度是否匹配(此题是' + DIFFICULTY[problem.difficulty] + '的)'
"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in difficulty_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="difficulty_rating" />
</n-form-item>
<n-form-item
label-width="220"
label-align="left"
label="综合评分"
label-placement="left"
>
<Icon
v-if="hasCommented"
icon="fluent-emoji:star"
:width="24"
v-for="(_, i) in difficulty_rating"
:key="i"
/>
<n-rate v-else size="large" v-model:value="comprehensive_rating" />
</n-form-item>
<n-form-item
v-if="!hasCommented"
label="对这道题的评价(可选,注意文明用语)"
>
<n-input v-model:value="content" type="textarea" />
</n-form-item>
<n-form-item v-if="hasCommented && content" label="你对这道题的评价:">
{{ content }}
</n-form-item>
<n-button
v-if="hasCommented && showStatistics"
type="primary"
@click="getComments"
>
查看统计
</n-button>
<n-button v-if="!hasCommented" type="primary" @click="submit">
提交
</n-button>
</n-form>
<div v-if="showStatistics">
<n-descriptions
class="list"
v-if="count"
:column="4"
bordered
label-placement="left"
>
<n-descriptions-item label="评论">
{{ count }}
</n-descriptions-item>
<n-descriptions-item label="描述">
{{ rating.description }}
</n-descriptions-item>
<n-descriptions-item label="难度">
{{ rating.difficulty }}
</n-descriptions-item>
<n-descriptions-item label="综合">
{{ rating.comprehensive }}
</n-descriptions-item>
</n-descriptions>
<n-empty class="list" v-if="show && count === 0">
暂无记录快去评论吧
</n-empty>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants"
import { createComment, getComment, getCommentStatistics } from "oj/api"
import { useUserStore } from "shared/store/user"
interface Props {
showStatistics?: boolean
}
const { showStatistics = true } = defineProps<Props>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const content = ref("")
const description_rating = ref(0)
const difficulty_rating = ref(0)
const comprehensive_rating = ref(0)
const [show, toggleShow] = useToggle()
const [hasCommented, toggleHasCommented] = useToggle()
const count = ref(0)
const rating = reactive({
description: 0,
difficulty: 0,
comprehensive: 0,
})
const title = computed(() => {
if (hasCommented.value) return "这道题你已经打过分了,你的评分如下:"
return "你已经完成了这道题,请给这道题打分吧!"
})
async function submit() {
if (
description_rating.value === 0 ||
difficulty_rating.value === 0 ||
comprehensive_rating.value === 0
) {
message.error("请完成打分")
return
}
const data = {
problem_id: problem.value!.id,
content: content.value,
description_rating: description_rating.value,
difficulty_rating: difficulty_rating.value,
comprehensive_rating: comprehensive_rating.value,
}
await createComment(data)
toggleHasCommented(true)
message.success("提交成功")
}
async function getComments() {
const res = await getCommentStatistics(problem.value!.id)
toggleShow(true)
if (!res.data) return
count.value = res.data.count
rating.description = res.data.rating.description
rating.difficulty = res.data.rating.difficulty
rating.comprehensive = res.data.rating.comprehensive
}
async function getMyComment() {
const res = await getComment(problem.value!.id)
if (!res.data) return
content.value = res.data.content
description_rating.value = res.data.description_rating
difficulty_rating.value = res.data.difficulty_rating
comprehensive_rating.value = res.data.comprehensive_rating
toggleHasCommented(true)
}
onMounted(getMyComment)
</script>
<style scoped>
.title {
margin-bottom: 24px;
}
.list {
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,698 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import type { CSSProperties } from "vue"
import { getReaction, setReaction } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types"
const emit = defineEmits<{ submitted: [] }>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const theme = useThemeVars()
const mine = ref<ReactionKey | null>(null)
const counts = ref<ReactionCounts | null>(null)
const loading = ref(false)
// 正在提交的 key用来锁住整组并只在当前选项显示进度。
const submitting = ref<ReactionKey | null>(null)
const activeIndex = ref<number | null>(null)
const keyboardActive = ref(false)
const wheelRef = ref<HTMLElement | null>(null)
let loadSequence = 0
const wheelGeometry = {
startAngle: -90,
contentRadius: 35,
pushRadius: 4,
outerRadius: 50,
arcPointCount: 9,
hitInnerRadius: 0.18,
hitOuterRadius: 0.49,
} as const
const sliceAngle = 360 / REACTIONS.length
function pointOnCircle(angle: number, radius: number) {
const radians = (angle * Math.PI) / 180
return {
x: 50 + Math.cos(radians) * radius,
y: 50 + Math.sin(radians) * radius,
}
}
function getWheelItemStyle(index: number): CSSProperties {
const centerAngle = wheelGeometry.startAngle + index * sliceAngle
const startAngle = centerAngle - sliceAngle / 2
const endAngle = centerAngle + sliceAngle / 2
const dividerAngle = index * sliceAngle - sliceAngle / 2
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
const arcPoints = Array.from(
{ length: wheelGeometry.arcPointCount },
(_, pointIndex) => {
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
const angle = startAngle + (endAngle - startAngle) * progress
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
},
)
return {
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
"--content-x": `${position.x}%`,
"--content-y": `${position.y}%`,
"--push-x": `${push.x - 50}px`,
"--push-y": `${push.y - 50}px`,
"--divider-angle": `${dividerAngle}deg`,
}
}
const wheelItems = REACTIONS.map((item, index) => ({
...item,
index,
style: getWheelItemStyle(index),
}))
const solved = computed(() => problem.value?.my_status === 0)
const locked = computed(() => mine.value !== null)
const canInteract = computed(
() =>
userStore.isAuthed &&
!!problem.value &&
solved.value &&
!locked.value &&
!loading.value &&
!submitting.value,
)
const wheelCenter = computed(() => {
if (loading.value) {
return {
icon: "ph:spinner-gap-bold",
eyebrow: "正在读取",
label: "题目点评",
spinning: true,
}
}
if (submitting.value) {
const item = REACTIONS.find((reaction) => reaction.key === submitting.value)
return {
icon: "svg-spinners:180-ring-with-bg",
eyebrow: "正在记录",
label: item?.label ?? "提交点评",
spinning: false,
}
}
if (mine.value) {
const item = REACTIONS.find((reaction) => reaction.key === mine.value)
return {
icon: "ph:check-bold",
eyebrow: "你的选择",
label: item?.label ?? "已提交",
spinning: false,
}
}
if (!userStore.isAuthed) {
return {
icon: "ph:user-circle-dashed",
eyebrow: "登录后开放",
label: "登录后点评",
spinning: false,
}
}
if (activeIndex.value !== null) {
const item = wheelItems[activeIndex.value]
const count = counts.value?.[item.key]
return {
icon: item.icon,
eyebrow: count === undefined ? "选择这项" : `${count} 人选择`,
label: item.label,
spinning: false,
}
}
if (!solved.value) {
return {
icon: "ph:lock-simple-bold",
eyebrow: "完成后开放",
label: "通关后点评",
spinning: false,
}
}
return {
icon: "ph:cursor-click-bold",
eyebrow: "移动到扇区",
label: "选择点评",
spinning: false,
}
})
const reactionStyle = computed(() => ({
"--reaction-accent": theme.value.primaryColor,
"--reaction-card": theme.value.cardColor,
"--reaction-border": theme.value.borderColor,
"--reaction-text": theme.value.textColor1,
"--reaction-text-faint": theme.value.textColor3,
}))
function optionAriaLabel(key: ReactionKey, label: string) {
const count = counts.value?.[key]
const countText = count === undefined ? "" : `${count} 人选择`
const selectedText = mine.value === key ? ",你的选择" : ""
return `${label}${countText}${selectedText}`
}
function getPointerIndex(event: PointerEvent | MouseEvent) {
const wheel = wheelRef.value
if (!wheel) return null
const bounds = wheel.getBoundingClientRect()
const x = event.clientX - (bounds.left + bounds.width / 2)
const y = event.clientY - (bounds.top + bounds.height / 2)
const distance = Math.hypot(x, y)
if (
distance < bounds.width * wheelGeometry.hitInnerRadius ||
distance > bounds.width * wheelGeometry.hitOuterRadius
) {
return null
}
const angle = (Math.atan2(y, x) * 180) / Math.PI
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
return (
((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
)
}
function preview(index: number, fromKeyboard = false) {
if (!canInteract.value) return
keyboardActive.value = fromKeyboard
activeIndex.value = index
}
function clearPreview() {
if (locked.value) return
activeIndex.value = null
keyboardActive.value = false
}
function onWheelPointerMove(event: PointerEvent) {
if (!canInteract.value) return
keyboardActive.value = false
activeIndex.value = getPointerIndex(event)
}
function onWheelClick(event: MouseEvent) {
if (!canInteract.value) return
const target = event.target
if (target instanceof Element && target.closest(".reaction-option")) return
const index = getPointerIndex(event)
if (index !== null) pick(wheelItems[index].key)
}
async function pick(key: ReactionKey) {
if (
!problem.value ||
!solved.value ||
locked.value ||
loading.value ||
submitting.value
)
return
activeIndex.value = null
keyboardActive.value = false
submitting.value = key
try {
const res = await setReaction(problem.value.id, key)
mine.value = res.data.mine
counts.value = res.data.counts
emit("submitted")
} catch {
message.error("提交失败,请重试")
} finally {
submitting.value = null
}
}
async function load(problemId: number) {
const sequence = ++loadSequence
loading.value = true
try {
const res = await getReaction(problemId)
if (sequence !== loadSequence) return
mine.value = res.data.mine
counts.value = res.data.counts
} catch {
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
} finally {
if (sequence === loadSequence) loading.value = false
}
}
watch(
[() => userStore.isAuthed, () => problem.value?.id],
([isAuthed, problemId]) => {
mine.value = null
counts.value = null
submitting.value = null
activeIndex.value = null
keyboardActive.value = false
if (!isAuthed || problemId === undefined) {
loadSequence += 1
loading.value = false
return
}
load(problemId)
},
{ immediate: true },
)
</script>
<template>
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
<div class="wheel-stage">
<div
ref="wheelRef"
class="reaction-wheel"
:class="{
'has-selection': locked,
'is-disabled': !canInteract,
'is-keyboard-active': keyboardActive,
}"
role="group"
aria-label="选择一项题目点评点击后立即提交"
:aria-busy="loading || !!submitting"
@pointermove="onWheelPointerMove"
@pointerleave="clearPreview"
@click="onWheelClick"
>
<span
v-for="item in wheelItems"
:key="`${item.key}-face`"
class="segment-face"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
}"
:style="item.style"
aria-hidden="true"
/>
<span
v-for="item in wheelItems"
:key="`${item.key}-divider`"
class="segment-divider"
:style="item.style"
aria-hidden="true"
/>
<button
v-for="item in wheelItems"
:key="item.key"
type="button"
class="reaction-option"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
'is-muted': locked && mine !== item.key,
'is-unavailable': !userStore.isAuthed || !solved || loading,
}"
:style="item.style"
:disabled="!canInteract"
:aria-pressed="mine === item.key"
:aria-label="optionAriaLabel(item.key, item.label)"
@focus="preview(item.index, true)"
@blur="clearPreview"
@click.stop="pick(item.key)"
>
<span class="option-content">
<span class="option-icon" aria-hidden="true">
<Icon
:icon="
submitting === item.key
? 'svg-spinners:180-ring-with-bg'
: item.icon
"
/>
</span>
<span class="option-label">{{ item.label }}</span>
<span v-if="counts" class="option-count">
{{ counts[item.key] }}
</span>
</span>
</button>
<div class="wheel-core" aria-hidden="true">
<div class="core-content">
<Icon
class="core-icon"
:class="{ 'is-spinning': wheelCenter.spinning }"
:icon="wheelCenter.icon"
/>
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
<strong class="core-label">{{ wheelCenter.label }}</strong>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.reaction-panel {
width: min(100%, 720px);
box-sizing: border-box;
container-type: inline-size;
margin: 0 auto;
padding: clamp(12px, 3vw, 24px);
color: var(--reaction-text);
}
.wheel-stage {
display: grid;
place-items: center;
padding: 4px 0;
}
.reaction-wheel {
position: relative;
width: min(100%, 400px);
box-sizing: border-box;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
isolation: isolate;
}
.segment-face {
position: absolute;
inset: 1px;
overflow: hidden;
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
clip-path: var(--segment-path);
transform: translate(0, 0) scale(1);
transform-origin: center;
pointer-events: none;
transition:
transform 140ms cubic-bezier(0, 0, 0.2, 1),
background-color 140ms ease-out;
}
.segment-face:is(.is-active, .is-submitting) {
z-index: 2;
background: color-mix(
in srgb,
var(--reaction-accent) 10%,
var(--reaction-card)
);
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7))
scale(1.045);
}
.segment-face.is-selected {
z-index: 3;
background: color-mix(
in srgb,
var(--reaction-accent) 18%,
var(--reaction-card)
);
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
}
.segment-divider {
position: absolute;
z-index: 8;
top: 1px;
left: calc(50% - 1px);
width: 2px;
height: calc(50% - 1px);
background: var(--reaction-border);
transform: rotate(var(--divider-angle));
transform-origin: 50% 100%;
pointer-events: none;
}
.reaction-option {
position: absolute;
z-index: 9;
top: var(--content-y);
left: var(--content-x);
display: flex;
width: clamp(66px, 20%, 80px);
min-height: clamp(52px, 16%, 64px);
align-items: stretch;
justify-content: stretch;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--reaction-text);
font: inherit;
transform: translate(-50%, -50%);
touch-action: manipulation;
cursor: pointer;
}
.reaction-option:disabled {
cursor: not-allowed;
}
.reaction-option.is-unavailable {
opacity: 0.58;
}
.reaction-option.is-muted {
opacity: 0.48;
}
.reaction-option:is(.is-selected, .is-submitting) {
opacity: 1;
}
.reaction-option:focus-visible {
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
outline-offset: 2px;
}
.option-content {
display: flex;
width: 100%;
min-height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
border-radius: 6px;
transform: translate(0, 0) scale(1);
transition: transform 140ms cubic-bezier(0, 0, 0.2, 1);
}
.reaction-option:active:not(:disabled) .option-content {
transform: scale(0.96);
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
scale(1.18);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
.option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
scale(1.1);
}
.reaction-option.is-selected .option-content {
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
}
.option-icon {
display: grid;
width: 28px;
height: 28px;
place-items: center;
font-size: 24px;
line-height: 1;
}
.option-icon svg {
width: 24px;
height: 24px;
}
.option-label {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.option-count {
color: var(--reaction-text-faint);
font-size: 12px;
font-weight: 400;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.reaction-option.is-selected .option-count {
color: var(--reaction-text);
}
.wheel-core {
position: absolute;
z-index: 12;
top: 50%;
left: 50%;
display: grid;
width: 31%;
aspect-ratio: 1;
place-items: center;
padding: 12px;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
color: var(--reaction-text);
text-align: center;
transform: translate(-50%, -50%);
pointer-events: none;
}
.core-content {
display: grid;
justify-items: center;
gap: 3px;
}
.core-icon {
width: clamp(20px, 5cqi, 26px);
height: clamp(20px, 5cqi, 26px);
font-size: clamp(20px, 5cqi, 26px);
color: var(--reaction-text-faint);
}
.core-eyebrow {
color: var(--reaction-text-faint);
font-size: clamp(9px, 2.3cqi, 11px);
font-weight: 400;
}
.core-label {
font-size: clamp(12px, 3cqi, 15px);
font-weight: 600;
line-height: 1.2;
text-wrap: balance;
}
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
transition-duration: 0.01ms;
}
.is-spinning {
animation: reaction-spin 850ms linear infinite;
}
@container (max-width: 440px) {
.wheel-stage {
padding-top: 0;
}
.reaction-option {
width: clamp(58px, 20%, 70px);
min-height: clamp(48px, 16%, 56px);
}
.segment-face:is(.is-active, .is-submitting) {
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
scale(1.03);
}
.segment-face.is-selected {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
scale(1.05);
}
.option-content {
gap: 2px;
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
scale(1.12);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
.option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
scale(1.06);
}
.reaction-option.is-selected .option-content {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
scale(1.17);
}
.option-icon {
width: 24px;
height: 24px;
font-size: 21px;
}
.option-icon svg {
width: 21px;
height: 21px;
}
.option-label {
font-size: 11px;
}
.option-count {
font-size: 11px;
}
.wheel-core {
padding: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.segment-face,
.option-content,
.option-icon,
.option-label,
.option-count {
transition-duration: 0.01ms;
}
.is-spinning {
animation-duration: 1.8s;
}
}
@keyframes reaction-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { import {
formatCode, formatCode,
getComment, getReaction,
submitCode, submitCode,
updateProblemSetProgress, updateProblemSetProgress,
} from "oj/api" } from "oj/api"
@@ -17,11 +17,14 @@ import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState" import { getSubmitButtonState } from "./submitButtonState"
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 { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck" import {
checkPythonSyntax,
prefetchPythonSyntaxChecker,
} from "oj/problem/utils/pythonSyntaxCheck"
// ==================== 异步组件 ==================== // ==================== 异步组件 ====================
const ProblemComment = defineAsyncComponent( const ProblemReaction = defineAsyncComponent(
() => import("./ProblemComment.vue"), () => import("./ProblemReaction.vue"),
) )
// ==================== 基础状态 ==================== // ==================== 基础状态 ====================
@@ -37,6 +40,10 @@ const router = useRouter()
const [commentPanel] = useToggle() const [commentPanel] = useToggle()
const message = useMessage() const message = useMessage()
function closeCommentPanel() {
commentPanel.value = false
}
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
// ==================== 烟花效果 ==================== // ==================== 烟花效果 ====================
@@ -50,6 +57,16 @@ const showResult = ref(false)
const isFormatting = ref(false) const isFormatting = ref(false)
const isSubmittingRequest = ref(false) const isSubmittingRequest = ref(false)
// ==================== Python 语法检测器预取 ====================
// 选中 Python3 时就把 Skulpt 拉下来,避免点提交时才开始下载
watch(
() => codeStore.code.language,
(language) => {
if (language === "Python3") prefetchPythonSyntaxChecker()
},
{ immediate: true },
)
// ==================== 提交冷却 ==================== // ==================== 提交冷却 ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, { const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
controls: true, controls: true,
@@ -59,8 +76,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
// ==================== AC后显示评论框 ==================== // ==================== AC后显示评论框 ====================
const { start: showCommentPanelDelayed } = useTimeoutFn( const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => { async () => {
const res = await getComment(problem.value!.id) const res = await getReaction(problem.value!.id)
if (!res.data) { if (res.data.mine === null) {
commentPanel.value = true commentPanel.value = true
} }
}, },
@@ -99,7 +116,7 @@ async function submit() {
// 0. Python3 语法检测 // 0. Python3 语法检测
if (codeStore.code.language === "Python3") { if (codeStore.code.language === "Python3") {
const syntaxError = checkPythonSyntax(codeStore.code.value) const syntaxError = await checkPythonSyntax(codeStore.code.value)
if (syntaxError) { if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`) message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
return return
@@ -246,11 +263,13 @@ watch(
<!-- 评价弹窗 --> <!-- 评价弹窗 -->
<n-modal <n-modal
preset="card" preset="card"
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)" title="恭喜你成功提交,说说你对这道题的感受吧"
:mask-closable="false" :mask-closable="false"
:closable="false"
:close-on-esc="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel" v-model:show="commentPanel"
> >
<ProblemComment :showStatistics="false" /> <ProblemReaction @submitted="closeCommentPanel" />
</n-modal> </n-modal>
</template> </template>

View File

@@ -138,6 +138,7 @@ export function useFireworks() {
confetti({ confetti({
particleCount: 12, particleCount: 12,
angle: 270,
spread: 100, spread: 100,
startVelocity: 12, startVelocity: 12,
gravity: 0.6, gravity: 0.6,
@@ -145,7 +146,7 @@ export function useFireworks() {
ticks: 200, ticks: 200,
scalar: 1.2, scalar: 1.2,
shapes: ["star"], shapes: ["star"],
origin: { x: Math.random(), y: -0.1 }, origin: { x: Math.random(), y: 0 },
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"], colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
}) })
}, 150) }, 150)
@@ -285,29 +286,32 @@ export function useFireworks() {
// 效果7: 螺旋上升 // 效果7: 螺旋上升
() => { () => {
const defaults = { const steps = 16
spread: 360, const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
ticks: 100,
gravity: 0,
decay: 0.94,
startVelocity: 30,
}
function shoot() { for (let step = 0; step < steps; step++) {
confetti({ runLater(() => {
...defaults, const progress = step / (steps - 1)
particleCount: 50, const phase = step * (Math.PI / 4)
scalar: 1.2,
shapes: ["circle", "square"],
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
})
}
runLater(shoot, 0) confetti({
runLater(shoot, 100) particleCount: 7,
runLater(shoot, 200) angle: 90 + Math.cos(phase) * 18,
runLater(shoot, 300) spread: 25,
runLater(shoot, 400) startVelocity: 16,
gravity: 0.25,
decay: 0.94,
ticks: 100,
scalar: 0.9,
shapes: ["circle", "square"],
origin: {
x: 0.5 + Math.sin(phase) * 0.14,
y: 0.85 - progress * 0.55,
},
colors,
})
}, step * 65)
}
}, },
// 效果8: 表情包烟花 // 效果8: 表情包烟花
@@ -315,6 +319,7 @@ export function useFireworks() {
loadEmojiShapes().then((emojiShapes) => { loadEmojiShapes().then((emojiShapes) => {
confetti({ confetti({
shapes: emojiShapes, shapes: emojiShapes,
flat: true,
scalar: 6, scalar: 6,
particleCount: 18, particleCount: 18,
spread: 360, spread: 360,
@@ -349,16 +354,28 @@ export function useFireworks() {
// 效果10: 礼花瀑布 // 效果10: 礼花瀑布
() => { () => {
confetti({ const animationEnd = Date.now() + 1800
particleCount: 150, const colors = ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"]
spread: 180,
startVelocity: 0, const interval = setInterval(() => {
gravity: 1.2, if (Date.now() >= animationEnd) return stopTimer(interval)
decay: 0.95,
ticks: 300, for (let column = 0; column < 6; column++) {
origin: { x: 0.5, y: 0 }, confetti({
colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"], particleCount: 2,
}) angle: 270,
spread: 30,
startVelocity: 5,
gravity: 1,
decay: 0.96,
ticks: 240,
scalar: 1.1,
origin: { x: (column + Math.random()) / 6, y: 0 },
colors,
})
}
}, 120)
timers.add(interval)
}, },
] ]

View File

@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
const ProblemSubmission = defineAsyncComponent( const ProblemSubmission = defineAsyncComponent(
() => import("./components/ProblemSubmission.vue"), () => import("./components/ProblemSubmission.vue"),
) )
const ProblemComment = defineAsyncComponent( const ProblemReaction = defineAsyncComponent(
() => import("./components/ProblemComment.vue"), () => import("./components/ProblemReaction.vue"),
) )
const ProblemFlowchart = defineAsyncComponent( const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"), () => import("./components/ProblemFlowchart.vue"),
@@ -174,7 +174,7 @@ watch(
tab="题目点评" tab="题目点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemReaction />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
@@ -226,7 +226,7 @@ watch(
tab="题目点评" tab="题目点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemReaction />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
@@ -266,7 +266,7 @@ watch(
tab="点评" tab="点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemReaction />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"

View File

@@ -1,15 +1,47 @@
// @ts-ignore - skulpt has no type definitions
import Sk from "skulpt"
export interface PythonSyntaxError { export interface PythonSyntaxError {
line: number line: number
} }
let skulptPromise: Promise<any> | null = null
/**
* 按需加载 Skulpt约 233KB gzip只在提交 Python3 代码时才下载。
* 结果缓存,同一页面只加载一次。
*/
function loadSkulpt(): Promise<any> {
if (!skulptPromise) {
// @ts-ignore - skulpt has no type definitions
skulptPromise = import("skulpt").then((m) => m.default ?? m)
}
return skulptPromise
}
/**
* 提前把 Skulpt 拉下来,避免点提交时才开始下载。
* 失败不影响功能,提交时会再试一次。
*/
export function prefetchPythonSyntaxChecker() {
loadSkulpt().catch(() => {
skulptPromise = null
})
}
/** /**
* 用 Skulpt 检测 Python 代码中的语法错误。 * 用 Skulpt 检测 Python 代码中的语法错误。
* 只编译不执行,不受 input() 等 IO 调用影响。 * 只编译不执行,不受 input() 等 IO 调用影响。
* 加载失败时返回 null放行提交交给后端判题兜底。
*/ */
export function checkPythonSyntax(code: string): PythonSyntaxError | null { export async function checkPythonSyntax(
code: string,
): Promise<PythonSyntaxError | null> {
let Sk: any
try {
Sk = await loadSkulpt()
} catch {
skulptPromise = null
return null
}
Sk.configure({ output: () => {} }) Sk.configure({ output: () => {} })
try { try {
Sk.compile(code, "prog.py", "exec") Sk.compile(code, "prog.py", "exec")

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props { interface Props {
type: "题目" | "用户" type: "题目" | "用户"
@@ -10,12 +11,14 @@ const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"]) const emits = defineEmits(["click", "search", "filterClass"])
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
// 也显示出按钮,点了却什么都不发生
const showFilterClass = computed(() => { const showFilterClass = computed(() => {
return props.type === "用户" && props.username?.startsWith("ks") return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
}) })
function filterClass() { function filterClass() {
const match = props.username!.match(/^ks\d{3,4}/) const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : "" const classname = match ? match[0] : ""
if (!classname) return if (!classname) return
emits("filterClass", classname) emits("filterClass", classname)

View File

@@ -4,15 +4,10 @@ import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { import type { AchievementSummary, Profile } from "utils/types"
AchievementSummary,
Profile,
UserBadge as UserBadgeType,
} from "utils/types"
import { getAchievementSummary } from "oj/achievement/api" import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics, getUserBadges } from "../api" import { getMetrics } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
const route = useRoute() const route = useRoute()
@@ -24,7 +19,6 @@ const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("") const latestSubmissionAt = ref("")
const toLatestAt = ref("") const toLatestAt = ref("")
const learnDuration = ref("") const learnDuration = ref("")
const userBadges = ref<GroupedBadge[]>([])
const achievementSummary = ref<AchievementSummary | null>(null) const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
@@ -65,40 +59,6 @@ const hasMoreProblems = computed(
() => problems.value.length > itemsPerRow.value * 3, () => problems.value.length > itemsPerRow.value * 3,
) )
// 分组徽章接口
interface GroupedBadge {
icon: string
count: number
badges: UserBadgeType[]
latestEarnedTime: Date
}
// 按图标分组徽章
function groupBadgesByIcon(badges: UserBadgeType[]): GroupedBadge[] {
const grouped = new Map<string, UserBadgeType[]>()
// 按图标分组
badges.forEach((badge) => {
const icon = badge.badge.icon
if (!grouped.has(icon)) {
grouped.set(icon, [])
}
grouped.get(icon)!.push(badge)
})
// 转换为数组并排序
return Array.from(grouped.entries())
.map(([icon, badgeList]) => ({
icon,
count: badgeList.length,
badges: badgeList,
latestEarnedTime: new Date(
Math.max(...badgeList.map((b) => new Date(b.earned_time).getTime())),
),
}))
.sort((a, b) => a.icon.localeCompare(b.icon))
}
async function init() { async function init() {
toggle(true) toggle(true)
try { try {
@@ -113,23 +73,9 @@ async function init() {
}) })
ac.sort() ac.sort()
problems.value = ac problems.value = ac
const promises: Promise<{ data: any }>[] = []
if (profile.value.submission_number > 0) { if (profile.value.submission_number > 0) {
promises.push(getMetrics(profile.value.user.id)) const metricsRes = await getMetrics(profile.value.user.id)
}
if (route.query.name) {
promises.push(getUserBadges(route.query.name as string))
} else {
promises.push(getUserBadges())
}
const results = await Promise.all(promises)
// 处理 metrics 结果
if (profile.value.submission_number > 0) {
const metricsRes = results[0]
firstSubmissionAt.value = parseTime(metricsRes.data.first) firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest) latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays( toLatestAt.value = durationToDays(
@@ -141,9 +87,6 @@ async function init() {
metricsRes.data.latest, metricsRes.data.latest,
) )
} }
// 处理 badges 结果
userBadges.value = groupBadgesByIcon(results[1].data)
} finally { } finally {
toggle(false) toggle(false)
} }
@@ -245,14 +188,14 @@ onMounted(() => {
<n-grid <n-grid
v-if="profile && profile.submission_number > 0" v-if="profile && profile.submission_number > 0"
class="wrapper" class="wrapper"
:cols="2" :cols="isDesktop ? 2 : 1"
:x-gap="10" :x-gap="10"
:y-gap="10" :y-gap="10"
> >
<n-gi v-for="item in metrics" :key="item.content"> <n-gi v-for="item in metrics" :key="item.content">
<n-card hoverable> <n-card hoverable>
<n-flex align="center"> <n-flex align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="50" /> <Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
<div> <div>
<Component :is="isDesktop ? NH2 : NH3" class="number"> <Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" /> <n-number-animation v-if="item.animate" :to="item.title" />
@@ -297,6 +240,9 @@ onMounted(() => {
</n-button> </n-button>
</n-flex> </n-flex>
<n-flex align="center" :size="10" class="achievement-recent"> <n-flex align="center" :size="10" class="achievement-recent">
<n-text v-if="achievementSummary.recent.length" depth="3">
最近获得
</n-text>
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id"> <n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger> <template #trigger>
<span class="achievement-icon"> <span class="achievement-icon">
@@ -311,24 +257,6 @@ onMounted(() => {
</n-flex> </n-flex>
</n-card> </n-card>
<!-- 徽章展示卡片 -->
<n-card
v-if="!loading && profile && userBadges.length > 0"
class="wrapper"
hoverable
>
<template #header>
<n-h4 style="margin: 0">获得的徽章</n-h4>
</template>
<n-flex wrap>
<GroupedUserBadge
v-for="groupedBadge in userBadges"
:key="groupedBadge.icon"
:grouped-badge="groupedBadge"
/>
</n-flex>
</n-card>
<n-descriptions v-if="!loading && profile" class="wrapper" bordered> <n-descriptions v-if="!loading && profile" class="wrapper" bordered>
<n-descriptions-item v-if="!!problems.length"> <n-descriptions-item v-if="!!problems.length">
<template #label> <template #label>

View File

@@ -257,12 +257,6 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{
path: "comment/list",
name: "admin comment list",
component: () => import("admin/communication/comments.vue"),
meta: { requiresSuperAdmin: true },
},
{ {
path: "message/list", path: "message/list",
name: "admin message list", name: "admin message list",

View File

@@ -9,7 +9,6 @@ export function signup(data: {
username: string username: string
email: string email: string
password: string password: string
captcha: string
}) { }) {
return http.post("register", data) return http.post("register", data)
} }
@@ -26,10 +25,6 @@ export function getProblemTagList() {
return http.get<Tag[]>("problem/tags") return http.get<Tag[]>("problem/tags")
} }
export function getCaptcha() {
return http.get("captcha")
}
export function getHitokoto() { export function getHitokoto() {
return http.get("hitokoto") return http.get("hitokoto")
} }

View File

@@ -1,18 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useAchievementStore } from "shared/store/achievement" import { useAchievementStore } from "shared/store/achievement"
import { RARITY_COLOR } from "utils/constants"
const store = useAchievementStore() const store = useAchievementStore()
const { current, queue } = storeToRefs(store) const { current, queue } = storeToRefs(store)
const visible = ref(false) const visible = ref(false)
const RARITY_COLOR: Record<string, string> = {
bronze: "#b87333",
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let gapTimer: ReturnType<typeof setTimeout> | null = null let gapTimer: ReturnType<typeof setTimeout> | null = null

View File

@@ -1,76 +0,0 @@
<template>
<div class="badge-item">
<img
:src="groupedBadge.icon"
:alt="groupedBadge.badges[0].badge.name"
class="badge-icon"
@error="handleImageError"
/>
<div v-if="groupedBadge.count > 1" class="badge-count">
×{{ groupedBadge.count }}
</div>
</div>
</template>
<script setup lang="ts">
interface GroupedBadge {
icon: string
count: number
badges: any[]
latestEarnedTime: Date
}
interface Props {
groupedBadge: GroupedBadge
}
defineProps<Props>()
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.src = "/badge-1.png"
}
</script>
<style scoped>
.badge-item {
position: relative;
display: inline-block;
cursor: pointer;
width: 44px;
height: 44px;
}
.badge-icon {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
transition: all 0.3s ease;
}
.badge-icon:hover {
transform: scale(1.1);
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.badge-count {
position: absolute;
top: -5px;
right: -5px;
background: #ff4d4f;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -21,8 +21,20 @@ const limit = ref(initialLimit)
const page = ref(initialPage) const page = ref(initialPage)
const sizes = [10, 30, 50] const sizes = [10, 30, 50]
watch(limit, () => emit("update:limit", limit)) // 必须 emit 数值emit ref 对象时,父组件用普通 ref 接会拿不到数字,
watch(page, () => emit("update:page", page)) // 而且每次 emit 的都是同一个对象,父组件那边察觉不到变化
watch(limit, (value) => emit("update:limit", value))
watch(page, (value) => emit("update:page", value))
// 父组件改页码 / 每页条数(比如换搜索条件后重置到第一页)时同步回来
watch(
() => initialLimit,
(value) => (limit.value = value),
)
watch(
() => initialPage,
(value) => (page.value = value),
)
</script> </script>
<template> <template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { getCaptcha, signup } from "../api" import { signup } from "../api"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal" import { useAuthModalStore } from "../store/authModal"
@@ -10,7 +10,6 @@ const {
signupForm: form, signupForm: form,
signupLoading: isLoading, signupLoading: isLoading,
signupError: msg, signupError: msg,
captchaSrc,
} = storeToRefs(authStore) } = storeToRefs(authStore)
const signupRef = useTemplateRef("signupRef") const signupRef = useTemplateRef("signupRef")
@@ -31,9 +30,6 @@ const rules: FormRules = {
trigger: "blur", trigger: "blur",
}, },
], ],
captcha: [
{ required: true, message: "验证码必填", trigger: "blur", min: 1, max: 10 },
],
} }
function goLogin() { function goLogin() {
@@ -50,20 +46,15 @@ function submit() {
username: form.value.username, username: form.value.username,
email: form.value.email, email: form.value.email,
password: form.value.password, password: form.value.password,
captcha: form.value.captcha,
}) })
} catch (err: any) { } catch (err: any) {
if (err.data === "Invalid captcha") { if (err.data === "Username already exists") {
authStore.setSignupError("验证码不正确")
} else if (err.data === "Username already exists") {
authStore.setSignupError("用户名已存在") authStore.setSignupError("用户名已存在")
} else if (err.data === "Email already exists") { } else if (err.data === "Email already exists") {
authStore.setSignupError("邮箱已存在") authStore.setSignupError("邮箱已存在")
} else { } else {
authStore.setSignupError("无法注册") authStore.setSignupError("无法注册")
} }
getCaptchaSrc()
form.value.captcha = ""
} finally { } finally {
authStore.setSignupLoading(false) authStore.setSignupLoading(false)
} }
@@ -74,14 +65,6 @@ function submit() {
}) })
} }
async function getCaptchaSrc() {
const res = await getCaptcha()
authStore.setCaptchaSrc(res.data)
}
watch(signupModalOpen, (v) => {
if (v) getCaptchaSrc()
})
</script> </script>
<template> <template>
@@ -134,18 +117,6 @@ watch(signupModalOpen, (v) => {
autocomplete="new-password" autocomplete="new-password"
/> />
</n-form-item> </n-form-item>
<n-form-item label="验证码" path="captcha">
<n-space>
<n-input
v-model:value="form.captcha"
clearable
name="captcha"
id="signup-captcha"
autocomplete="off"
/>
<img class="captcha" :src="captchaSrc" @click="getCaptchaSrc" />
</n-space>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert> <n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert>
<n-form-item> <n-form-item>
<n-space> <n-space>
@@ -158,10 +129,3 @@ watch(signupModalOpen, (v) => {
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
<style scoped>
.captcha {
height: 34px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,15 @@
import { useDark } from "@vueuse/core"
import { computed } from "vue"
import { RARITY_TEXT_COLOR } from "utils/constants"
/**
* 成就稀有度的文字配色,跟随明暗主题切换。
* 两套色值都压到 4.5:1 以上,不然浅色模式下白金和黄金的小字看不清。
* 边框和色块不用这个,直接用 RARITY_COLOR 的原色。
*/
export function useRarityColor() {
const isDark = useDark()
return computed(() =>
isDark.value ? RARITY_TEXT_COLOR.dark : RARITY_TEXT_COLOR.light,
)
}

View File

@@ -114,15 +114,6 @@ const options = computed<MenuOption[]>(() => {
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }), h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list", key: "admin user list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
),
key: "admin comment list",
},
{ {
label: () => label: () =>
h( h(
@@ -177,7 +168,6 @@ const active = computed(() => {
if (path.startsWith("/admin/problem")) return "admin problem list" if (path.startsWith("/admin/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list" if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list" if (path.startsWith("/admin/user")) return "admin user list"
if (path.startsWith("/admin/comment")) return "admin comment list"
if (path.startsWith("/admin/announcement")) return "admin announcement list" if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list" if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports" if (path.startsWith("/admin/ai")) return "admin ai reports"

View File

@@ -25,15 +25,11 @@ export const useAuthModalStore = defineStore("authModal", () => {
email: "", email: "",
password: "", password: "",
passwordAgain: "", passwordAgain: "",
captcha: "",
}) })
const signupLoading = ref(false) const signupLoading = ref(false)
const signupError = ref("") const signupError = ref("")
// ==================== 验证码 ====================
const captchaSrc = ref("")
// ==================== 模态框操作 ==================== // ==================== 模态框操作 ====================
/** /**
* 打开登录模态框 * 打开登录模态框
@@ -123,13 +119,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupError.value = "" signupError.value = ""
} }
/**
* 设置验证码图片地址
*/
function setCaptchaSrc(src: string) {
captchaSrc.value = src
}
return { return {
// 模态框状态 // 模态框状态
loginModalOpen, loginModalOpen,
@@ -145,9 +134,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupLoading, signupLoading,
signupError, signupError,
// 验证码
captchaSrc,
// 模态框操作 // 模态框操作
openLoginModal, openLoginModal,
closeLoginModal, closeLoginModal,
@@ -165,6 +151,5 @@ export const useAuthModalStore = defineStore("authModal", () => {
setSignupLoading, setSignupLoading,
setSignupError, setSignupError,
clearSignupError, clearSignupError,
setCaptchaSrc,
} }
}) })

View File

@@ -1,4 +1,4 @@
import type { SUBMISSION_RESULT } from "./types" import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
export enum SubmissionStatus { export enum SubmissionStatus {
compile_error = -2, compile_error = -2,
@@ -276,6 +276,44 @@ export enum ChartType {
Activity, Activity,
} }
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
// 青铜往红偏(色相 23黄金往纯黄推色相 47中间隔开两档亮度
// 不然两个都落在橙棕区,扫一眼分不出来
export const RARITY_COLOR: Record<AchievementRarity, string> = {
bronze: "#c2703d",
silver: "#9fa6b2",
gold: "#f2c012",
platinum: "#7dd3fc",
}
// 文字要另配一套。上面那组是照深色底调的,搬到白底上白金只有 1.7:1、
// 黄金 2.2:112px 的稀有度标签根本看不清。取色见 useRarityColor
export const RARITY_TEXT_COLOR: Record<
"dark" | "light",
Record<AchievementRarity, string>
> = {
dark: {
bronze: "#e08d63",
silver: "#b6bcc7",
gold: "#f2c012",
platinum: "#7dd3fc",
},
light: {
bronze: "#a13d1e",
silver: "#6b7280",
gold: "#8f6f00",
platinum: "#0369a1",
},
}
// 时间范围配置 // 时间范围配置
export const DURATION_OPTIONS = [ export const DURATION_OPTIONS = [
{ label: "本节课内", value: "hours:1" }, { label: "本节课内", value: "hours:1" },
@@ -287,3 +325,46 @@ export const DURATION_OPTIONS = [
{ label: "半年内", value: "months:6" }, { label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" }, { label: "一年内", value: "years:1" },
] as const ] as const
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
// 网站配置里的班级列表对得上。
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
// 是同一条规则的另一份,改这里必须同时改那边。
export const CLASS_NAME_MIN_DIGITS = 3
export const CLASS_NAME_MAX_DIGITS = 4
/** 合法班级号3~4 位纯数字 */
export const CLASS_NAME_RE = new RegExp(
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
)
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
export const USERNAME_CLASS_RE = new RegExp(
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
)
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
export const REACTIONS: {
key: ReactionKey
label: string
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
},
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
]

View File

@@ -1,7 +1,14 @@
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types" import { User } from "./types"
import { USER_TYPE } from "./constants" import { USER_TYPE } from "./constants"
import { strFromU8, strToU8, unzlibSync, zlibSync } from "fflate" import {
strFromU8,
strToU8,
unzlibSync,
zipSync,
zlibSync,
type Zippable,
} from "fflate"
import copyTextFallback from "copy-text-to-clipboard" import copyTextFallback from "copy-text-to-clipboard"
import { customAlphabet } from "nanoid" import { customAlphabet } from "nanoid"
@@ -208,6 +215,22 @@ export function atou(base64: string): string {
return strFromU8(unzipped) return strFromU8(unzipped)
} }
/**
* 把若干文本文件打包成 zip Blob
* @param files 文件名和文本内容
* @param mtime 归档内的修改时间,默认当前时间
*/
export function createZipBlob(
files: { name: string; content: string }[],
mtime: Date = new Date(),
): Blob {
const entries: Zippable = {}
for (const f of files) {
entries[f.name] = strToU8(f.content)
}
return new Blob([zipSync(entries, { mtime })], { type: "application/zip" })
}
/** /**
* 复制文本到剪贴板 * 复制文本到剪贴板
* 优先使用 Clipboard API支持在 modal 中使用),失败时回退到 copy-text-to-clipboard * 优先使用 Clipboard API支持在 modal 中使用),失败时回退到 copy-text-to-clipboard

View File

@@ -12,7 +12,6 @@ export function usePermissions() {
canManageUsers: computed(() => userStore.isSuperAdmin), canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin), canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin), canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin), canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin), canSendMessages: computed(() => userStore.isSuperAdmin),
@@ -65,7 +64,6 @@ export function checkRoutePermission(routeName: string): boolean {
"admin announcement list", "admin announcement list",
"admin announcement create", "admin announcement create",
"admin announcement edit", "admin announcement edit",
"admin comment list",
"admin message list", "admin message list",
"admin tutorial list", "admin tutorial list",
"admin tutorial create", "admin tutorial create",

View File

@@ -35,7 +35,6 @@ export interface User {
problem_permission: string problem_permission: string
create_time: Date create_time: Date
last_login: Date last_login: Date
two_factor_auth: boolean
open_api: boolean open_api: boolean
is_disabled: boolean is_disabled: boolean
password?: string password?: string
@@ -178,7 +177,11 @@ export interface Problem {
sql_display?: SQLDisplay | null sql_display?: SQLDisplay | null
} }
export type AdminProblem = Problem & AlterProblem export type AdminProblem = Problem &
AlterProblem & {
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
top_reaction?: { type: ReactionKey; count: number } | null
}
interface AlterProblem { interface AlterProblem {
test_case_id: string test_case_id: string
@@ -227,6 +230,8 @@ export interface AdminProblemFiltered {
has_ast_rules: boolean has_ast_rules: boolean
allow_flowchart: boolean allow_flowchart: boolean
show_flowchart: boolean show_flowchart: boolean
// 比赛题目列表接口不返回这个字段
top_reaction?: { type: ReactionKey; count: number } | null
} }
// 题单相关类型 // 题单相关类型
@@ -595,16 +600,20 @@ export interface CreateMessage {
message: string message: string
} }
export interface Comment { export type ReactionKey =
id: number | "too_easy"
problem: string | "too_hard"
submission: string | "confusing"
content: string | "buggy"
description_rating: 1 | 2 | 3 | 4 | 5 | "learned"
difficulty_rating: 1 | 2 | 3 | 4 | 5 | "interesting"
comprehensive_rating: 1 | 2 | 3 | 4 | 5 | "want_explain"
create_time: Date
user: SampleUser export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState {
mine: ReactionKey | null
counts: ReactionCounts | null
} }
export interface Tutorial { export interface Tutorial {

View File

@@ -6,6 +6,69 @@ import AutoImport from "unplugin-auto-import/vite"
import Components from "unplugin-vue-components/vite" import Components from "unplugin-vue-components/vite"
import { NaiveUiResolver } from "unplugin-vue-components/resolvers" import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
// 显式保留 Chrome 90 所需的运行时兼容项,避免 plugin-legacy 对每个产物执行 Babel 扫描。
// 升级前端依赖后需要重新审计此列表。
const polyfills = [
"es.aggregate-error.cause",
"es.array-buffer.detached",
"es.array-buffer.transfer-to-fixed-length",
"es.array-buffer.transfer",
"es.array.at",
"es.array.find-last-index",
"es.array.push",
"es.array.to-reversed",
"es.array.to-sorted",
"es.array.to-spliced",
"es.array.with",
"es.error.cause",
"es.iterator.constructor",
"es.iterator.drop",
"es.iterator.every",
"es.iterator.filter",
"es.iterator.find",
"es.iterator.flat-map",
"es.iterator.for-each",
"es.iterator.map",
"es.iterator.reduce",
"es.iterator.some",
"es.iterator.to-array",
"es.json.parse",
"es.json.stringify",
"es.map.get-or-insert-computed",
"es.map.get-or-insert",
"es.object.has-own",
"es.regexp.flags",
"es.set.difference.v2",
"es.set.intersection.v2",
"es.set.is-disjoint-from.v2",
"es.set.is-subset-of.v2",
"es.set.is-superset-of.v2",
"es.set.symmetric-difference.v2",
"es.set.union.v2",
"es.string.at-alternative",
"es.typed-array.at",
"es.typed-array.find-last-index",
"es.typed-array.find-last",
"es.typed-array.set",
"es.typed-array.to-reversed",
"es.typed-array.to-sorted",
"es.typed-array.with",
"es.uint8-array.set-from-base64",
"es.uint8-array.set-from-hex",
"es.uint8-array.to-base64",
"es.uint8-array.to-hex",
"es.weak-map.get-or-insert-computed",
"es.weak-map.get-or-insert",
"esnext.array.group",
"web.dom-exception.stack",
"web.immediate",
"web.structured-clone",
"web.url-search-params.delete",
"web.url-search-params.has",
"web.url-search-params.size",
"web.url.can-parse",
]
// index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现) // index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现)
function injectMaxkb(maxkbUrl: string | undefined): Plugin { function injectMaxkb(maxkbUrl: string | undefined): Plugin {
return { return {
@@ -48,7 +111,7 @@ export default defineConfig(({ mode }) => {
legacy({ legacy({
renderLegacyChunks: false, renderLegacyChunks: false,
modernTargets: "chrome>=90", modernTargets: "chrome>=90",
modernPolyfills: true, modernPolyfills: polyfills,
}), }),
AutoImport({ AutoImport({
imports: [ imports: [