Compare commits

..
107 Commits
Author SHA1 Message Date
xuyue 8cf98ed087 fix
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
xuyue 235d11ecdd fix
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
xuyue 8be96e178e fix
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
xuyue ec54fb430e update
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
xuyue 56b5dc2e36 dota2 like
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
xuyue 39f9bb9d38 update
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
xuyue 34aff29048 fix
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
xuyue d7b57074d3 fix
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
xuyue 280e360506 fix
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
xuyue 5b8e0f866b refactor(reaction): 收敛单选提交交互
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
xuyueandClaude Opus 5 9f89be3876 feat(reaction): 评价改为先勾选再提交,提交后不可修改
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
xuyueandClaude Opus 5 1fb93e6b16 refactor(reaction): 后台反馈统计独立成 reaction 目录,去掉排序、加最近评价时间
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 06:15:34 -06:00
xuyueandClaude Opus 5 b3d0c3fcc7 题目点评按钮改成大图标按钮
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
xuyueandClaude Opus 5 c4d55bb88e fix(reaction): fix disabled-tooltip hover and post-AC dialog copy
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
xuyueandClaude Opus 5 c91c923fd6 refactor(reaction): 清理旧的评论代码
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:55:11 -06:00
xuyueandClaude Opus 5 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
xuyueandClaude Opus 5 a8cc7c855a feat(reaction): 后台题目反馈统计页
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:42:25 -06:00
xuyueandClaude Opus 5 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
xuyueandClaude Opus 5 0af91b1e20 feat(reaction): 学生端表情条组件
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:27:45 -06:00
xuyueandClaude Opus 5 d16e3dddb8 feat(reaction): 前端表情常量、类型与 API
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:19:39 -06:00
xuyueandClaude Opus 5 8a46bae27d feat(admin): 班级号位数校验,规则收敛到 constants
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
xuyueandClaude Opus 5 0e2cc28235 refactor(signup): 去掉注册验证码
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
xuyueandClaude Opus 5 e777a94568 refactor(admin): 去掉 two_factor_auth 残留
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
xuyue 56f1431a10 update
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
xuyue 7b2a4a9094 fix
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
xuyue b559c93476 fix
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
xuyue 42de79ee60 revert(achievement): 去掉进度条动画
保留奖章的题单来源,只回退 0 → 真值那套触发动画的处理和拉长的过渡时长,
圆环和进度条回到直接显示终值。
2026-08-05 13:56:13 -06:00
xuyue 8cfa90ca16 feat(achievement): 奖章标出来自哪个题单 + 进度条动画
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
xuyue 4b796650ff fix(achievement): 拉开青铜和黄金的配色
青铜和黄金原来都落在橙棕区(色相 30 / 44),扫一眼分不出来。
青铜往红铜偏、黄金往纯黄推,边框色和明暗两套文字色一起调整,
对比度全部保持 4.5:1 以上。顺带修浅色模式白金只有 4.10:1。
2026-08-05 13:52:58 -06:00
xuyue ade6034316 fix
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
xuyue a1bea7182f fix
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
xuyue 425bab1c15 fix
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
xuyue 17dbd7a4a7 fix
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
xuyue 8db26ef7a4 rewind
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
xuyueandClaude Opus 5 508267bb59 fix(成就): 圆环百分比在移动端撑出 + 卡片边框加粗
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
xuyue a4fdd377f2 update
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
xuyue da2334ed3a update
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
xuyue a626160f29 update
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
xuyue bf028b3fb1 fix
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
xuyueandClaude Opus 5 b7f82adb7c fix(achievement): 抬头四条稀有度进度固定单列
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
xuyueandClaude Opus 5 05ce9cbb9e revert(achievement): 抬头回到环形进度版,保留白金优先的排序
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
xuyueandClaude Opus 5 4072abe199 feat(achievement): 抬头改成 PlayStation 奖杯陈列
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
xuyueandClaude Opus 5 0d7336bce8 refactor(achievement): 稀有度配色收进 constants,文字色适配浅色模式
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
xuyueandClaude Opus 5 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
xuyueandClaude Opus 5 56d06eb456 style(achievement): 成就页抬头改为环形进度 + 四色稀有度条
左侧环形显示总完成度,右侧四行稀有度各配一条对应颜色的进度条,
配色复用 AchievementCard 的 RARITY_COLOR。窄屏自动塌成一列。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:19:57 -06:00
xuyueandClaude Opus 5 b79dd6c721 feat(admin): 成就管理标题显示条数
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
xuyueandClaude Opus 5 15d823aa20 refactor: 个人主页移除徽章卡片
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
xuyueandClaude Opus 5 4ae25b09c0 perf: skulpt 改为按需加载,client-zip 换成 fflate
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
xuyue ea691834b0 fix
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
xuyue 344e9734b6 feat: 标签管理页点击标签查看对应题目
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:11:46 -06:00
xuyue 7694db7d41 feat: 新增标签题目弹窗组件 2026-08-05 06:04:46 -06:00
xuyueandClaude Sonnet 5 a191bd78d4 feat(admin): 题目列表支持批量添加/移除标签
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 Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:28:26 -06:00
xuyueandClaude Sonnet 5 842c4796c0 feat(admin): 标签管理页
支持搜索、重命名(撞名自动合并并提示影响题数)、删除(二次确认)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:25:36 -06:00
xuyueandClaude Sonnet 5 15aaec4384 feat(admin): 标签管理与批量打标签的 API 封装
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:23:17 -06:00
xuyueandClaude Sonnet 5 52ac8d205d merge: 合并 github 远程分支(同一改动的不同提交历史)
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 Sonnet 5 <noreply@anthropic.com>
2026-08-05 00:07:08 -06:00
xuyueandClaude Sonnet 5 30ea5c8191 fix(header): 深浅色切换扩散动画改为从屏幕中心扩散
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 Sonnet 5 <noreply@anthropic.com>
2026-08-05 00:04:37 -06:00
xuyueandClaude Sonnet 5 58c67240b7 Merge branch 'fix/learn-exercise-highlight-dedup'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 21:25:35 -06:00
xuyueandClaude Sonnet 5 b4b96b0da3 fix(learn): 修复填空题代码高亮丢失,去重高亮与洗牌逻辑
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
- ExerciseFill.vue 之前未导入 exercise-highlight.css,若某课只含
  fill 类型练习(不加载 ExerciseSort),高亮代码会渲染成无色纯文本
- ExerciseFill/ExerciseSort 改为复用 useCodeHighlight 的
  highlight()/highlightLines(),删除各自重复的 hljs 注册与
  escapeHtml 实现
- 新增 useShuffle.ts,供 ExerciseSort/ExerciseMatch/ExerciseGroup
  共用洗牌逻辑,替代三处几乎逐字相同的实现

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 21:25:21 -06:00
xuyue 4ed8014c30 merge: 自学模块支持 C 语言教程
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
- 路由改为 /learn/:type/:step,按语言分流
- 导航栏自学变二级菜单,按语言记住上次学到的课
- 示例代码编辑器语言跟随教程类型
2026-08-04 21:14:28 -06:00
xuyueandClaude Opus 5 40bd331a1c feat(learn): 导航栏自学二级菜单,按语言记住学习进度
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:08:06 -06:00
xuyue 4ec6b1d738 feat(learn): 自学模块按语言分路由,支持 C 语言教程 2026-08-04 21:02:27 -06:00
xuyue c88753ba78 fix
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-04 19:27:26 -06:00
xuyueandClaude Opus 5 0932a05850 feat(achievement): 成就图标改用 iconify 渲染
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
机房的老浏览器缺 emoji 字体,成就图标会渲染成方块。后端的 icon 字段
改存 iconify 图标名(noto 集),前端统一走新的 AchievementIcon 组件
渲染成 SVG。

组件按三种形态判断:图片 URL 走 img,含冒号走 Icon,其余当纯文本兜底,
所以存量的 emoji 数据不会瞎掉。

顺带修好解锁弹窗:题单奖章的 icon 是图片 URL,之前当文本渲染,会把
一串路径直接显示出来。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:20:10 -06:00
xuyueandClaude Opus 5 0ba25dc4e3 merge: 修复弹窗队列的 id 空间冲突
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-04 07:23:42 -06:00
xuyueandClaude Opus 5 7e498a1c1c fix(achievement): 弹窗队列区分成就与题单奖章的 id 空间
两者的 id 来自不同的表、数值会重叠,却共用同一个队列:
- 去重只按 id,队列里有奖章 5 时成就 5 会被当成重复项丢掉
- 弹完奖章后调 markAchievementsRead(奖章id),后端按成就 id 解释,
  把同号且未弹过的成就静默标记为已弹,那个奖杯再也不会出现

载荷里本来就有 kind 字段,前端此前完全忽略。现在去重按 kind:id,
标记已读跳过奖章,弹窗标题也按类型区分。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 07:22:42 -06:00
xuyueandClaude Opus 5 700559557d merge: 成就系统前端剩余部分
个人主页与排行榜的成就入口、管理后台成就配置页。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:59:55 -06:00
xuyueandClaude Opus 5 a9278589c9 feat(achievement): 添加管理后台成就配置页
指标下拉框由后端 METRIC_REGISTRY 动态提供,选中后显示该指标的说明。
列表显示已解锁人数——阈值配错时学生永远拿不到也永远不会来问,
这个计数器是唯一的仪表盘。

API 追加到 src/admin/api.ts(仓库的 admin 接口集中在这一个文件里),
路由 meta 用 requiresSuperAdmin 以匹配后端的 super_admin_required。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:59:27 -06:00
xuyueandClaude Opus 5 d31bfd8b8f feat(achievement): 个人主页与排行榜添加成就入口
个人主页显示完成度与最近获得的成就,可跳转奖杯馆;
全服 Top100 和班级排名每行加一个跳转按钮。

成就摘要单独请求,不并进 init 里那个按位置取值的 promises 数组,
且失败时静默降级——它不该拖垮整个个人主页。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:57:07 -06:00
xuyueandClaude Opus 5 64a770ff62 merge: 成就系统前端
奖杯馆页面、成就 API 层与类型、全局解锁弹窗与待弹拉取、
WebSocket 消息分流修复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:51:43 -06:00
xuyueandClaude Opus 5 d7d80e1c1b feat(achievement): 添加全局解锁弹窗与待弹拉取
推拉结合:布局层每次路由切换拉一次 pending 作为主通道,
WebSocket 只作为在线时的即时增强。纯靠推会丢消息——
前端 WebSocket 只在问题页且有提交监听时才建连。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:46:28 -06:00
xuyueandClaude Opus 5 fe7095bd74 fix(achievement): 奖杯馆取 ApiResponse.data 而非整个响应对象
http 客户端返回 ApiResponse<T>,载荷在 .data 里。
原写法会让页面拿到 undefined,奖杯馆整片空白。
项目没有类型检查步骤,Vite 只转译不校验,构建不会报这个错。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:46:20 -06:00
xuyueandClaude Opus 5 d81e00ee14 feat(achievement): 添加奖杯馆页面
三种视觉状态:已解锁显示日期与获得率;未解锁公开成就显示进度条;
未解锁隐藏成就只显示稀有度,不画进度条(会泄露后端刻意遮掉的门槛)。
lte 类成就(如最短代码 ≤ 50 字符)不画百分比条,改为显示当前最好成绩。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:42:38 -06:00
xuyue 71186f0ca5 feat(achievement): 添加成就 API 层与类型,修复 WebSocket 消息分流 2026-08-04 05:24:13 -06:00
xuyue 8d06fafbae update
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-07-26 08:52:14 -06:00
xuyue dc853cd61f fix
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-07-26 08:44:46 -06:00
xuyue ccbb961d64 update
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-07-26 08:36:49 -06:00
xuyue f928e79c1f update
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-07-26 08:11:51 -06:00
xuyueandClaude Opus 5 8db3b15df1 Merge branch 'feat/demo-student-view'
学生视角(演示模式):超管在用户下拉菜单一键把界面伪装成普通学生。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:51:52 -06:00
xuyueandClaude Opus 5 6766c4dcd5 style(header): 演示模式菜单文案改为「进入演示 / 退出演示」
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
两端都是四字,动作对称。spec 与 plan 文档同步更新。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:51:11 -06:00
xuyueandClaude Opus 5 bd6c5d19f8 style(header): 演示模式退出文案改为四字
「退出学生视角」六字,与「学生视角」不齐。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:49:22 -06:00
xuyueandClaude Opus 5 8a343edbbb feat(header): 用户下拉菜单加入学生视角开关
仅超管可见。进入演示模式时若停在后台页面则跳回首页。
options 改为 computed,否则菜单文案与显示条件不会随状态更新。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:48:22 -06:00
xuyueandClaude Opus 5 07bbbce013 feat(user): store 层加入演示模式伪装开关
超管开启后所有角色 getter 降级为普通学生,全站权限判断自动跟随。
realIsSuperAdmin 保留真实身份,用于判断能否切换。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:47:27 -06:00
xuyueandClaude Opus 5 5fac75238d docs: 学生视角(演示模式)实施计划
两个任务:store 层伪装开关、Header 下拉菜单入口。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:46:23 -06:00
xuyueandClaude Opus 5 3509f340ff docs: 学生视角(演示模式)设计
超管一键把界面切换成普通学生的样子,方便上课演示。
纯前端伪装,在 userStore 层包装角色 getter,全站权限判断自动跟随。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 07:43:57 -06:00
xuyue 297f674bac fix
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-07-26 07:20:13 -06:00
xuyue e99f9d5458 update
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-07-26 06:51:46 -06:00
xuyue 63b02ec221 fix
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-07-24 07:19:05 -06:00
xuyue 3aa82bb200 fix
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-07-24 07:16:09 -06:00
xuyue 4031e2bd3c update
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-07-24 07:12:43 -06:00
xuyue 4e5b2fdb3e fix 2026-07-24 06:46:59 -06:00
xuyue be1b9a97a0 update
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-07-05 03:12:36 -06:00
xuyueandClaude Fable 5 02c523b32f docs: SQL 题强制至少 2 个测试点设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:11:53 -06:00
xuyueandClaude Fable 5 f6d4d9bcb2 docs: SQL 题目表名/字段名自动补全设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:32 -06:00
xuyue 527127d9c7 fix
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-07-05 01:33:30 -06:00
xuyueandClaude Opus 4.8 e7a621075c 移除 OI 残留:前端 Profile / Rank 去掉 oi_problems_status / total_score
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
配合后端删除 UserProfile 的 OI 字段:
- Profile、Rank 类型移除 oi_problems_status、total_score
- 用户主页 AC 列表只用 acm_problems_status,去掉 oi 合并

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:43:11 -06:00
xuyueandClaude Opus 4.8 081981d115 精简 Problem 模型:前端移除 rule_type / io_mode / total_score
配合后端删除这三列:
- Problem 类型与 admin 出题页去掉 rule_type、io_mode 字段
- 出题提交 payload 不再携带这些字段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:42:21 -06:00
xuyue ff5daf8afe update
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-07-04 11:06:41 -06:00
xuyueandClaude Fable 5 bda28b8400 feat: SQL 题接入测试点在线编辑器
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 Fable 5 <noreply@anthropic.com>
2026-07-04 03:57:55 -06:00
xuyueandClaude Fable 5 740f956fe3 feat: SQL 测试点在线编辑器组件与 API
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:50:43 -06:00
xuyue ebe2ab762f fix
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-07-03 10:27:58 -06:00
xuyue 9144dbb4fc update
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-07-03 10:15:33 -06:00
xuyueandClaude Fable 5 10dcb29704 feat: SQL 提交前自动格式化
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
格式化白名单加入 sql,后端用 sqlparse 处理

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:05:39 -06:00
xuyue 1cac0d01f8 fmt
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-07-03 09:59:15 -06:00
xuyueandClaude Fable 5 47281029ec feat: SQL 代码编辑器中文自动补全
- 新增 sql.ts 中文补全列表(关键字 + 常用函数,SQLite 方言)
- enhanceCompletion 接入 SQL,移除原来的早退逻辑
- CodeEditor.vue 补上 lang-sql 语言扩展,与 SyncCodeEditor 一致

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:58:52 -06:00
xuyueandClaude Fable 5 ad28c0ba21 fix: sqlTableName 字体 fallback
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 Fable 5 <noreply@anthropic.com>
2026-07-03 06:26:00 -06:00
xuyueandClaude Fable 5 0b627b5157 feat: 管理端 SQL 题隐藏输入输出描述与样例表单
- 隐藏 SQL 题的输入/输出描述编辑器
- 隐藏 SQL 题的样例编辑区域
- 跳过 SQL 题的输入输出和样例验证
- SQL 题提交时清空样例数组

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:31:08 -06:00
xuyueandClaude Fable 5 aa212427ba feat: SQL 题展示数据表与期望结果
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:26:16 -06:00
79 changed files with 5908 additions and 1904 deletions
+3 -2
View File
@@ -12,6 +12,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: debian
@@ -23,9 +24,9 @@ jobs:
remote_port: 8822
target: /root/OJ/data/dist
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
@@ -0,0 +1,296 @@
# 学生视角(演示模式)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 超级管理员在右上角下拉菜单点一下,整站界面变成普通学生看到的样子,再点一下恢复。
**Architecture:** 纯前端伪装。全站 40 多处权限判断读的都是 `shared/store/user.ts` 里的 6 个角色 getter,没有一处直接读 `user.admin_type`。在 store 里加一个 `demoMode` 开关,给每个 getter 加 `!demoMode.value &&` 前缀,全站自动跟随。路由守卫(`src/main.ts`)、权限工具(`src/utils/permissions.ts`)、各页面 `v-if` 零改动。
**Tech Stack:** Vue 3 `<script setup>` + TypeScriptPinia setup storeNaive UI`n-dropdown` / `DropdownOption`),Vite。
**Spec:** `docs/superpowers/specs/2026-07-26-demo-student-view-design.md`
## Global Constraints
- **不写测试。** 项目根 CLAUDE.md 明确规定 "Do not write new tests",且 ojnext 无测试框架。本计划的验证步骤全部是 `npm run build` 冒烟 + 浏览器手工核对。
- **不改后端。** 演示模式是界面伪装,登录态仍是超管,接口权限不变。
- 仅超级管理员可见此开关。教师管理员、学生管理员不提供。
- 自动导入已配置:`ref` / `computed` / `useRouter` / `useRoute` / Naive UI 组件与类型(`DropdownOption`)均**不需要手写 import**。
- 存储 key 常量统一放 `src/utils/constants.ts``STORAGE_KEY`,读写走 `src/utils/storage.ts` 默认导出(内部做 JSON 序列化)。
- 提交前跑 `npm fmt`Prettier)。
- 中文注释、中文 UI 文案,与现有代码一致。
---
## File Structure
| 文件 | 职责 | 本次改动 |
|---|---|---|
| `src/utils/constants.ts` | 全局常量 | `STORAGE_KEY` 增加一个键 |
| `src/shared/store/user.ts` | 用户身份与角色判断的唯一来源 | 新增 `demoMode` 状态与伪装逻辑(改动主体) |
| `src/shared/components/Header.vue` | 顶栏与用户下拉菜单 | 新增菜单项与切换处理函数 |
不新建文件。`demoMode` 放进已有的 `user` store 而不是单开一个 store —— 它伪装的就是这个 store 的输出,分开会让两个 store 循环依赖。
---
### Task 1: store 层伪装开关
**Files:**
- Modify: `src/utils/constants.ts:147-153`
- Modify: `src/shared/store/user.ts`
**Interfaces:**
- Consumes: 无(第一个任务)
- Produces: `useUserStore()` 新增三个成员,供 Task 2 使用:
- `demoMode: boolean` — 当前是否处于演示模式(store 解包后为布尔值)
- `canToggleDemoMode: boolean` — 是否显示切换入口(真实超管身份,不受伪装影响)
- `toggleDemoMode(): void` — 翻转开关并写入 localStorage
- [ ] **Step 1: 在 `STORAGE_KEY` 增加常量**
打开 `src/utils/constants.ts`,把 `STORAGE_KEY` 改成:
```ts
export const STORAGE_KEY = {
AUTHED: "authed",
LANGUAGE: "problemLanguage",
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode",
}
```
- [ ] **Step 2: 在 user store 加入 `demoMode` 与真实身份 getter**
打开 `src/shared/store/user.ts`。在 `const isAuthed = ...` 那一行之后、`const isAdminRole = ...` 之前,插入:
```ts
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
const realIsSuperAdmin = computed(
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
)
```
`storage``STORAGE_KEY` 文件顶部已经 import 过,不需要新增 import。
- [ ] **Step 3: 给 6 个角色 getter 加上伪装前缀**
`src/shared/store/user.ts` 中原有的 6 个 getter`isAdminRole``isStudentAdmin``isTeacherAdmin``isTeacherOrAbove``isSuperAdmin``hasProblemPermission`)整段替换为:
```ts
const isAdminRole = computed(
() =>
!demoMode.value &&
(user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
)
const isStudentAdmin = computed(
() => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
)
const isTeacherAdmin = computed(
() => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
)
const isTeacherOrAbove = computed(
() =>
!demoMode.value &&
(user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
)
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
const hasProblemPermission = computed(
() =>
!demoMode.value &&
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE,
)
```
注意:`isAdminRole``isTeacherOrAbove` 原本是多个 `||` 连成的表达式,加前缀时**必须给原表达式套一层括号**,否则 `&&` 的优先级会让第一个 `||` 分支逃过伪装。
- [ ] **Step 4: 加入切换能力与切换函数**
紧接在 `hasProblemPermission` 之后插入:
```ts
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
function toggleDemoMode() {
demoMode.value = !demoMode.value
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
}
```
- [ ] **Step 5: 退出登录时重置内存中的开关**
`storage.clear()` 会清掉 localStorage 里的标记,但内存中的 ref 还留着,同一次会话里换账号登录会带过去。把 `clearProfile` 改成:
```ts
function clearProfile() {
profile.value = null
demoMode.value = false
storage.clear()
}
```
- [ ] **Step 6: 导出新成员**
在 store 末尾的 `return { ... }` 里加入三项(放在 `hasProblemPermission` 之后):
```ts
demoMode,
canToggleDemoMode,
toggleDemoMode,
```
- [ ] **Step 7: 格式化并冒烟构建**
```bash
cd ojnext
npm fmt
npm run build
```
Expected: 构建成功,无报错。
- [ ] **Step 8: 手工验证伪装生效(此时还没有 UI 入口,用 localStorage 模拟)**
启动 `npm start`,用超管账号登录,然后在浏览器 DevTools Console 执行:
```js
localStorage.setItem("demoMode", "true")
location.reload()
```
逐项核对:
- 顶栏「后台」菜单项消失
- 地址栏直接输入 `/admin` → 被弹回首页
- 题目详情页不再出现管理员专属按钮
再执行 `localStorage.setItem("demoMode", "false"); location.reload()`,确认上述内容全部恢复。
- [ ] **Step 9: 提交**
```bash
cd ojnext
git add src/utils/constants.ts src/shared/store/user.ts
git commit -m "feat(user): store 层加入演示模式伪装开关
超管开启后所有角色 getter 降级为普通学生,全站权限判断自动跟随。
realIsSuperAdmin 保留真实身份,用于判断能否切换。"
```
---
### Task 2: 下拉菜单切换入口
**Files:**
- Modify: `src/shared/components/Header.vue:109-113`(新增函数)、`:178-227``options` 改为 computed 并增加菜单项)
**Interfaces:**
- Consumes: Task 1 提供的 `userStore.demoMode``userStore.canToggleDemoMode``userStore.toggleDemoMode()`
- Produces: 无后续任务依赖
- [ ] **Step 1: 把 `options` 从普通数组改为 computed**
`src/shared/components/Header.vue` 第 178 行现在是:
```ts
const options: Array<DropdownOption | DropdownDividerOption> = [
```
改为:
```ts
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
```
并把第 227 行的结尾 `]` 改为 `])`
**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「进入演示」/「退出演示」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。
模板里 `:options="options"`(第 280 行)不用动,computed 在模板中自动解包。
- [ ] **Step 2: 加入切换处理函数**
`handleLogout`(第 109-113 行)之后插入:
```ts
function handleToggleDemoMode() {
const entering = !userStore.demoMode
userStore.toggleDemoMode()
// 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去
if (entering && route.path.startsWith("/admin")) {
router.push("/")
}
}
```
`route``router` 在第 16-17 行已经拿到,`userStore` 在第 12 行已经拿到,无需新增。
- [ ] **Step 3: 在下拉菜单中加入菜单项**
`options` 数组里、`{ type: "divider" }`(第 220 行)**之前**插入:
```ts
{
label: userStore.demoMode ? "退出演示" : "进入演示",
key: "demo-mode",
show: userStore.canToggleDemoMode,
icon: renderIcon("fluent-emoji:graduation-cap"),
props: { onClick: handleToggleDemoMode },
},
```
文案本身就是状态指示器:看到「退出演示」说明当前正处于演示模式。按设计不额外加横幅。
- [ ] **Step 4: 格式化并冒烟构建**
```bash
cd ojnext
npm fmt
npm run build
```
Expected: 构建成功,无报错。
- [ ] **Step 5: 手工验证完整流程**
先清掉 Task 1 遗留的手工标记:DevTools Console 执行 `localStorage.removeItem("demoMode")`,刷新。
用超管账号登录,逐项核对:
1. 右上角用户名下拉菜单出现「进入演示」,图标正常显示(不是空白方块)
2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出演示」
3. 刷新页面 → 仍是学生界面,菜单仍显示「退出演示」
4. 地址栏直接输入 `/admin` → 弹回首页
5. 点「退出演示」→「后台」入口恢复,文案变回「进入演示」
6. 进入 `/admin/problem/list`,打开下拉菜单点「进入演示」→ 自动跳回首页
7. 退出登录后重新用超管登录 → 演示模式已重置为关闭状态
8. 用教师管理员账号登录 → 下拉菜单中**没有**这一项
- [ ] **Step 6: 提交**
```bash
cd ojnext
git add src/shared/components/Header.vue
git commit -m "feat(header): 用户下拉菜单加入学生视角开关
仅超管可见。进入演示模式时若停在后台页面则跳回首页。
options 改为 computed,否则菜单文案与显示条件不会随状态更新。"
```
---
## 完成后
两个任务都提交后,整个特性即完成。回读一遍 spec 的「连带影响」章节,确认这些变化在实机上都是预期的:
- 提交列表的教师筛选与额外列消失,`showSubmissions` 改为跟随站点配置
- 比赛失去超管免密码特权
- 协同代码编辑(`shared/composables/sync.ts`)的超管特殊颜色与提示一并变为学生行为 —— **已确认不做豁免**
@@ -0,0 +1,28 @@
# SQL 题强制至少 2 个测试点 — 设计
日期:2026-07-05
## 背景
题目页的 `sql_display` 用**测试点 1** 的数据生成期望结果展示(截断到 20 行)。如果 SQL 题只有 1 个测试点且结果 ≤20 行,页面展示的期望结果就是完整答案输出,学生可用 `SELECT ... UNION ALL ...`query 模式)或硬编码 INSERT/UPDATEmodify 模式)对照抄写直接 AC。多测试点时数据不同,硬编码只能过测试点 1。
## 决定
SQL 题**强制至少 2 个数据不同的初始化脚本**,前后端双重拦截:
- **前端** `ojnext/src/admin/problem/components/SQLTestcaseEditor.vue`
- `canUpload` 要求非空脚本数 ≥ 2,不满足时上传按钮禁用;
- 上传按钮 tooltip 在脚本不足时显示原因("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")。
- **后端** `OnlineJudge/problem/views/admin.py``TestCaseZipProcessor.process_zip`
- `sql=True` 且测试点数 < 2 时 `raise APIError(...)`,兜底直接调 API 的情况。
## 影响范围
- 只在**重新上传/保存测试点**时拦截,已有的单测试点老题目不受影响、不回溯校验。
- 非 SQL 题(.in/.out 沙箱判题)不受影响。
- "数据不同"不做内容级校验(两个脚本内容相同也能过),只保证数量下限,YAGNI。
## 验证
前端 `vue-tsc --noEmit`、Prettier 通过;后端 `ruff check` / `ruff format --check` 通过。
人工验证:出题页只填 1 个脚本 → 上传按钮禁用且 tooltip 说明原因;填 2 个并预览通过 → 可上传。
@@ -0,0 +1,50 @@
# SQL 题目表名/字段名自动补全 — 设计
日期:2026-07-05
## 目标
学生在 SQL 题目的代码编辑器里输入时,自动补全列表中除现有的 SQL 关键字/函数外,还出现**当前题目的表名和字段名**(带类型提示),减少抄写表名字段名的负担和拼写错误。
## 背景
- SQL 题目详情页已下发 `problem.sql_display``SQLDisplay` 类型),其中 `tables: SQLDisplayTable[]` 包含每张表的 `name``columns[{name, type}]`。数据在前端齐全,**无需后端改动**。
- 编辑器补全入口是 `shared/extensions/autocompletion.ts``enhanceCompletion(language)``CodeEditor.vue``SyncCodeEditor.vue` 都用它,且都叠加了 `completeAnyWord`
- SQL 静态关键字补全表在 `shared/extensions/sql.ts`
- `shared` 直接 import `oj/store/problem` 已有先例(`FlowchartEditor/index.vue`)。
## 方案(已选:方案 A
`enhanceCompletion` 中,当 `language === "SQL"` 时,从 `useProblemStore().problem?.sql_display?.tables` 动态生成补全项,追加到静态关键字列表后:
- **表名**`type: "class"``detail: "数据表"``info` 列出该表全部字段(如 `字段:id INTEGER, name TEXT, score REAL`),`boost` 高于所有关键字(如 110)。
- **字段名**`type: "property"``detail` 标注来源表和类型(如 `students 的字段 · TEXT`),`boost` 略低于表名、高于关键字(如 105)。
- **同名字段每表一条**,靠 detail 区分来源表。
- store 在补全回调内惰性读取(每次按键执行),题目切换后自动反映最新表结构。
- 非 SQL 语言、无题目上下文(如 admin/tutorial/learn 页面)或 `sql_display` 为空时,不追加任何项,行为与现状一致。
### 不做的事(YAGNI
- 不改后端;不改题目描述展示(`SQLDataTable` 已展示表结构)。
- 管理端出题的 SQL 编辑器(`SQLTestcaseEditor`)不接入。
- 不做基于 SQL 语法位置的智能上下文补全(如 FROM 后只补表名)。
## 改动文件
| 文件 | 改动 |
|---|---|
| `src/shared/extensions/autocompletion.ts` | SQL 分支追加由 `sql_display.tables` 生成的动态补全项 |
(如生成逻辑较长,可拆一个小函数放同文件或 `sql.ts`,保持单一职责。)
## 错误处理
- `problem``sql_display``tables` 任一为空 → 返回纯静态列表(可选链兜底)。
- Pinia store 在组件上下文外调用的风险:补全回调在编辑器运行期触发,此时 Pinia 已安装;与 FlowchartEditor 的既有用法一致。
## 测试
项目无测试套件(政策:不写新测试)。人工验证:
1. 打开一道 SQL 题,编辑器中输入表名/字段名前缀,确认补全项出现且 detail/info 正确。
2. 打开非 SQL 题,确认补全行为无变化。
3. 协作编辑(SyncCodeEditor)场景同样生效。
@@ -0,0 +1,102 @@
# 学生视角(演示模式)设计
日期:2026-07-26
范围:仅前端(ojnext
## 背景
超级管理员给学生上课演示时,界面上到处是管理员才可见的入口和按钮(后台菜单、题目编辑、提交列表的额外操作列等)。这些东西对学生是噪音,也容易误点。需要一个一键开关,把界面临时切换成普通学生看到的样子。
## 目标
- 超管点一下,全站界面变成普通学生的样子
- 再点一下恢复
- 刷新页面不丢状态
- 改动集中,不散落到几十个页面
## 非目标
- 不改后端。演示模式是纯界面伪装,登录态仍然是超管,接口权限不变。目的是演示,不是权限隔离。
- 不做审计日志、不做时长限制。
- 教师管理员、学生管理员不提供此功能。
## 机制
全站所有权限判断都读 `shared/store/user.ts` 里的几个 getter,没有任何一处直接读 `user.admin_type`。因此在 store 层加一个开关,就能一次性覆盖全部调用点。
```ts
// shared/store/user.ts
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
// 不受伪装影响的真实身份,只用于决定是否显示切换入口
const realIsSuperAdmin = computed(
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
)
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
const isAdminRole = computed(() => !demoMode.value && (/* 原逻辑 */))
const isStudentAdmin = computed(() => !demoMode.value && (/* 原逻辑 */))
const isTeacherAdmin = computed(() => !demoMode.value && (/* 原逻辑 */))
const isTeacherOrAbove = computed(() => !demoMode.value && (/* 原逻辑 */))
const hasProblemPermission = computed(() => !demoMode.value && (/* 原逻辑 */))
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
function toggleDemoMode() {
demoMode.value = !demoMode.value
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
}
```
`realIsSuperAdmin` 是关键:如果切换入口的显示条件用被伪装后的 `isSuperAdmin`,一进入演示模式入口自己就消失了,退不出来。
## 改动清单
| 文件 | 改动 |
|---|---|
| `src/utils/constants.ts` | `STORAGE_KEY` 增加 `DEMO_MODE: "demoMode"` |
| `src/shared/store/user.ts` | 新增 `demoMode``realIsSuperAdmin``canToggleDemoMode``toggleDemoMode`6 个角色 getter 加 `!demoMode.value &&` 前缀;导出新成员 |
| `src/shared/components/Header.vue` | 用户下拉菜单 `options` 增加一项「进入演示 / 退出演示」 |
**不改**`src/main.ts` 路由守卫、`src/utils/permissions.ts`、以及所有页面级的 `v-if` 判断。它们读的都是上述 getter,自动跟随。
## 交互
**入口**:右上角用户头像下拉菜单,与「我的主页」「我的提交」并列。
- 显示条件:`userStore.canToggleDemoMode`
- 文案随状态翻转:未开启显示「进入演示」,已开启显示「退出演示」
- 该文案本身就是状态指示器,不额外加横幅或角标
**点击行为**
1. 调用 `toggleDemoMode()`
2. 如果是**进入**演示模式,且当前路由属于 `admins` 分支,执行 `router.push("/")`。否则页面会停在一个已失去权限的后台界面上。
3. 退出演示模式不需要跳转,留在当前页即可。
## 连带影响(均为预期行为)
- **后台入口消失**`Header.vue:165-175`)。手动输入 `/admin/*` 地址也会被 `main.ts` 守卫弹回首页,因为守卫读的是被伪装后的 getter。
- **提交列表**`submission/list.vue`)的教师专属筛选、额外列隐藏;`showSubmissions` 改为跟随站点配置 `submission_list_show_all`,而不是无条件为 true。
- **题目编辑表单**`problem/components/Form.vue`)的管理员字段隐藏。
- **比赛访问**`oj/store/contest.ts:49`)失去超管免密码特权,需按学生流程输密码。演示时更真实。
- **协同代码编辑**`shared/composables/sync.ts`)的超管特殊颜色与提示一并变为学生行为。**确认不做豁免**——演示场景不涉及协同编辑功能。
## 持久化与清理
-`localStorage`key 为 `demoMode`
- store 初始化时从 storage 读取,刷新后保持
- 退出登录时 `clearProfile()` 调用 `storage.clear()`,会一并清除,不会残留到下一个登录用户
## 验证方式
手工验证(本项目不写测试):
1. 超管登录 → 下拉菜单出现「进入演示」
2. 点击 → 顶栏「后台」消失,菜单文案变为「退出演示」
3. 停在 `/admin/problem/list` 时点击 → 跳回首页
4. 地址栏直接输 `/admin` → 弹回首页
5. 刷新页面 → 仍是学生界面
6. 点「退出演示」→ 后台入口恢复
7. 退出登录再登录 → 演示模式已重置为关闭
8. 用教师管理员账号登录 → 菜单中无此项
+965 -694
View File
File diff suppressed because it is too large Load Diff
+20 -21
View File
@@ -20,47 +20,46 @@
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1",
"@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^14.3.0",
"@vueuse/router": "^14.3.0",
"@wangeditor-next/editor": "^5.7.13",
"@wangeditor-next/editor-for-vue": "^5.1.14",
"axios": "^1.18.1",
"@vueuse/core": "^14.4.0",
"@vueuse/router": "^14.4.0",
"@wangeditor-next/editor": "^6.2.0",
"@wangeditor-next/editor-for-vue": "^6.2.0",
"axios": "^1.19.0",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.4.0",
"fflate": "^0.8.3",
"highlight.js": "^11.11.1",
"md-editor-v3": "^6.5.3",
"mermaid": "^11.16.0",
"md-editor-v3": "^6.5.6",
"mermaid": "^11.16.1",
"mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.44.1",
"nanoid": "^5.1.16",
"nanoid": "^6.0.1",
"normalize.css": "^8.0.1",
"pinia": "^3.0.4",
"pinia": "^4.0.2",
"skulpt": "^1.2.0",
"vue": "^3.5.39",
"vue-chartjs": "^5.3.3",
"vue": "^3.5.41",
"vue-chartjs": "^5.3.4",
"vue-codemirror": "^6.1.1",
"vue-router": "^5.1.0",
"vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.5",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.31"
"yjs": "^13.6.32"
},
"devDependencies": {
"@iconify/vue": "^5.0.1",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^26.1.0",
"@vitejs/plugin-legacy": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.7",
"@types/node": "^26.1.2",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.4",
"typescript": "^6.0.3",
"unplugin-auto-import": "^21.0.0",
"prettier": "^3.9.6",
"typescript": "^7.0.2",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.3"
"vite": "^8.2.1"
}
}
@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
createAchievement,
getMetricOptions,
updateAchievement,
type AdminAchievement,
type MetricOption,
} from "admin/api"
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<{
show: boolean
editing: AdminAchievement | null
}>()
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
const message = useMessage()
const metrics = ref<MetricOption[]>([])
const saving = ref(false)
function emptyForm() {
return {
name: "",
description: "",
icon: "noto:trophy",
rarity: "bronze",
hidden: false,
metric: "",
operator: "gte" as "gte" | "lte",
threshold: 1,
visible: true,
order: 0,
}
}
const form = ref(emptyForm())
const metricOptions = computed(() =>
metrics.value.map((m) => ({ label: `${m.name}${m.key}`, value: m.key })),
)
const metricHelp = computed(
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
)
watch(
() => props.show,
async (show) => {
if (!show) return
if (!metrics.value.length) {
const res = await getMetricOptions()
metrics.value = res.data
}
if (props.editing) {
form.value = { ...emptyForm(), ...props.editing }
} else {
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
}
},
)
async function save() {
if (!form.value.name || !form.value.metric) {
message.error("名称和指标不能为空")
return
}
saving.value = true
try {
if (props.editing) {
await updateAchievement({ ...form.value, id: props.editing.id })
} else {
await createAchievement(form.value)
}
message.success("保存成功")
emit("update:show", false)
emit("saved")
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal
:show="show"
preset="card"
style="width: 560px"
:title="editing ? '编辑成就' : '新建成就'"
@update:show="emit('update:show', $event)"
>
<n-form label-placement="left" :label-width="80">
<n-form-item label="名称" required>
<n-input v-model:value="form.name" placeholder="成就名称" />
</n-form-item>
<n-form-item label="描述" required>
<n-input
v-model:value="form.description"
type="textarea"
placeholder="达成条件的描述,展示给学生看"
/>
</n-form-item>
<n-form-item label="图标">
<n-flex vertical :size="4" style="flex: 1">
<n-flex align="center" :size="10" :wrap="false">
<div class="icon-preview">
<AchievementIcon :icon="form.icon" :size="28" />
</div>
<n-input
v-model:value="form.icon"
placeholder="iconify 图标名,例如 noto:owl"
/>
</n-flex>
<n-text depth="3" style="font-size: 12px">
iconify 图标名推荐 noto: 开头的彩色 emoji 图标左侧是实时
预览预览不出来说明名字写错了图标名可在 icon-sets.iconify.design
搜索
</n-text>
</n-flex>
</n-form-item>
<n-form-item label="稀有度">
<n-select v-model:value="form.rarity" :options="rarityOptions" />
</n-form-item>
<n-form-item label="指标" required>
<n-select
v-model:value="form.metric"
:options="metricOptions"
filterable
/>
</n-form-item>
<n-form-item v-if="metricHelp" label=" ">
<n-text depth="3">{{ metricHelp }}</n-text>
</n-form-item>
<n-form-item label="条件">
<n-flex align="center">
<n-select
v-model:value="form.operator"
style="width: 130px"
:options="[
{ label: '大于等于', value: 'gte' },
{ label: '小于等于', value: 'lte' },
]"
/>
<n-input-number v-model:value="form.threshold" :min="0" />
</n-flex>
</n-form-item>
<n-form-item label="隐藏成就">
<n-switch v-model:value="form.hidden" />
<n-text depth="3" style="margin-left: 12px">
未解锁时学生只能看到 ???
</n-text>
</n-form-item>
<n-form-item label="上架">
<n-switch v-model:value="form.visible" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="form.order" />
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">
保存
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 34px;
flex: none;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { NButton, NFlex } from "naive-ui"
import {
deleteAchievement,
getAdminAchievements,
type AdminAchievement,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
const message = useMessage()
const dialog = useDialog()
const list = ref<AdminAchievement[]>([])
const loading = ref(false)
const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null)
// 下架的只在有的时候才提,全部上架时标题不啰嗦
const title = computed(() => {
if (!list.value.length) return "成就管理"
const offline = list.value.filter((a) => !a.visible).length
return offline
? `成就管理(${list.value.length} 条,${offline} 条下架)`
: `成就管理(${list.value.length} 条)`
})
async function load() {
loading.value = true
try {
const res = await getAdminAchievements()
list.value = res.data
} finally {
loading.value = false
}
}
function create() {
editing.value = null
showModal.value = true
}
function edit(row: AdminAchievement) {
editing.value = row
showModal.value = true
}
function remove(row: AdminAchievement) {
dialog.warning({
title: "删除成就",
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteAchievement(row.id)
message.success("已删除")
load()
},
})
}
const columns: DataTableColumn<AdminAchievement>[] = [
{
title: "图标",
key: "icon",
width: 60,
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
},
{ title: "名称", key: "name" },
{
title: "稀有度",
key: "rarity",
width: 90,
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
},
{ title: "指标", key: "metric_name" },
{
title: "条件",
key: "threshold",
width: 110,
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
},
{
title: "隐藏",
key: "hidden",
width: 70,
render: (row) => (row.hidden ? "是" : "—"),
},
{
title: "上架",
key: "visible",
width: 70,
render: (row) => (row.visible ? "是" : "否"),
},
{ title: "已解锁人数", key: "unlock_count", width: 110 },
{
title: "操作",
key: "actions",
width: 130,
render: (row) =>
h(NFlex, { size: 8 }, () => [
h(
NButton,
{ text: true, type: "primary", onClick: () => edit(row) },
() => "编辑",
),
h(
NButton,
{ text: true, type: "error", onClick: () => remove(row) },
() => "删除",
),
]),
},
]
onMounted(load)
</script>
<template>
<n-card :title="title">
<template #header-extra>
<n-button type="primary" @click="create">新建成就</n-button>
</template>
<n-alert type="info" style="margin-bottom: 12px">
已解锁人数是唯一的仪表盘配置一周后仍为
0多半是阈值配错了而不是太难
</n-alert>
<n-data-table
:loading="loading"
:data="list"
:columns="columns"
:row-key="(row: AdminAchievement) => row.id"
/>
<AchievementModal
v-model:show="showModal"
:editing="editing"
@saved="load"
/>
</n-card>
</template>
+103 -10
View File
@@ -2,6 +2,7 @@ import http from "utils/http"
import { toProblemListItem } from "admin/transforms"
import type {
AdminProblem,
AdminTag,
Announcement,
AnnouncementEdit,
BlankContest,
@@ -10,6 +11,7 @@ import type {
Exercise,
ExerciseType,
Server,
SQLDisplay,
TestcaseUploadedReturns,
Tutorial,
User,
@@ -30,6 +32,7 @@ export async function getProblemList(
keyword: string,
author?: string,
contestID?: string,
tagId?: number,
) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get<{ results: AdminProblem[]; total: number }>(
@@ -42,6 +45,7 @@ export async function getProblemList(
keyword,
author,
contest_id: contestID,
tag_id: tagId,
},
},
)
@@ -83,6 +87,35 @@ export function getContestProblem(id: number) {
return http.get("admin/contest/problem", { params: { id } })
}
// 标签管理
export function getTagAdminList(keyword = "") {
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
}
export function renameTag(id: number, name: string) {
return http.put<{
merged: boolean
id: number
name: string
affected_count: number
}>("admin/problem/tag", { id, name })
}
export function deleteTag(id: number) {
return http.delete("admin/problem/tag", { params: { id } })
}
export function batchTagProblems(
problemIds: number[],
tagNames: string[],
action: "add" | "remove",
) {
return http.post<{ problem_count: number; tag_count: number }>(
"admin/problem/batch_tag",
{ problem_ids: problemIds, tag_names: tagNames, action },
)
}
// 用户列表
export function getUserList(
offset = 0,
@@ -145,6 +178,31 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
})
}
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
export function previewSQLTestcase(data: {
init_sql: string
ref_sql: string
mode: "query" | "modify"
}) {
return http.post<SQLDisplay>("admin/sql_test_case_preview", data)
}
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) {
return http.get<{ name: string; content: string }[]>(
"admin/sql_test_case_scripts",
{ params: { problem_id: problemId } },
)
}
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
export function generateSQLTestcase(data: {
ref_sql: string
mode: "query" | "modify"
}) {
return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data)
}
export function createProblem(problem: BlankProblem) {
return http.post("admin/problem", problem)
}
@@ -229,16 +287,6 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
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() {
const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data
@@ -505,3 +553,48 @@ export function pinAIReport(id: number) {
export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
}
// ==================== 成就 ====================
export interface AdminAchievement {
id: number
name: string
description: string
icon: string
rarity: string
hidden: boolean
metric: string
metric_name: string
operator: "gte" | "lte"
threshold: number
visible: boolean
unlock_count: number
order: number
create_time: string
}
export interface MetricOption {
key: string
name: string
help_text: string
}
export function getAdminAchievements() {
return http.get<AdminAchievement[]>("admin/achievement")
}
export function getMetricOptions() {
return http.get<MetricOption[]>("admin/achievement/metrics")
}
export function createAchievement(data: Partial<AdminAchievement>) {
return http.post<AdminAchievement>("admin/achievement", data)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return http.put<AdminAchievement>("admin/achievement", data)
}
export function deleteAchievement(id: number) {
return http.delete("admin/achievement", { params: { id } })
}
-114
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>
@@ -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>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { AdminTag } from "utils/types"
import { batchTagProblems, getTagAdminList } from "admin/api"
interface Props {
show: boolean
problemIds: number[]
action: "add" | "remove"
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
done: []
}>()
const message = useMessage()
const tags = ref<AdminTag[]>([])
const selected = ref<string[]>([])
const newTags = ref<string[]>([])
const title = computed(() =>
props.action === "add" ? "批量添加标签" : "批量移除标签",
)
const selectedSet = computed(() => new Set(selected.value))
const names = computed(() =>
props.action === "add"
? Array.from(new Set([...selected.value, ...newTags.value]))
: selected.value,
)
function toggleTag(name: string) {
const set = new Set(selected.value)
if (set.has(name)) set.delete(name)
else set.add(name)
selected.value = Array.from(set)
}
async function listTags() {
const res = await getTagAdminList()
tags.value = res.data
}
function close() {
emit("update:show", false)
}
async function submit() {
if (!names.value.length) {
message.error("请先选择标签")
return
}
const res = await batchTagProblems(
props.problemIds,
names.value,
props.action,
)
const verb = props.action === "add" ? "添加" : "移除"
message.success(
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
)
close()
emit("done")
}
watch(
() => props.show,
(show) => {
if (!show) return
selected.value = []
newTags.value = []
listTags()
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="title"
style="width: 600px"
:mask-closable="false"
@close="close"
>
<n-flex vertical size="large">
<div>已选中 {{ problemIds.length }} 道题目</div>
<n-flex size="small">
<n-tag
v-for="tag in tags"
:key="tag.id"
checkable
:checked="selectedSet.has(tag.name)"
@update:checked="toggleTag(tag.name)"
>
{{ tag.name }}{{ tag.problem_count }}
</n-tag>
</n-flex>
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
<n-flex justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" @click="submit">确定</n-button>
</n-flex>
</n-flex>
</n-modal>
</template>
@@ -0,0 +1,345 @@
<script setup lang="ts">
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import {
generateSQLTestcase,
getSQLTestcaseScripts,
previewSQLTestcase,
uploadTestcases,
} from "../../api"
interface ScriptEntry {
id: number
sql: string
display: SQLDisplay | null
error: string
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
stale: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
mode: "query" | "modify"
problemId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function blankEntry(): ScriptEntry {
return { id: nextId++, sql: "", display: null, error: "", stale: false }
}
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
const refSQL = computed(
() =>
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
"",
)
const isPreviewing = ref(false)
const isUploading = ref(false)
const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
const filledCount = computed(
() => scripts.value.filter((s) => s.sql.trim()).length,
)
const canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim())
return (
!isPreviewing.value &&
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
filled.length >= 2 &&
filled.every((s) => s.display && !s.error && !s.stale)
)
})
watch([refSQL, () => props.mode], () => {
for (const s of scripts.value) {
if (s.display || s.error) s.stale = true
}
})
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
onMounted(async () => {
if (!props.problemId) return
try {
const res = await getSQLTestcaseScripts(props.problemId)
if (res.data.length) {
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch {}
})
function add() {
scripts.value.push(blankEntry())
}
function remove(index: number) {
scripts.value.splice(index, 1)
}
function reset() {
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
}
function expectedQuery(d: SQLDisplay) {
return "columns" in d.expected ? d.expected : null
}
function changedTables(d: SQLDisplay) {
return "changed_tables" in d.expected ? d.expected.changed_tables : []
}
async function generate() {
const blanks = scripts.value.filter((s) => !s.sql.trim())
if (!blanks.length) return
isGenerating.value = true
await Promise.all(
blanks.map(async (s) => {
try {
const res = await generateSQLTestcase({
ref_sql: refSQL.value,
mode: props.mode,
})
s.sql = res.data.sql
} catch (err) {
const data = (err as { data?: unknown })?.data
message.error(typeof data === "string" ? data : "AI 生成失败")
}
}),
)
isGenerating.value = false
await preview()
}
async function preview() {
// 丢弃空脚本
scripts.value = scripts.value.filter((s) => s.sql.trim())
if (!scripts.value.length) {
scripts.value = [blankEntry()]
return
}
isPreviewing.value = true
await Promise.all(
scripts.value.map(async (s) => {
s.display = null
s.error = ""
s.stale = false
try {
const res = await previewSQLTestcase({
init_sql: s.sql,
ref_sql: refSQL.value,
mode: props.mode,
})
s.display = res.data
} catch (err) {
const data = (err as { data?: unknown })?.data
s.error = typeof data === "string" ? data : "预览失败"
}
}),
)
isPreviewing.value = false
}
async function upload() {
isUploading.value = true
try {
const data = scripts.value
.filter((s) => s.sql.trim())
.map((s, i) => ({
name: `${i + 1}.sql`,
content: s.sql,
}))
const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true })
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(
i === testcases.length - 1 ? baseScore + remainder : baseScore,
)
})
emit("uploaded", res.data.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert
v-if="!refSQL"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
还没有填写 SQL 标准答案请先在上方"本题参考答案"中填写再来编写测试点
</n-alert>
<n-flex align="center" wrap>
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
清空
</n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="add">
+1
</n-button>
<n-tooltip :disabled="!!refSQL && hasBlankScript">
<template #trigger>
<span>
<n-button
:loading="isGenerating"
:disabled="!refSQL || !hasBlankScript || isPreviewing"
@click="generate"
>
AI 生成
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
</n-tooltip>
<n-tooltip :disabled="!!refSQL && hasAnyScript">
<template #trigger>
<span>
<n-button
type="success"
:loading="isPreviewing"
:disabled="!refSQL || !hasAnyScript || isGenerating"
@click="preview"
>
预览验证
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
</n-tooltip>
<n-tooltip :disabled="canUpload || isPreviewing">
<template #trigger>
<span>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload || isGenerating"
@click="upload"
>
上传
</n-button>
</span>
</template>
{{
filledCount < 2
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
: "所有脚本预览验证通过后才能上传"
}}
</n-tooltip>
</n-flex>
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
<n-flex justify="space-between" align="center">
<strong>{{ index + 1 }}.sql</strong>
<n-button
size="small"
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
<n-input
type="textarea"
v-model:value="s.sql"
:rows="8"
placeholder="-- 本测试点的建表 + 插入数据脚本
CREATE TABLE ...;
INSERT INTO ...;"
:status="
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
"
/>
<n-alert v-if="s.error" type="error" :show-icon="false">
{{ s.error }}
</n-alert>
<template v-if="s.display">
<n-alert v-if="s.stale" type="warning" :show-icon="false">
标准答案或题型已修改以下预览已过期请重新预览
</n-alert>
<div :class="{ stalePreview: s.stale }">
<p class="previewTitle">数据表</p>
<div v-for="t in s.display.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<p class="previewTitle">期望结果</p>
<SQLDataTable
v-if="expectedQuery(s.display)"
:columns="expectedQuery(s.display)!.columns"
:rows="expectedQuery(s.display)!.rows"
:total-rows="expectedQuery(s.display)!.total_rows"
:truncated="expectedQuery(s.display)!.truncated"
/>
<div v-for="t in changedTables(s.display)" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name}` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</div>
</template>
</div>
</n-flex>
</template>
<style scoped>
.scriptBox {
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.previewTitle {
font-weight: bold;
margin: 4px 0;
}
.sqlTableName {
font-weight: 500;
margin: 4px 0;
opacity: 0.85;
}
.stalePreview {
opacity: 0.45;
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api"
interface Props {
show: boolean
tagId: number
tagName: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
changed: []
}>()
const router = useRouter()
const message = useMessage()
const problems = ref<AdminProblemFiltered[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{
title: "标题",
key: "title",
minWidth: 200,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => goEdit(row) },
() => row.title,
),
},
{
title: "可见",
key: "visible",
width: 80,
render: (row) =>
h(
NTag,
{ size: "small", type: row.visible ? "success" : "default" },
() => (row.visible ? "公开" : "隐藏"),
),
},
{
title: "选项",
key: "actions",
width: 110,
render: (row) =>
h(
NButton,
{ size: "small", type: "error", onClick: () => removeTag(row) },
() => "移除标签",
),
},
]
async function listProblems() {
if (page.value < 1) page.value = 1
const offset = (page.value - 1) * limit.value
const res = await getProblemList(
offset,
limit.value,
keyword.value,
"",
undefined,
props.tagId,
)
problems.value = res.results
total.value = res.total
}
function close() {
emit("update:show", false)
}
function goEdit(row: AdminProblemFiltered) {
close()
router.push({ name: "admin problem edit", params: { problemID: row.id } })
}
async function removeTag(row: AdminProblemFiltered) {
await batchTagProblems([row.id], [props.tagName], "remove")
message.success(`已移除「${row.title}」的标签`)
emit("changed")
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
if (problems.value.length === 1 && page.value > 1) {
page.value -= 1
} else {
listProblems()
}
}
// 改搜索词就回到第一页
watch(keyword, () => (page.value = 1))
// 每次打开弹窗重置状态,拉取交给下面的 watcher
watch(
() => props.show,
(show) => {
if (!show) return
page.value = 1
keyword.value = ""
},
)
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
watchDebounced(
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
() => {
if (!props.show) return
listProblems()
},
{ debounce: 300, maxWait: 800 },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="`标签「${tagName}」下的题目`"
style="width: 720px"
@close="close"
>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<span> {{ total }} 道题</span>
<n-input
v-model:value="keyword"
style="width: 220px"
placeholder="输入标题关键字"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="problems" />
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
</n-flex>
</n-modal>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api"
@@ -158,15 +158,14 @@ async function run() {
async function upload() {
isUploading.value = true
try {
const now = new Date()
const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [
{ name: `${i + 1}.in`, input: f.in, lastModified: now },
{ name: `${i + 1}.out`, input: f.out, lastModified: now },
{ name: `${i + 1}.in`, content: f.in },
{ 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 res = await uploadTestcases(file)
+58 -57
View File
@@ -2,6 +2,7 @@
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import {
CODE_TEMPLATES,
@@ -78,16 +79,10 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
],
test_case_id: "",
test_case_score: [] as Testcase[],
rule_type: "ACM",
hint: "",
source: "",
prompt: "",
answers: [] as { language: LANGUAGE; code: string }[],
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
contest_id: "",
allow_flowchart: false,
mermaid_code: "",
@@ -239,7 +234,6 @@ async function getProblemDetail() {
problem.value.samples = data.samples
problem.value.test_case_id = data.test_case_id
problem.value.test_case_score = data.test_case_score
problem.value.rule_type = data.rule_type
problem.value.hint = data.hint
problem.value.source = data.source
problem.value.prompt = data.prompt
@@ -259,7 +253,6 @@ async function getProblemDetail() {
code: "",
}))
}
problem.value.io_mode = data.io_mode
if (problem.value.contest_id) {
problem.value.contest_id = problem.value.contest_id
}
@@ -344,19 +337,20 @@ async function validateProblem() {
// 题目
else if (
!problem.value.description ||
!problem.value.input_description ||
!problem.value.output_description
(!isSQLProblem.value &&
(!problem.value.input_description || !problem.value.output_description))
) {
message.error("题目或输入或输出没有填写")
hasErrors = true
}
// 样例
else if (problem.value.samples.length == 0) {
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
message.error("样例没有填写")
hasErrors = true
}
// 样例是空的
else if (
!isSQLProblem.value &&
problem.value.samples.some(
(sample) => sample.output === "" || sample.input === "",
)
@@ -432,12 +426,18 @@ function filterAnswers() {
)
}
function filterSamplesForSQL() {
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
if (isSQLProblem.value) problem.value.samples = []
}
async function submit() {
const hasValidationErrors = await validateProblem()
if (hasValidationErrors) return
filterHint()
getTemplate()
filterAnswers()
filterSamplesForSQL()
syncProblemTags()
const api = {
"admin problem create": createProblem,
@@ -592,45 +592,47 @@ watch(
:min-height="300"
/>
<TextEditor
v-if="ready"
v-if="ready && !isSQLProblem"
v-model:value="problem.input_description"
title="输入的描述"
/>
<TextEditor
v-if="ready"
v-if="ready && !isSQLProblem"
v-model:value="problem.output_description"
title="输出的描述"
/>
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button
tertiary
type="warning"
size="small"
@click="removeSample(index)"
>
删除 {{ index + 1 }}
</n-button>
</n-flex>
<n-grid x-gap="20" cols="2">
<n-gi span="1">
<n-flex vertical>
<span>输入样例</span>
<n-input type="textarea" v-model:value="sample.input" />
</n-flex>
</n-gi>
<n-gi span="1">
<n-flex vertical>
<span>输出样例</span>
<n-input type="textarea" v-model:value="sample.output" />
</n-flex>
</n-gi>
</n-grid>
</div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
添加用例
</n-button>
<template v-if="!isSQLProblem">
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button
tertiary
type="warning"
size="small"
@click="removeSample(index)"
>
删除 {{ index + 1 }}
</n-button>
</n-flex>
<n-grid x-gap="20" cols="2">
<n-gi span="1">
<n-flex vertical>
<span>输入样例</span>
<n-input type="textarea" v-model:value="sample.input" />
</n-flex>
</n-gi>
<n-gi span="1">
<n-flex vertical>
<span>输出样例</span>
<n-input type="textarea" v-model:value="sample.output" />
</n-flex>
</n-gi>
</n-grid>
</div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
添加用例
</n-button>
</template>
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
<n-form>
<n-form-item label="题目的来源(选填)">
@@ -774,8 +776,8 @@ watch(
<h2 class="title">测试用例区域</h2>
<n-flex align="center" style="margin-bottom: 12px">
<div v-if="!isSQLProblem">
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true">
直接生成
</n-button>
@@ -786,26 +788,25 @@ watch(
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">
{{ isSQLProblem ? "上传数据脚本压缩包" : "(老)手动上传" }}
</n-button>
<n-button type="info">手动上传</n-button>
</n-upload>
</div>
<n-tooltip placement="right" style="max-width: 320px; white-space: normal">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
<template v-if="isSQLProblem">
压缩包内放 1.sql2.sql每个文件是一个测试点的建表+插入数据脚本
判题时对每个测试点分别运行标准答案和学生 SQL 比对结果 建议至少 2-3
个数据不同的测试点防止学生硬编码答案
</template>
<template v-else>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
</n-flex>
<SQLTestcaseEditor
v-if="isSQLProblem"
:answers="problem.answers"
:mode="problem.sql_config?.mode ?? 'query'"
:problem-id="problem.id"
@uploaded="handleTestcasesGenerated"
/>
<n-alert
class="box"
v-if="problem.test_case_score.length"
@@ -891,7 +892,7 @@ watch(
</n-form-item>
</n-form>
</template>
<n-flex style="margin-bottom: 120px" align="center" justify="end">
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>
</template>
+76 -4
View File
@@ -1,16 +1,18 @@
<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 Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue"
import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue"
interface Props {
contestID?: string
@@ -36,6 +38,30 @@ const { count, inc } = useCounter(0)
const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([])
const selectedRowKeys = ref<DataTableRowKey[]>([])
const batchTagAction = ref<"add" | "remove">("add")
const [showBatchTag, toggleBatchTag] = useToggle(false)
const selectedProblemIds = computed(() =>
selectedRowKeys.value.map((key) => Number(key)),
)
const rowKey = (row: AdminProblemFiltered) => row.id
function chooseProblems(rowKeys: DataTableRowKey[]) {
selectedRowKeys.value = rowKeys
}
function openBatchTag(action: "add" | "remove") {
batchTagAction.value = action
toggleBatchTag(true)
}
function onBatchTagDone() {
selectedRowKeys.value = []
listProblems()
}
const nextDisplayID = computed(() => {
if (!isContestProblemList.value) return ""
if (problems.value.length === 0) return "1"
@@ -57,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
author: useRouteQuery("author", "").value,
})
const columns: DataTableColumn<AdminProblemFiltered>[] = [
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 },
@@ -109,6 +135,21 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
: 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: "创建时间",
@@ -141,6 +182,13 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
},
]
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
: [{ type: "selection" }, ...baseColumns],
)
async function listProblems() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
@@ -212,8 +260,20 @@ watch(() => [query.page, query.limit, query.author], listProblems)
>
年度趋势
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin tag list' })"
>
标签管理
</n-button>
</n-flex>
<n-flex>
<template v-if="!isContestProblemList && selectedProblemIds.length">
<n-button type="primary" @click="openBatchTag('add')">
添加标签{{ selectedProblemIds.length }}
</n-button>
<n-button @click="openBatchTag('remove')">移除标签</n-button>
</template>
<n-button v-if="isContestProblemList" @click="createContestProblem">
新建比赛题目
</n-button>
@@ -238,7 +298,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
</div>
</n-flex>
</n-flex>
<n-data-table striped :columns="columns" :data="problems" />
<n-data-table
striped
:columns="columns"
:data="problems"
:row-key="rowKey"
@update:checked-row-keys="chooseProblems"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
@@ -250,6 +316,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
:next-display-id="nextDisplayID"
@change="listProblems"
/>
<BatchTagModal
v-model:show="showBatchTag"
:problem-ids="selectedProblemIds"
:action="batchTagAction"
@done="onBatchTagDone"
/>
</template>
<style scoped>
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { NButton, NFlex, NInput } from "naive-ui"
import type { AdminTag } from "utils/types"
import { deleteTag, getTagAdminList, renameTag } from "../api"
import TagProblemsModal from "./components/TagProblemsModal.vue"
const message = useMessage()
const dialog = useDialog()
const tags = ref<AdminTag[]>([])
const keyword = ref("")
const editingId = ref<number | null>(null)
const editingName = ref("")
const activeTag = ref<AdminTag | null>(null)
const [showTagProblems, toggleTagProblems] = useToggle(false)
function openTagProblems(tag: AdminTag) {
activeTag.value = tag
toggleTagProblems(true)
}
const columns: DataTableColumn<AdminTag>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "标签名",
key: "name",
minWidth: 200,
render: (row) =>
editingId.value === row.id
? h(NInput, {
value: editingName.value,
autofocus: true,
size: "small",
style: "max-width: 240px",
onUpdateValue: (v: string) => (editingName.value = v),
onKeyup: (e: KeyboardEvent) => {
if (e.key === "Enter") saveTag(row)
if (e.key === "Escape") cancelEdit()
},
})
: h(
NButton,
{
text: true,
type: "primary",
onClick: () => openTagProblems(row),
},
() => row.name,
),
},
{
title: "题目数",
key: "problem_count",
width: 100,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
() => String(row.problem_count),
),
},
{
title: "选项",
key: "actions",
width: 200,
render: (row) =>
h(NFlex, { size: 8 }, () =>
editingId.value === row.id
? [
h(
NButton,
{ size: "small", type: "primary", onClick: () => saveTag(row) },
() => "保存",
),
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
]
: [
h(
NButton,
{ size: "small", onClick: () => startEdit(row) },
() => "重命名",
),
h(
NButton,
{
size: "small",
type: "error",
onClick: () => confirmDelete(row),
},
() => "删除",
),
],
),
},
]
async function listTags() {
const res = await getTagAdminList(keyword.value)
tags.value = res.data
}
function startEdit(tag: AdminTag) {
editingId.value = tag.id
editingName.value = tag.name
}
function cancelEdit() {
editingId.value = null
editingName.value = ""
}
async function saveTag(tag: AdminTag) {
const name = editingName.value.trim()
if (!name) {
message.error("标签名不能为空")
return
}
if (name === tag.name) {
cancelEdit()
return
}
const res = await renameTag(tag.id, name)
if (res.data.merged) {
message.success(
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
)
} else {
message.success("已重命名")
}
cancelEdit()
listTags()
}
function confirmDelete(tag: AdminTag) {
dialog.warning({
title: "删除标签",
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteTag(tag.id)
message.success("已删除")
listTags()
},
})
}
onMounted(listTags)
watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">标签管理</h2>
<n-button @click="$router.push({ name: 'admin problem list' })">
返回题目列表
</n-button>
</n-flex>
<n-input
v-model:value="keyword"
style="width: 200px"
placeholder="搜索标签"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="tags" />
<TagProblemsModal
v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0"
:tag-name="activeTag?.name ?? ''"
@changed="listTags"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+27 -2
View File
@@ -1,5 +1,10 @@
<script setup lang="ts">
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 type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config"
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
}
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("网站配置保存成功")
getWebsiteConfig()
configStore.getConfig()
@@ -214,7 +233,13 @@ onMounted(() => {
</n-form>
<n-form label-placement="left">
<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>
<n-flex align="center">
+2
View File
@@ -14,5 +14,7 @@ export function toProblemListItem(result: AdminProblem) {
has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
// 比赛题目列表接口不返回这个字段
top_reaction: result.top_reaction ?? null,
}
}
+19 -2
View File
@@ -1,4 +1,10 @@
<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"
const message = useMessage()
@@ -12,6 +18,17 @@ function generateUsers() {
message.info("请填写相关内容")
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}` : ""
rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n")
@@ -64,9 +81,9 @@ async function submit() {
style="width: 170px"
v-model:value="prefix"
clearable
:max="9999"
:max="CLASS_NAME_MAX_VALUE"
:min="0"
placeholder="班级号"
:placeholder="`班级号${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
/>
</n-flex>
<n-input
+23 -15
View File
@@ -172,7 +172,6 @@ function createNewUser() {
problem_permission: "None",
create_time: new Date(),
last_login: new Date(),
two_factor_auth: false,
open_api: false,
is_disabled: false,
password: "",
@@ -197,20 +196,29 @@ async function handleEditUser() {
message.error("密码长度不得小于 6")
return
}
if (create.value) {
const newUser = [
[
userEditing.value.username,
password.value,
userEditing.value.email,
userEditing.value.real_name,
],
]
await importUsers(newUser)
listUsers()
} else {
const user = Object.assign(userEditing.value, { password: password.value })
await editUser(user)
// http 拦截器只对 login-required / permission-denied 自动弹提示,
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
try {
if (create.value) {
const newUser = [
[
userEditing.value.username,
password.value,
userEditing.value.email,
userEditing.value.real_name,
],
]
await importUsers(newUser)
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
password.value = ""
+27
View File
@@ -0,0 +1,27 @@
import http from "utils/http"
import type {
Achievement,
AchievementSummary,
PendingAchievement,
} from "utils/types"
export function getAchievements(name?: string) {
return http.get<{ username: string; achievements: Achievement[] }>(
"achievements",
{ params: name ? { name } : {} },
)
}
export function getAchievementSummary(name?: string) {
return http.get<AchievementSummary>("achievements/summary", {
params: name ? { name } : {},
})
}
export function getPendingAchievements() {
return http.get<PendingAchievement[]>("achievements/pending")
}
export function markAchievementsRead(ids: number[]) {
return http.post("achievements/pending", { ids })
}
@@ -0,0 +1,134 @@
<script setup lang="ts">
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"
const props = defineProps<{ achievement: Achievement }>()
// 边框用原色,tag 里的文字用跟主题走的那套
const rarityTextColor = useRarityColor()
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
const masked = computed(
() => props.achievement.hidden && !props.achievement.unlocked,
)
// 获得率低于 5% 的加稀有闪光边框
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
)
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
// 画成百分比毫无意义,改成直接显示当前最好成绩
const showProgressBar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "gte" &&
props.achievement.threshold !== null,
)
const showBestSoFar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "lte" &&
props.achievement.threshold !== null,
)
const percent = computed(() => {
const { progress, threshold } = props.achievement
if (threshold === null || threshold <= 0) return 100
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
})
const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlock_time) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得`
})
</script>
<template>
<n-card
size="small"
:class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<n-thing>
<template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" />
</template>
<template #header>
<n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag
size="tiny"
:color="{
borderColor: RARITY_COLOR[achievement.rarity],
textColor: rarityTextColor[achievement.rarity],
}"
>
{{ RARITY_LABEL[achievement.rarity] }}
</n-tag>
</n-flex>
</template>
<template #description>
<n-text depth="3">{{ achievement.description }}</n-text>
</template>
<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
style="flex: 1"
type="line"
:percentage="percent"
:height="6"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</n-text>
</template>
<template v-else-if="showBestSoFar">
<n-text depth="3" class="nowrap">
目标 {{ achievement.threshold }}
</n-text>
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
当前最好 {{ achievement.progress }}
</n-text>
</template>
<n-text v-else depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</n-flex>
</n-thing>
</n-card>
</template>
<style scoped>
.nowrap {
white-space: nowrap;
}
.locked {
filter: grayscale(1);
opacity: 0.55;
}
.rare {
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api"
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"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
}
const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined)
// 标签和进度条同色,整行读作一个单位
const rarityColor = useRarityColor()
const achievements = ref<Achievement[]>([])
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 { isDesktop } = useBreakpoints()
const tab = ref("all")
const loading = ref(true)
const filtered = computed(() => {
if (tab.value === "unlocked")
return achievements.value.filter((a) => a.unlocked)
if (tab.value === "locked")
return achievements.value.filter((a) => !a.unlocked)
return achievements.value
})
async function load() {
loading.value = true
try {
const [list, sum, badgeRes] = await Promise.all([
getAchievements(name.value),
getAchievementSummary(name.value),
getUserBadges(name.value),
])
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
achievements.value = list.data.achievements
summary.value = sum.data
badges.value = (badgeRes.data ?? []) as UserBadge[]
} finally {
loading.value = false
}
}
onMounted(load)
watch(name, load)
</script>
<template>
<div class="hall">
<!-- delay 50ms缓存命中时数据几乎立刻回来不闪一下转圈 -->
<n-spin :show="loading" :delay="50" style="min-height: 240px">
<n-card v-if="summary">
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
<n-flex vertical align="center" :size="6">
<n-progress
type="circle"
:percentage="summary.percent"
:stroke-width="8"
>
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
{{ summary.percent }}%
</n-text>
</n-progress>
<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-tabs v-model:value="tab" type="line" class="tabs">
<n-tab name="all">全部</n-tab>
<n-tab name="unlocked">已获得</n-tab>
<n-tab name="locked">未获得</n-tab>
<n-tab name="badges">题单奖章</n-tab>
</n-tabs>
<template v-if="tab !== 'badges'">
<n-grid
v-if="filtered.length"
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>
<template v-else>
<n-grid
v-if="badges.length"
responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="b in badges" :key="b.id">
<n-card size="small">
<n-thing
: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>
</div>
</template>
<style scoped>
.hall {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.rarity {
flex: 1;
max-width: 420px;
}
.nowrap {
white-space: nowrap;
}
.tabs {
margin: 16px 0;
}
.source {
display: block;
margin-top: 6px;
font-size: 13px;
}
.source a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
</style>
+13 -16
View File
@@ -3,6 +3,8 @@ import { filterResult } from "oj/transforms"
import type {
Exercise,
Problem,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
@@ -223,22 +225,17 @@ export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } })
}
export function createComment(data: {
problem_id: number
description_rating: number
difficulty_rating: number
comprehensive_rating: number
content: string
}) {
return http.post("comment", data)
export function getReaction(problemID: number) {
return http.get<ReactionState>("reaction", {
params: { problem_id: problemID },
})
}
export function getComment(problemID: number) {
return http.get("comment", { params: { problem_id: problemID } })
}
export function getCommentStatistics(problemID: number) {
return http.get("comment/statistics", { params: { problem_id: problemID } })
export function setReaction(problemID: number, type: ReactionKey) {
return http.post<ReactionState>("reaction", {
problem_id: problemID,
type,
})
}
// TODO: 这个API有问题
@@ -254,8 +251,8 @@ export function getTutorial(id: number) {
return http.get("tutorial", { params: { id } })
}
export function getTutorials() {
return http.get("tutorials")
export function getTutorials(type: "python" | "c") {
return http.get("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
+12 -3
View File
@@ -871,7 +871,10 @@ const radarChartOptions = {
class="stat-avg-ac"
>
<template #suffix>
<Icon icon="streamline-ultimate-color:analytics-pie-2" width="20" />
<Icon
icon="streamline-ultimate-color:analytics-pie-2"
width="20"
/>
</template>
</n-statistic>
</n-gi>
@@ -883,7 +886,10 @@ const radarChartOptions = {
class="stat-median-ac"
>
<template #suffix>
<Icon icon="streamline-ultimate-color:cursor-target-1" width="20" />
<Icon
icon="streamline-ultimate-color:cursor-target-1"
width="20"
/>
</template>
</n-statistic>
</n-gi>
@@ -895,7 +901,10 @@ const radarChartOptions = {
class="stat-total-submission"
>
<template #suffix>
<Icon icon="streamline-ultimate-color:common-file-text" width="20" />
<Icon
icon="streamline-ultimate-color:common-file-text"
width="20"
/>
</template>
</n-statistic>
</n-gi>
+3 -22
View File
@@ -1,11 +1,7 @@
<script setup lang="ts">
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
import type { Exercise, ExerciseFillData } from "utils/types"
hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseFillData)
@@ -14,10 +10,6 @@ type CodeSeg = { type: "code"; html: string }
type BlankSeg = { type: "blank"; answers: string[]; index: number }
type Segment = CodeSeg | BlankSeg
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
const segments = computed<Segment[]>(() => {
const blanks: string[][] = []
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
@@ -25,18 +17,7 @@ const segments = computed<Segment[]>(() => {
return `____${blanks.length - 1}____`
})
const lang =
props.lang === "python" ? "python" : props.lang === "c" ? "c" : null
let highlighted: string
if (lang) {
try {
highlighted = hljs.highlight(markedCode, { language: lang }).value
} catch {
highlighted = escapeHtml(markedCode)
}
} else {
highlighted = escapeHtml(markedCode)
}
const highlighted = highlight(markedCode, props.lang)
const parts = highlighted.split(/____(\d+)____/)
const result: Segment[] = []
+1 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseGroupData)
@@ -9,15 +10,6 @@ const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表
const dragIdx = ref<number | null>(null)
const submitted = ref(false)
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function init() {
order.value = shuffle(data.value.items.map((_, i) => i))
placement.value = Array(data.value.items.length).fill(-1)
+1 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
@@ -20,15 +21,6 @@ const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原
const selectedLeft = ref<number | null>(null)
const submitted = ref(false)
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function init() {
const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
+14 -100
View File
@@ -1,11 +1,8 @@
<script setup lang="ts">
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
import type { Exercise, ExerciseSortData } from "utils/types"
hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseSortData)
@@ -15,21 +12,16 @@ type LineItem = { originalIdx: number; text: string }
const lines = ref<LineItem[]>([])
const submitted = ref(false)
function shuffle(arr: LineItem[]): LineItem[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
const isCorrect = a.every((item, i) => item.originalIdx === i)
if (isCorrect && a.length > 1) [a[0], a[1]] = [a[1], a[0]]
return a
}
function init() {
lines.value = shuffle(
const shuffled = shuffle(
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
)
// 打乱后若恰好与原顺序一致,交换前两项,避免一进入就是已解出状态
const isCorrect = shuffled.every((item, i) => item.originalIdx === i)
if (isCorrect && shuffled.length > 1) {
;[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]]
}
lines.value = shuffled
submitted.value = false
}
@@ -69,35 +61,9 @@ function reset() {
init()
}
function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
const lineHtmlMap = computed<Record<number, string>>(() => {
const rawLines = data.value.lines
const map: Record<number, string> = {}
const lang =
props.lang === "python" ? "python" : props.lang === "c" ? "c" : null
if (lang) {
try {
const result = hljs.highlight(rawLines.join("\n"), {
language: lang,
}).value
result.split("\n").forEach((html, i) => {
map[i] = html
})
} catch {
// fall through
}
}
rawLines.forEach((text, i) => {
if (map[i] === undefined) map[i] = escapeHtml(text)
})
return map
})
const lineHtml = computed<string[]>(() =>
highlightLines(data.value.lines, props.lang),
)
</script>
<template>
@@ -143,7 +109,7 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
@drop="onDrop(idx)"
>
<span style="color: #bbb; cursor: grab"></span>
<span v-html="lineHtmlMap[line.originalIdx]" style="white-space: pre" />
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" />
</div>
</n-space>
@@ -162,55 +128,3 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
</n-space>
</n-card>
</template>
<style>
.hljs-keyword,
.hljs-operator,
.hljs-selector-tag {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-template-literal {
color: #032f62;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-number,
.hljs-literal {
color: #005cc5;
}
.hljs-built_in,
.hljs-title.function_,
.hljs-class .hljs-title {
color: #6f42c1;
}
.dark .hljs-keyword,
.dark .hljs-operator,
.dark .hljs-selector-tag {
color: #c678dd;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-template-literal {
color: #98c379;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #7f848e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #e5c07b;
}
.dark .hljs-built_in,
.dark .hljs-title.function_,
.dark .hljs-class .hljs-title {
color: #61afef;
}
</style>
+1 -2
View File
@@ -1,8 +1,7 @@
import type { Exercise } from "utils/types"
type Segment =
| { type: "md"; content: string }
| { type: "exercise"; exercise: Exercise }
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
export function parseExercises(
content: string,
+9
View File
@@ -0,0 +1,9 @@
// Fisher–Yates 洗牌,返回新数组,不修改原数组
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
+36 -11
View File
@@ -57,7 +57,7 @@
content-style="height: calc(100% - 44px); padding: 0;"
>
<CodeEditor
language="Python3"
:language="editorLanguage"
v-model="tutorial.code"
height="100%"
/>
@@ -102,7 +102,7 @@
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor language="Python3" v-model="tutorial.code" />
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane>
</n-tabs>
@@ -128,16 +128,23 @@
</n-button>
</n-flex>
</template>
<n-empty
v-if="isEmpty"
description="该教程还没有公开"
style="margin-top: 80px"
/>
</div>
</template>
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise } from "utils/types"
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"),
@@ -150,21 +157,32 @@ const isDark = useDark()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const step = computed(() => {
if (!route.params.step || !route.params.step.length) return 1
return parseInt(route.params.step[0])
const value = route.params.step as string | undefined
if (!value) return 1
return parseInt(value)
})
const type = computed<"python" | "c">(() =>
route.params.type === "c" ? "c" : "python",
)
const tutorial = ref<Partial<Tutorial>>({
id: 0,
title: "",
content: "",
code: "",
})
const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([])
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
const isEmpty = ref(false)
const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value),
@@ -175,7 +193,9 @@ const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push("/learn/" + lessonNumber.toString().padStart(2, "0"))
router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
)
}
function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1)
@@ -185,9 +205,10 @@ function goToNextLesson() {
}
async function init() {
const res1 = await getTutorials()
const res1 = await getTutorials(type.value)
titles.value = res1.data
if (titles.value.length === 0) return
isEmpty.value = titles.value.length === 0
if (isEmpty.value) return
const id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([
getTutorial(id),
@@ -195,10 +216,11 @@ async function init() {
])
if (res2.status === "fulfilled") tutorial.value = res2.value.data
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
}
watch(
() => route.params.step,
() => [route.params.type, route.params.step],
async () => {
if (route.name === "learn") init()
},
@@ -207,8 +229,11 @@ watch(
</script>
<style scoped>
.learn-container {
height: calc(100vh - 138px);
/* 桌面端固定高度,让目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 138px);
}
}
.learn-grid {
+91 -46
View File
@@ -17,7 +17,6 @@ import { useUserStore } from "shared/store/user"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import IconButton from "shared/components/IconButton.vue"
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
@@ -72,6 +71,71 @@ const showGoSubmissionButton = computed(() => {
else return false
})
const menuOptions = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = []
// 移动端额外收纳桌面端常驻的两项
if (!isDesktop.value) {
if (showGoSubmissionButton.value) {
options.push({
label: "提交信息",
key: "submissions",
})
}
if (userStore.isTeacherOrAbove) {
options.push({
label: "课堂统计",
key: "statistics",
})
}
}
if (codeStore.code.language !== "Flowchart") {
if (codeStore.code.language !== "SQL") {
options.push({
label: "去自测猫",
key: "testcat",
})
}
options.push({
label: "复制代码",
key: "copy",
})
options.push({
label: "重置代码",
key: "reset",
})
}
if (isDesktop.value && userStore.isSuperAdmin) {
options.push({
label: "编辑题目",
key: "edit",
})
}
return options
})
const handleMenuSelect = (key: string) => {
switch (key) {
case "submissions":
goSubmissions()
break
case "statistics":
statisticPanel.value = true
break
case "testcat":
goTestCat()
break
case "copy":
copy()
break
case "reset":
reset()
break
case "edit":
goEdit()
break
}
}
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
label: () =>
h(NFlex, { align: "center" }, () => [
@@ -160,59 +224,40 @@ onMounted(() => {
<SubmitCode v-else />
<IconButton
v-if="showGoSubmissionButton"
icon="streamline-ultimate-color:task-list-text-1"
tip="提交信息"
<n-button
v-if="isDesktop && showGoSubmissionButton"
:size="buttonSize"
@click="goSubmissions"
/>
>
提交信息
</n-button>
<IconButton
v-if="userStore.isTeacherOrAbove"
icon="streamline-ultimate-color:analytics-pie-2"
tip="统计信息"
<n-button
v-if="isDesktop && userStore.isTeacherOrAbove"
:size="buttonSize"
@click="statisticPanel = true"
/>
>
课堂统计
</n-button>
<template v-if="codeStore.code.language !== 'Flowchart'">
<IconButton
v-if="codeStore.code.language !== 'SQL'"
icon="streamline-ultimate-color:business-lucky-cat"
tip="自测猫"
@click="goTestCat"
/>
<IconButton
icon="streamline-ultimate-color:copy-paste-1"
tip="复制代码"
@click="copy"
/>
<IconButton
icon="streamline-ultimate-color:synchronize-arrow"
tip="重置代码"
@click="reset"
/>
</template>
<IconButton
v-if="isDesktop && userStore.isSuperAdmin"
icon="streamline-ultimate-color:common-file-edit"
tip="编辑题目"
@click="goEdit"
/>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单移动端再加上提交信息 / 课堂统计 -->
<n-dropdown
v-if="menuOptions.length"
trigger="click"
:options="menuOptions"
@select="handleMenuSelect"
>
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showSyncFeature">
<IconButton
:icon="
syncEnabled
? 'streamline-ultimate-color:wifi-off'
: 'streamline-ultimate-color:wifi-signal-2'
"
:tip="syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF"
<n-button
:size="buttonSize"
:type="syncEnabled ? 'warning' : 'default'"
@click="toggleSync"
/>
>
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
</n-button>
<!-- 同步状态标签 -->
<template v-if="isConnected">
@@ -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>
+142 -65
View File
@@ -12,6 +12,7 @@ import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue"
type Sample = Problem["samples"][number] & {
id: number
@@ -30,6 +31,18 @@ const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sql_config)
const sqlDisplay = computed(() => problem.value?.sql_display ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
})
const sqlChangedTables = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "changed_tables" in exp ? exp.changed_tables : []
})
const router = useRouter()
// 相似题目推荐
@@ -269,29 +282,79 @@ function type(status: ProblemStatus) {
:theme="isDark ? 'dark' : 'light'"
/>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
输入
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.input_description"
:theme="isDark ? 'dark' : 'light'"
/>
<template v-if="!isSQL">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
输入
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.input_description"
:theme="isDark ? 'dark' : 'light'"
/>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.output_description"
:theme="isDark ? 'dark' : 'light'"
/>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.output_description"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<template v-if="isSQL && sqlDisplay">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="devicon:sqlite"></Icon>
数据表
</n-flex>
</p>
<div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
期望结果
</n-flex>
</p>
<template v-if="sqlExpectedQuery">
<SQLDataTable
:columns="sqlExpectedQuery.columns"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote">
结果顺序不限
</p>
</template>
<div v-for="t in sqlChangedTables" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name}` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</template>
<div v-if="problem.hint">
<p class="title" :style="style">
@@ -334,50 +397,52 @@ function type(status: ProblemStatus) {
</div>
</div>
<div v-for="(sample, index) of samples" :key="index">
<n-flex align="center">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</n-flex>
</p>
<n-button
size="small"
:type="type(sample.status)"
@click="test(sample, index)"
<template v-if="!isSQL">
<div v-for="(sample, index) of samples" :key="index">
<n-flex align="center">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</n-flex>
</p>
<n-button
size="small"
:type="type(sample.status)"
@click="test(sample, index)"
>
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
<n-descriptions
bordered
:column="2"
label-style="width: 50%; min-width: 100px"
>
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
<n-descriptions
bordered
:column="2"
label-style="width: 50%; min-width: 100px"
>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
</template>
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
</template>
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
<n-descriptions-item label="运行结果" v-if="sample.msg">
<div class="testcase">{{ sample.msg }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
</template>
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
</template>
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
<n-descriptions-item label="运行结果" v-if="sample.msg">
<div class="testcase">{{ sample.msg }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
</template>
<div v-if="problem.source">
<p class="title" :style="style">
@@ -470,4 +535,16 @@ function type(status: ProblemStatus) {
font-size: 13px;
opacity: 0.65;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: Monaco, Consolas, monospace;
}
.sqlNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style>
@@ -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>
+2 -2
View File
@@ -16,8 +16,8 @@ const color = computed(() => {
<template>
<n-icon v-if="showIcon" :color="color">
<Icon icon="ph:check" v-if="status === 'passed'"></Icon>
<Icon icon="ph:minus" v-if="status === 'failed'"></Icon>
<Icon icon="ph:check-bold" v-if="status === 'passed'"></Icon>
<Icon icon="ph:minus-bold" v-if="status === 'failed'"></Icon>
</n-icon>
</template>
@@ -77,10 +77,7 @@ const columns: DataTableColumn<Submission>[] = [
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: renderTableTitle(
"语言",
"streamline-ultimate-color:earth-pin-2",
),
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
key: "language",
width: 100,
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { SQLDisplayColumn } from "utils/types"
defineProps<{
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
totalRows?: number
truncated?: boolean
}>()
</script>
<template>
<n-table class="sqlTable" size="small" :single-line="false">
<thead>
<tr>
<th v-for="(col, i) in columns" :key="i">
{{ col.name }}
<span v-if="col.type" class="colType">{{ col.type }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<td :colspan="columns.length" class="nullCell">空表</td>
</tr>
<tr v-for="(row, i) in rows" :key="i">
<td v-for="(v, j) in row" :key="j" :class="{ nullCell: v === null }">
{{ v === null ? "NULL" : v }}
</td>
</tr>
</tbody>
</n-table>
<p v-if="truncated" class="truncNote">
{{ totalRows }} 仅展示前 {{ rows.length }}
</p>
</template>
<style scoped>
.sqlTable {
margin-bottom: 8px;
}
.colType {
font-size: 12px;
opacity: 0.55;
margin-left: 4px;
font-weight: normal;
}
.nullCell {
opacity: 0.45;
font-style: italic;
}
.truncNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style>
@@ -35,10 +35,12 @@ const msg = computed(() => {
let msg = ""
const result = props.submission.result
// 编译错误或运行时错误时给出提示
// 编译错误或运行时错误时给出提示
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
if (
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
(result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error) &&
props.submission.language !== "SQL"
) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
}
@@ -175,7 +177,7 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<n-icon
:color="rule.passed ? theme.successColor : theme.errorColor"
>
<Icon :icon="rule.passed ? 'ph:check' : 'ph:x-bold'" />
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
</n-icon>
<span>{{ rule.description }}</span>
</n-flex>
+30 -11
View File
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import {
formatCode,
getComment,
getReaction,
submitCode,
updateProblemSetProgress,
} from "oj/api"
@@ -17,11 +17,14 @@ import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
import {
checkPythonSyntax,
prefetchPythonSyntaxChecker,
} from "oj/problem/utils/pythonSyntaxCheck"
// ==================== 异步组件 ====================
const ProblemComment = defineAsyncComponent(
() => import("./ProblemComment.vue"),
const ProblemReaction = defineAsyncComponent(
() => import("./ProblemReaction.vue"),
)
// ==================== 基础状态 ====================
@@ -37,6 +40,10 @@ const router = useRouter()
const [commentPanel] = useToggle()
const message = useMessage()
function closeCommentPanel() {
commentPanel.value = false
}
const { isDesktop } = useBreakpoints()
// ==================== 烟花效果 ====================
@@ -50,6 +57,16 @@ const showResult = ref(false)
const isFormatting = 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, {
controls: true,
@@ -59,8 +76,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
// ==================== AC后显示评论框 ====================
const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => {
const res = await getComment(problem.value!.id)
if (!res.data) {
const res = await getReaction(problem.value!.id)
if (res.data.mine === null) {
commentPanel.value = true
}
},
@@ -99,16 +116,16 @@ async function submit() {
// 0. Python3 语法检测
if (codeStore.code.language === "Python3") {
const syntaxError = checkPythonSyntax(codeStore.code.value)
const syntaxError = await checkPythonSyntax(codeStore.code.value)
if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
return
}
}
// 0.5 提交前自动格式化(Python3 用 ruffC/C++ 用 clang-format
// 0.5 提交前自动格式化(Python3 用 ruffC/C++ 用 clang-formatSQL 用 sqlparse
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
if (["python", "c", "cpp"].includes(formatLang)) {
if (["python", "c", "cpp", "sql"].includes(formatLang)) {
isFormatting.value = true
try {
const res = await formatCode({
@@ -246,11 +263,13 @@ watch(
<!-- 评价弹窗 -->
<n-modal
preset="card"
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
title="恭喜你成功提交,说说你对这道题的感受吧"
:mask-closable="false"
:closable="false"
:close-on-esc="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel"
>
<ProblemComment :showStatistics="false" />
<ProblemReaction @submitted="closeCommentPanel" />
</n-modal>
</template>
+107 -57
View File
@@ -1,4 +1,5 @@
import confetti from "canvas-confetti"
import { getCurrentScope, onScopeDispose } from "vue"
// 表情图标取自 @iconify 的 streamline-emojis 图标集,与项目内 <Icon icon="streamline-emojis:..."> 用法保持一致
const EMOJI_SVGS = [
@@ -67,6 +68,31 @@ export function useFireworks() {
// 提前预加载表情包位图,避免首次触发效果8时出现解码延迟
loadEmojiShapes()
// 动画期间创建的定时器,组件卸载时统一清理,避免离开页面后动画继续
const timers = new Set<ReturnType<typeof setTimeout>>()
let disposed = false
function stopTimer(id: ReturnType<typeof setTimeout>) {
clearInterval(id)
timers.delete(id)
}
function runLater(fn: () => void, ms: number) {
const id = setTimeout(() => {
timers.delete(id)
fn()
}, ms)
timers.add(id)
}
if (getCurrentScope()) {
onScopeDispose(() => {
disposed = true
timers.forEach((id) => clearInterval(id))
timers.clear()
})
}
/**
* 触发随机烟花效果
*/
@@ -80,12 +106,11 @@ export function useFireworks() {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
}
const interval: any = setInterval(() => {
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now()
if (timeLeft <= 0) return clearInterval(interval)
if (timeLeft <= 0) return stopTimer(interval)
const particleCount = 50 * (timeLeft / duration)
confetti({
@@ -101,30 +126,31 @@ export function useFireworks() {
colors: ["#ff6b6b", "#ffd93d", "#6bcf7f", "#4ecdc4", "#a29bfe"],
})
}, 250)
timers.add(interval)
},
// 效果2: 星星雨
() => {
const count = 10
const defaults = {
origin: { y: 0.7 },
shapes: ["star"],
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
}
const animationEnd = Date.now() + 2000
const interval = setInterval(() => {
if (Date.now() >= animationEnd) return stopTimer(interval)
function fire(particleRatio: number, opts: any) {
confetti({
...defaults,
...opts,
particleCount: Math.floor(200 * particleRatio),
particleCount: 12,
angle: 270,
spread: 100,
startVelocity: 12,
gravity: 0.6,
decay: 0.96,
ticks: 200,
scalar: 1.2,
shapes: ["star"],
origin: { x: Math.random(), y: 0 },
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
})
}
fire(0.25, { spread: 26, startVelocity: 55 })
fire(0.2, { spread: 60 })
fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 })
fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 })
fire(0.1, { spread: 120, startVelocity: 45 })
}, 150)
timers.add(interval)
},
// 效果3: 爆炸波浪
@@ -134,7 +160,7 @@ export function useFireworks() {
}
for (let i = 0; i < 5; i++) {
setTimeout(() => {
runLater(() => {
confetti({
angle: randomInRange(55, 125),
spread: randomInRange(50, 70),
@@ -150,7 +176,14 @@ export function useFireworks() {
() => {
const end = Date.now() + 2000
const colors = ["#bb0000", "#ffffff"]
const colors = [
"#ff595e",
"#ff924c",
"#ffca3a",
"#8ac926",
"#1982c4",
"#6a4c93",
]
const frame = () => {
confetti({
@@ -168,7 +201,7 @@ export function useFireworks() {
colors: colors,
})
if (Date.now() < end) {
if (Date.now() < end && !disposed) {
requestAnimationFrame(frame)
}
}
@@ -181,9 +214,9 @@ export function useFireworks() {
const duration = 2500
const animationEnd = Date.now() + duration
const interval: any = setInterval(() => {
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now()
if (timeLeft <= 0) return clearInterval(interval)
if (timeLeft <= 0) return stopTimer(interval)
const particleCount = 50
confetti({
@@ -205,6 +238,7 @@ export function useFireworks() {
],
})
}, 200)
timers.add(interval)
},
// 效果6: 炮竹齐鸣
@@ -252,29 +286,32 @@ export function useFireworks() {
// 效果7: 螺旋上升
() => {
const defaults = {
spread: 360,
ticks: 100,
gravity: 0,
decay: 0.94,
startVelocity: 30,
}
const steps = 16
const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
function shoot() {
confetti({
...defaults,
particleCount: 50,
scalar: 1.2,
shapes: ["circle", "square"],
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
})
}
for (let step = 0; step < steps; step++) {
runLater(() => {
const progress = step / (steps - 1)
const phase = step * (Math.PI / 4)
setTimeout(shoot, 0)
setTimeout(shoot, 100)
setTimeout(shoot, 200)
setTimeout(shoot, 300)
setTimeout(shoot, 400)
confetti({
particleCount: 7,
angle: 90 + Math.cos(phase) * 18,
spread: 25,
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: 表情包烟花
@@ -282,6 +319,7 @@ export function useFireworks() {
loadEmojiShapes().then((emojiShapes) => {
confetti({
shapes: emojiShapes,
flat: true,
scalar: 6,
particleCount: 18,
spread: 360,
@@ -298,7 +336,7 @@ export function useFireworks() {
const colors = ["#ff9a9e", "#fad0c4", "#fbc2eb", "#a18cd1", "#fad961"]
for (let angle = 0; angle < 360; angle += 30) {
setTimeout(
runLater(
() => {
confetti({
particleCount: 15,
@@ -316,16 +354,28 @@ export function useFireworks() {
// 效果10: 礼花瀑布
() => {
confetti({
particleCount: 150,
spread: 180,
startVelocity: 0,
gravity: 1.2,
decay: 0.95,
ticks: 300,
origin: { x: 0.5, y: 0 },
colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"],
})
const animationEnd = Date.now() + 1800
const colors = ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"]
const interval = setInterval(() => {
if (Date.now() >= animationEnd) return stopTimer(interval)
for (let column = 0; column < 6; column++) {
confetti({
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)
},
]
@@ -2,7 +2,8 @@ import { ref, computed, watch, onUnmounted } from "vue"
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
import { getSubmission } from "oj/api"
import { SubmissionStatus } from "utils/constants"
import type { Submission } from "utils/types"
import type { PendingAchievement, Submission } from "utils/types"
import { useAchievementStore } from "shared/store/achievement"
import {
useSubmissionWebSocket,
type SubmissionUpdate,
@@ -45,6 +46,20 @@ export function useSubmissionMonitor() {
// ==================== WebSocket 处理 ====================
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
// push_to_user 复用了 submission_update 这个 channel handler
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
const frame = data as unknown as {
type: string
achievements?: PendingAchievement[]
}
if (frame.type === "achievement_unlocked") {
useAchievementStore().enqueue(frame.achievements ?? [])
return
}
if (frame.type !== "submission_update") {
return
}
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
if (data.submission_id !== submissionId.value) {
+5 -5
View File
@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
const ProblemSubmission = defineAsyncComponent(
() => import("./components/ProblemSubmission.vue"),
)
const ProblemComment = defineAsyncComponent(
() => import("./components/ProblemComment.vue"),
const ProblemReaction = defineAsyncComponent(
() => import("./components/ProblemReaction.vue"),
)
const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"),
@@ -174,7 +174,7 @@ watch(
tab="题目点评"
:disabled="!!problemSetId"
>
<ProblemComment />
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
@@ -226,7 +226,7 @@ watch(
tab="题目点评"
:disabled="!!problemSetId"
>
<ProblemComment />
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
@@ -266,7 +266,7 @@ watch(
tab="点评"
:disabled="!!problemSetId"
>
<ProblemComment />
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
+8 -2
View File
@@ -160,12 +160,18 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: renderTableTitle("编号", "streamline-ultimate-color:board-game-dice-1"),
title: renderTableTitle(
"编号",
"streamline-ultimate-color:board-game-dice-1",
),
key: "_id",
width: 100,
},
{
title: renderTableTitle("题目", "streamline-ultimate-color:fruit-watermelon"),
title: renderTableTitle(
"题目",
"streamline-ultimate-color:fruit-watermelon",
),
key: "title",
minWidth: 200,
render: (row) => h(ProblemListTitle, { problem: row }),
+36 -4
View File
@@ -1,15 +1,47 @@
// @ts-ignore - skulpt has no type definitions
import Sk from "skulpt"
export interface PythonSyntaxError {
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 代码中的语法错误。
* 只编译不执行,不受 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: () => {} })
try {
Sk.compile(code, "prog.py", "exec")
+91 -42
View File
@@ -110,7 +110,11 @@ async function analyzeSingleClassWithAI() {
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
},
onMessage(payload) {
const parsed = payload as { type?: string; content?: string; message?: string }
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
@@ -194,17 +198,30 @@ const columns: DataTableColumn<Rank>[] = [
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 200,
width: 240,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () =>
router.push("/achievement?name=" + row.user.username),
},
() => "🏆",
),
]),
},
{
title: renderTableTitle(
@@ -221,7 +238,10 @@ const columns: DataTableColumn<Rank>[] = [
align: "center",
},
{
title: renderTableTitle("提交数", "streamline-ultimate-color:space-rocket-earth"),
title: renderTableTitle(
"提交数",
"streamline-ultimate-color:space-rocket-earth",
),
key: "submission_number",
width: 120,
align: "center",
@@ -371,32 +391,44 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
{
title: "用户名",
key: "username",
width: 200,
width: 240,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.username),
},
() =>
row.rank === myRank.value
? h(
NFlex,
{ align: "flex-end" },
{
default: () => [
h("span", {}, row.username),
h(Icon, {
width: 20,
icon: "fluent-emoji:person-raising-hand",
}),
],
},
)
: row.username,
),
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.username),
},
() =>
row.rank === myRank.value
? h(
NFlex,
{ align: "flex-end" },
{
default: () => [
h("span", {}, row.username),
h(Icon, {
width: 20,
icon: "fluent-emoji:person-raising-hand",
}),
],
},
)
: row.username,
),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () => router.push("/achievement?name=" + row.username),
},
() => "🏆",
),
]),
},
{
title: "已解决",
@@ -606,7 +638,10 @@ watch(
class="stat-avg-ac"
>
<template #suffix>
<Icon icon="streamline-ultimate-color:analytics-pie-2" width="20" />
<Icon
icon="streamline-ultimate-color:analytics-pie-2"
width="20"
/>
</template>
</n-statistic>
</n-gi>
@@ -618,7 +653,10 @@ watch(
class="stat-median-ac"
>
<template #suffix>
<Icon icon="streamline-ultimate-color:cursor-target-1" width="20" />
<Icon
icon="streamline-ultimate-color:cursor-target-1"
width="20"
/>
</template>
</n-statistic>
</n-gi>
@@ -630,7 +668,10 @@ watch(
class="stat-total-submission"
>
<template #suffix>
<Icon icon="streamline-ultimate-color:common-file-text" width="20" />
<Icon
icon="streamline-ultimate-color:common-file-text"
width="20"
/>
</template>
</n-statistic>
</n-gi>
@@ -736,7 +777,12 @@ watch(
</n-space>
</n-card>
<n-flex justify="center" align="center" :size="12" style="margin-top: 12px">
<n-flex
justify="center"
align="center"
:size="12"
style="margin-top: 12px"
>
<n-tag type="success" size="large">
综合分: {{ classDetailData.composite_score.toFixed(1) }}
</n-tag>
@@ -769,7 +815,10 @@ watch(
>
<n-spin :show="classDetailAiLoading" :delay="50">
<div style="min-height: 200px">
<MdPreview v-if="classDetailAiContent" :model-value="classDetailAiContent" />
<MdPreview
v-if="classDetailAiContent"
:model-value="classDetailAiContent"
/>
<n-flex
v-else-if="!classDetailAiLoading"
align="center"
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props {
type: "题目" | "用户"
@@ -10,12 +11,14 @@ const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"])
// kstest ks
//
const showFilterClass = computed(() => {
return props.type === "用户" && props.username?.startsWith("ks")
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
})
function filterClass() {
const match = props.username!.match(/^ks\d{3,4}/)
const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : ""
if (!classname) return
emits("filterClass", classname)
+5 -5
View File
@@ -267,10 +267,7 @@ const columns = computed(() => {
),
},
{
title: renderTableTitle(
"语言",
"streamline-ultimate-color:earth-pin-2",
),
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
key: "language",
minWidth: 120,
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
@@ -347,7 +344,10 @@ const flowchartColumns = computed(() => {
),
},
{
title: renderTableTitle("评分", "streamline-ultimate-color:analytics-bars-3d"),
title: renderTableTitle(
"评分",
"streamline-ultimate-color:analytics-bars-3d",
),
key: "ai_score",
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
},
+82 -78
View File
@@ -4,9 +4,10 @@ import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions"
import type { Profile, UserBadge as UserBadgeType } from "utils/types"
import { getMetrics, getUserBadges } from "../api"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
import type { AchievementSummary, Profile } from "utils/types"
import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useUserStore } from "shared/store/user"
const route = useRoute()
@@ -18,7 +19,7 @@ const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("")
const toLatestAt = ref("")
const learnDuration = ref("")
const userBadges = ref<GroupedBadge[]>([])
const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false)
@@ -58,74 +59,23 @@ const hasMoreProblems = computed(
() => 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() {
toggle(true)
try {
const res = await getProfile(route.query.name as string)
profile.value = res.data
const acm = res.data.acm_problems_status.problems || {}
const oi = res.data.oi_problems_status.problems || {}
const ac: string[] = []
for (let problems of [acm, oi]) {
Object.keys(problems).forEach((id) => {
if (problems[id]["status"] === 0) {
ac.push(problems[id]["_id"])
}
})
}
Object.keys(acm).forEach((id) => {
if (acm[id]["status"] === 0) {
ac.push(acm[id]["_id"])
}
})
ac.sort()
problems.value = ac
const promises: Promise<{ data: any }>[] = []
if (profile.value.submission_number > 0) {
promises.push(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]
const metricsRes = await getMetrics(profile.value.user.id)
firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays(
@@ -137,14 +87,24 @@ async function init() {
metricsRes.data.latest,
)
}
// badges
userBadges.value = groupBadgesByIcon(results[1].data)
} finally {
toggle(false)
}
}
// promises results[0]/[1]
//
async function loadAchievementSummary() {
try {
const res = await getAchievementSummary(
(route.query.name as string) || undefined,
)
achievementSummary.value = res.data
} catch {
achievementSummary.value = null
}
}
const metrics = computed(() => {
if (loading.value) return []
return [
@@ -183,7 +143,10 @@ const metrics = computed(() => {
]
})
onMounted(init)
onMounted(() => {
init()
loadAchievementSummary()
})
</script>
<template>
<n-flex
@@ -225,14 +188,14 @@ onMounted(init)
<n-grid
v-if="profile && profile.submission_number > 0"
class="wrapper"
:cols="2"
:cols="isDesktop ? 2 : 1"
:x-gap="10"
:y-gap="10"
>
<n-gi v-for="item in metrics" :key="item.content">
<n-card hoverable>
<n-flex align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="50" />
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
<div>
<Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" />
@@ -247,21 +210,50 @@ onMounted(init)
</n-gi>
</n-grid>
<!-- 徽章展示卡片 -->
<!-- 成就摘要 -->
<n-card
v-if="!loading && profile && userBadges.length > 0"
v-if="!loading && profile && achievementSummary"
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 align="center" justify="space-between">
<n-flex align="center" :size="12">
<span class="achievement-title">
成就 {{ achievementSummary.unlocked }} /
{{ achievementSummary.total }}
</span>
<n-tag size="small" type="info">
{{ achievementSummary.percent }}%
</n-tag>
</n-flex>
<n-button
text
type="primary"
@click="
router.push({
path: '/achievement',
query: route.query.name ? { name: route.query.name } : {},
})
"
>
查看全部
</n-button>
</n-flex>
<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">
<template #trigger>
<span class="achievement-icon">
<AchievementIcon :icon="a.icon" :size="24" />
</span>
</template>
{{ a.name }}
</n-tooltip>
<n-text v-if="!achievementSummary.recent.length" depth="3">
还没有获得成就
</n-text>
</n-flex>
</n-card>
@@ -324,4 +316,16 @@ h2 {
word-wrap: break-word;
max-width: 100%;
}
.achievement-title {
font-weight: 600;
}
.achievement-recent {
margin-top: 10px;
}
.achievement-icon {
display: inline-flex;
align-items: center;
font-size: 24px;
cursor: default;
}
</style>
+21 -7
View File
@@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/user/index.vue"),
meta: { requiresAuth: true },
},
{
path: "achievement",
name: "achievement",
component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true },
},
{
path: "setting",
component: () => import("oj/user/setting.vue"),
@@ -93,7 +99,7 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true },
},
{
path: "learn/:step+",
path: "learn/:type/:step",
name: "learn",
component: () => import("oj/learn/index.vue"),
props: true,
@@ -165,6 +171,12 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/problem/list.vue"),
meta: { requiresProblemPermission: true },
},
{
path: "problem/tags",
name: "admin tag list",
component: () => import("admin/problem/tags.vue"),
meta: { requiresProblemPermission: true },
},
{
path: "problem/create",
name: "admin problem create",
@@ -245,12 +257,6 @@ export const admins: RouteRecordRaw = {
props: true,
meta: { requiresSuperAdmin: true },
},
{
path: "comment/list",
name: "admin comment list",
component: () => import("admin/communication/comments.vue"),
meta: { requiresSuperAdmin: true },
},
{
path: "message/list",
name: "admin message list",
@@ -295,6 +301,14 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/problemset/list.vue"),
meta: { requiresTeacherAdmin: true },
},
{
path: "achievement/list",
name: "admin achievement list",
// 后端 admin 接口是 super_admin_required,这里必须跟着用
// requiresSuperAdmin,否则教师能进页面但每个请求都 403
component: () => import("admin/achievement/list.vue"),
meta: { requiresSuperAdmin: true },
},
{
path: "problemset/create",
name: "admin problemset create",
-5
View File
@@ -9,7 +9,6 @@ export function signup(data: {
username: string
email: string
password: string
captcha: string
}) {
return http.post("register", data)
}
@@ -26,10 +25,6 @@ export function getProblemTagList() {
return http.get<Tag[]>("problem/tags")
}
export function getCaptcha() {
return http.get("captcha")
}
export function getHitokoto() {
return http.get("hitokoto")
}
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
// iconifynoto emoji SVG
// emoji
//
// icon URL
// https:// iconify URL
// icon emoji
const props = withDefaults(defineProps<{ icon: string; size?: number }>(), {
size: 32,
})
const kind = computed(() => {
const v = props.icon ?? ""
if (/^(https?:\/\/|\/|data:)/.test(v)) return "image"
if (v.includes(":")) return "iconify"
return "text"
})
</script>
<template>
<img
v-if="kind === 'image'"
:src="icon"
:width="size"
:height="size"
class="image"
alt=""
/>
<Icon
v-else-if="kind === 'iconify'"
:icon="icon"
:width="size"
:height="size"
/>
<span v-else class="fallback" :style="{ fontSize: `${size}px` }">
{{ icon }}
</span>
</template>
<style scoped>
.image {
object-fit: contain;
border-radius: 4px;
}
.fallback {
line-height: 1;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useAchievementStore } from "shared/store/achievement"
import { RARITY_COLOR } from "utils/constants"
const store = useAchievementStore()
const { current, queue } = storeToRefs(store)
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
let gapTimer: ReturnType<typeof setTimeout> | null = null
//
function playNext() {
const item = store.next()
if (!item) return
visible.value = true
timer = setTimeout(async () => {
visible.value = false
await store.markRead(item)
gapTimer = setTimeout(playNext, 400)
}, 3000)
}
watch(
() => queue.value.length,
(len) => {
if (len > 0 && !visible.value) playNext()
},
)
onUnmounted(() => {
if (timer) clearTimeout(timer)
if (gapTimer) clearTimeout(gapTimer)
})
</script>
<template>
<Transition name="slide">
<div
v-if="visible && current"
class="toast"
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
>
<div class="icon">
<AchievementIcon :icon="current.icon" :size="34" />
</div>
<div class="body">
<div class="label">
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}
</div>
<div class="name">{{ current.name }}</div>
<div class="desc">{{ current.description }}</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 3000;
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-radius: 10px;
border: 2px solid;
background: var(--n-color, rgba(24, 24, 28, 0.95));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
min-width: 260px;
}
.icon {
font-size: 34px;
}
.label {
font-size: 11px;
letter-spacing: 2px;
opacity: 0.6;
}
.name {
font-weight: 700;
margin-top: 2px;
}
.desc {
font-size: 12px;
opacity: 0.7;
margin-top: 2px;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.35s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateX(40px);
}
</style>
+26 -7
View File
@@ -3,17 +3,21 @@
v-if="!hiddenICP"
justify="center"
align="center"
:vertical="isMobile"
:size="isMobile ? 4 : 'medium'"
:wrap="false"
class="beian"
:class="{ 'beian--mobile': isMobile }"
>
<n-flex justify="center" align="center" :vertical="isMobile" size="small">
<n-text>&copy; 2022 - 2026 判题狗 保留所有权利</n-text>
<n-flex justify="center" align="center" :size="isMobile ? 4 : 'small'">
<n-text>{{ copyrightText }}</n-text>
<n-button text @click="goCC">CC BY-NC 4.0</n-button>
</n-flex>
<n-button text @click="goICP">浙ICP备2023044109号-1</n-button>
<n-button text @click="goPublicSecurity">
浙公网安备33100402331786号
</n-button>
<template v-if="!isMobile">
<n-button text @click="goICP">浙ICP备2023044109号-1</n-button>
<n-button text @click="goPublicSecurity">
浙公网安备33100402331786号
</n-button>
</template>
</n-flex>
</template>
<script lang="ts" setup>
@@ -25,6 +29,9 @@ const hiddenICP = computed(() =>
["problem", "contest problem"].includes(route.name as string),
)
const currentYear = new Date().getFullYear()
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
function goICP() {
window.open("https://beian.miit.gov.cn", "_blank")
}
@@ -47,4 +54,16 @@ function goPublicSecurity() {
.beian {
margin: 12px 0;
}
.beian--mobile {
font-size: 12px;
max-width: 100%;
overflow-x: auto;
padding: 0 8px;
}
.beian--mobile :deep(.n-button__content),
.beian--mobile :deep(.n-text) {
font-size: 12px;
}
</style>
+3
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror"
import {
@@ -34,6 +35,8 @@ const code = defineModel<string>("value")
const isDark = useDark()
const langExtension = computed(() => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp()
})
@@ -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>
+45 -9
View File
@@ -2,6 +2,7 @@
import { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal"
import { useScreenModeStore } from "shared/store/screenMode"
import { logout } from "../api"
@@ -17,15 +18,14 @@ const route = useRoute()
const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const isDark = useDark()
function toggleDark(event: MouseEvent) {
const { clientX: x, clientY: y } = event
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)
function toggleDark() {
const x = window.innerWidth / 2
const y = window.innerHeight / 2
const radius = Math.hypot(x, y)
if (!document.startViewTransition) {
isDark.value = !isDark.value
return
@@ -112,15 +112,44 @@ async function handleLogout() {
router.replace("/")
}
function handleToggleDemoMode() {
const entering = !userStore.demoMode
userStore.toggleDemoMode()
// 退
if (entering && route.path.startsWith("/admin")) {
router.push("/")
}
}
function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 20 })
}
function learnLink(type: "python" | "c") {
return `/learn/${type}/${learnStep.value[type].toString().padStart(2, "0")}`
}
const menus = computed<MenuOption[]>(() => [
{
label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }),
label: "自学",
key: "learn",
icon: renderIcon("fluent-emoji:books"),
children: [
{
label: () =>
h(
RouterLink,
{ to: learnLink("python") },
{ default: () => "Python" },
),
key: "learn-python",
},
{
label: () =>
h(RouterLink, { to: learnLink("c") }, { default: () => "C语言" }),
key: "learn-c",
},
],
},
{
label: () => h(RouterLink, { to: "/" }, { default: () => "题目" }),
@@ -175,7 +204,7 @@ const menus = computed<MenuOption[]>(() => [
},
])
const options: Array<DropdownOption | DropdownDividerOption> = [
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
{
label: "我的主页",
key: "home",
@@ -217,6 +246,13 @@ const options: Array<DropdownOption | DropdownDividerOption> = [
onClick: () => router.push("/ai-analysis"),
},
},
{
label: userStore.demoMode ? "退出演示" : "进入演示",
key: "demo-mode",
show: userStore.canToggleDemoMode,
icon: renderIcon("fluent-emoji:graduation-cap"),
props: { onClick: handleToggleDemoMode },
},
{ type: "divider" },
{
label: "退出",
@@ -224,7 +260,7 @@ const options: Array<DropdownOption | DropdownDividerOption> = [
icon: renderIcon("streamline-ultimate-color:coffee-cold"),
props: { onClick: handleLogout },
},
]
])
function goHome() {
router.push("/")
+19 -3
View File
@@ -7,7 +7,11 @@ interface Props {
page: number
}
const { total, limit: initialLimit = 10, page: initialPage = 1 } = defineProps<Props>()
const {
total,
limit: initialLimit = 10,
page: initialPage = 1,
} = defineProps<Props>()
const emit = defineEmits(["update:limit", "update:page"])
@@ -17,8 +21,20 @@ const limit = ref(initialLimit)
const page = ref(initialPage)
const sizes = [10, 30, 50]
watch(limit, () => emit("update:limit", limit))
watch(page, () => emit("update:page", page))
// emit emit ref ref
// 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>
<template>
+2 -38
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { getCaptcha, signup } from "../api"
import { signup } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
@@ -10,7 +10,6 @@ const {
signupForm: form,
signupLoading: isLoading,
signupError: msg,
captchaSrc,
} = storeToRefs(authStore)
const signupRef = useTemplateRef("signupRef")
@@ -31,9 +30,6 @@ const rules: FormRules = {
trigger: "blur",
},
],
captcha: [
{ required: true, message: "验证码必填", trigger: "blur", min: 1, max: 10 },
],
}
function goLogin() {
@@ -50,20 +46,15 @@ function submit() {
username: form.value.username,
email: form.value.email,
password: form.value.password,
captcha: form.value.captcha,
})
} catch (err: any) {
if (err.data === "Invalid captcha") {
authStore.setSignupError("验证码不正确")
} else if (err.data === "Username already exists") {
if (err.data === "Username already exists") {
authStore.setSignupError("用户名已存在")
} else if (err.data === "Email already exists") {
authStore.setSignupError("邮箱已存在")
} else {
authStore.setSignupError("无法注册")
}
getCaptchaSrc()
form.value.captcha = ""
} finally {
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>
<template>
@@ -134,18 +117,6 @@ watch(signupModalOpen, (v) => {
autocomplete="new-password"
/>
</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-form-item>
<n-space>
@@ -158,10 +129,3 @@ watch(signupModalOpen, (v) => {
</n-form>
</n-modal>
</template>
<style scoped>
.captcha {
height: 34px;
cursor: pointer;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { useStorage } from "@vueuse/core"
import { STORAGE_KEY } from "utils/constants"
export type TutorialType = "python" | "c"
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
// 否则导航栏的链接不会随学习进度更新
const learnStep = useStorage<Record<TutorialType, number>>(
STORAGE_KEY.LEARN_CURRENT_STEP,
{ python: 1, c: 1 },
)
export function useLearnProgress() {
return { learnStep }
}
+15
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,
)
}
+8 -9
View File
@@ -23,27 +23,26 @@ export const SYNC_ERROR_CODES = {
// 界面和通知文案
export const SYNC_MESSAGES = {
// 超管离开
SUPER_ADMIN_LEFT: (name: string) => `👋 超管 ${name} 已离开`,
SUPER_ADMIN_LEFT: (name: string) => `超管 ${name} 已离开`,
// 缺少超管
MISSING_SUPER_ADMIN: "⚠️ 协同编辑需要超管",
MISSING_SUPER_ADMIN: "协同编辑需要超管",
// 连接成功
SYNC_ACTIVE: "协同编辑已激活!",
SYNC_ACTIVE: "协同编辑已激活!",
// 连接断开
CONNECTION_LOST: "⚠️ 协同编辑已断开",
CONNECTION_LOST: "协同编辑已断开",
// 等待相关
WAITING_STUDENT: "正在等待学生加入...",
WAITING_ADMIN: "正在等待超管加入...",
WAITING_STUDENT: "正在等待学生加入...",
WAITING_ADMIN: "正在等待超管加入...",
// Form.vue 界面文案
SYNC_ON: "断开同步",
SYNC_OFF: "开启同步",
SYNCING_WITH: (name: string) => `🔗 ${name} 同步中`,
STUDENT_LEFT: (name?: string) =>
name ? `💡 ${name}已离开` : "💡 可以关闭同步",
SYNCING_WITH: (name: string) => `${name} 同步中`,
STUDENT_LEFT: (name?: string) => (name ? `${name}已离开` : "可以关闭同步"),
} as const
// 类型定义
+1 -4
View File
@@ -4,10 +4,7 @@ import { ref, onUnmounted, type Ref } from "vue"
* WebSocket
*/
export type ConnectionStatus =
| "disconnected"
| "connecting"
| "connected"
| "error"
"disconnected" | "connecting" | "connected" | "error"
/**
* WebSocket
+37 -4
View File
@@ -5,9 +5,11 @@ import type {
CompletionSource,
} from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import { useProblemStore } from "oj/store/problem"
import type { LANGUAGE } from "utils/types"
import { c } from "./c"
import { python } from "./python"
import { sql } from "./sql"
type ChineseCompletion = Pick<
Completion,
@@ -18,6 +20,31 @@ type ChineseCompletion = Pick<
const chineseAnnotations: Record<string, ChineseCompletion[]> = {
python,
c,
sql,
}
// SQL 题:当前题目的表名和字段名补全,数据来自 sql_display
function sqlSchemaCompletions(): Completion[] {
const tables = useProblemStore().problem?.sql_display?.tables ?? []
return tables.flatMap((table) => [
{
label: table.name,
detail: "数据表",
type: "class",
info: `字段:${table.columns
.map((col) => (col.type ? `${col.name} ${col.type}` : col.name))
.join(", ")}`,
boost: 110,
},
...table.columns.map((col) => ({
label: col.name,
detail: col.type
? `${table.name} 的字段 · ${col.type}`
: `${table.name} 的字段`,
type: "property",
boost: 105,
})),
])
}
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
@@ -27,10 +54,12 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
const word = context.matchBefore(/\w+/)
if (!word && !context.explicit) return null
// SQL 没有中文注释提示,关键字补全由 @codemirror/lang-sql 提供
if (language === "SQL") return null
const trulyLanguage = language.startsWith("Python") ? "python" : "c"
const trulyLanguage =
language === "SQL"
? "sql"
: language.startsWith("Python")
? "python"
: "c"
const completions: Completion[] = (
chineseAnnotations[trulyLanguage] || []
).map((completion) => {
@@ -65,6 +94,10 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
return completion
})
if (trulyLanguage === "sql") {
completions.push(...sqlSchemaCompletions())
}
return {
from: word ? word.from : context.pos,
options: completions,
+404
View File
@@ -0,0 +1,404 @@
export const sql = [
{
label: "SELECT",
detail: "查询数据",
type: "keyword",
info: "从表中查询数据,后面接要查询的列名,用 * 表示所有列。",
boost: 100,
apply: "SELECT ",
},
{
label: "FROM",
detail: "指定表",
type: "keyword",
info: "指定要查询的表,和 SELECT 搭配使用,如 SELECT * FROM 表名。",
boost: 98,
apply: "FROM ",
},
{
label: "WHERE",
detail: "筛选条件",
type: "keyword",
info: "按条件筛选行,只保留满足条件的数据,如 WHERE age > 18。",
boost: 96,
apply: "WHERE ",
},
{
label: "ORDER BY",
detail: "排序",
type: "keyword",
info: "按指定列排序,默认从小到大(ASC),加 DESC 表示从大到小。",
boost: 90,
apply: "ORDER BY ",
},
{
label: "GROUP BY",
detail: "分组",
type: "keyword",
info: "按指定列分组,常配合 COUNT、SUM 等聚合函数统计每组数据。",
boost: 88,
apply: "GROUP BY ",
},
{
label: "HAVING",
detail: "分组后筛选",
type: "keyword",
info: "对分组后的结果再筛选,WHERE 筛选行,HAVING 筛选组。",
boost: 86,
apply: "HAVING ",
},
{
label: "LIMIT",
detail: "限制条数",
type: "keyword",
info: "限制返回的行数,如 LIMIT 5 只取前 5 条,常和 ORDER BY 搭配。",
boost: 84,
apply: "LIMIT ",
},
{
label: "DISTINCT",
detail: "去重",
type: "keyword",
info: "去掉查询结果中的重复值,如 SELECT DISTINCT city FROM users。",
boost: 82,
apply: "DISTINCT ",
},
{
label: "AS",
detail: "起别名",
type: "keyword",
info: "给列或表起别名,让结果更易读,如 SELECT name AS 姓名。",
boost: 80,
apply: "AS ",
},
{
label: "JOIN",
detail: "连接表",
type: "keyword",
info: "把两张表按条件连接起来查询,需要用 ON 指定连接条件。",
boost: 78,
apply: "JOIN ",
},
{
label: "LEFT JOIN",
detail: "左连接",
type: "keyword",
info: "以左表为主连接右表,左表的行都保留,右表没匹配的补 NULL。",
boost: 76,
apply: "LEFT JOIN ",
},
{
label: "ON",
detail: "连接条件",
type: "keyword",
info: "指定两张表的连接条件,如 ON a.id = b.user_id,和 JOIN 搭配。",
boost: 74,
apply: "ON ",
},
{
label: "INSERT INTO",
detail: "插入数据",
type: "keyword",
info: "向表中插入新行,如 INSERT INTO 表名 (列1, 列2) VALUES (值1, 值2)。",
boost: 72,
apply: "INSERT INTO ",
},
{
label: "VALUES",
detail: "插入的值",
type: "keyword",
info: "和 INSERT INTO 搭配,写具体要插入的值,顺序要和列名对应。",
boost: 70,
apply: "VALUES ",
},
{
label: "UPDATE",
detail: "更新数据",
type: "keyword",
info: "修改表中已有的数据,配合 SET 设置新值,别忘了加 WHERE 限定范围。",
boost: 68,
apply: "UPDATE ",
},
{
label: "SET",
detail: "设置新值",
type: "keyword",
info: "和 UPDATE 搭配,指定要修改的列和新值,如 SET score = 90。",
boost: 66,
apply: "SET ",
},
{
label: "DELETE FROM",
detail: "删除数据",
type: "keyword",
info: "删除表中的行,一定要配合 WHERE 使用,否则会删掉整张表的数据。",
boost: 64,
apply: "DELETE FROM ",
},
{
label: "CREATE TABLE",
detail: "创建表",
type: "keyword",
info: "新建一张表,需要定义列名和类型,如 CREATE TABLE users (id INTEGER, name TEXT)。",
boost: 62,
apply: "CREATE TABLE ",
},
{
label: "DROP TABLE",
detail: "删除表",
type: "keyword",
info: "删除整张表(包括结构和数据),操作不可恢复,要谨慎使用。",
boost: 60,
apply: "DROP TABLE ",
},
{
label: "ALTER TABLE",
detail: "修改表结构",
type: "keyword",
info: "修改已有表的结构,比如添加列:ALTER TABLE 表名 ADD COLUMN 列名 类型。",
boost: 58,
apply: "ALTER TABLE ",
},
{
label: "AND",
detail: "并且",
type: "keyword",
info: "连接多个条件,全部成立才算满足,如 WHERE age > 18 AND city = '上海'。",
boost: 56,
apply: "AND ",
},
{
label: "OR",
detail: "或者",
type: "keyword",
info: "连接多个条件,任意一个成立就算满足。",
boost: 54,
apply: "OR ",
},
{
label: "NOT",
detail: "取反",
type: "keyword",
info: "对条件取反,如 NOT IN、NOT LIKE、IS NOT NULL。",
boost: 52,
apply: "NOT ",
},
{
label: "IN",
detail: "在列表中",
type: "keyword",
info: "判断值是否在给定列表中,如 WHERE city IN ('北京', '上海')。",
boost: 50,
apply: "IN ",
},
{
label: "BETWEEN",
detail: "在区间内",
type: "keyword",
info: "判断值是否在某个范围内(包含两端),如 BETWEEN 60 AND 100。",
boost: 48,
apply: "BETWEEN ",
},
{
label: "LIKE",
detail: "模糊匹配",
type: "keyword",
info: "模糊查询,% 匹配任意多个字符,_ 匹配单个字符,如 LIKE '张%'。",
boost: 46,
apply: "LIKE ",
},
{
label: "IS NULL",
detail: "是否为空",
type: "keyword",
info: "判断值是否为 NULL(空值),不能写 = NULL,要用 IS NULL。",
boost: 44,
},
{
label: "IS NOT NULL",
detail: "是否非空",
type: "keyword",
info: "判断值不为 NULL,常用于过滤掉缺失数据的行。",
boost: 42,
},
{
label: "ASC",
detail: "升序",
type: "keyword",
info: "排序时从小到大排列,是 ORDER BY 的默认方式,可以省略。",
boost: 40,
},
{
label: "DESC",
detail: "降序",
type: "keyword",
info: "排序时从大到小排列,如 ORDER BY score DESC 按分数从高到低。",
boost: 41,
},
{
label: "UNION",
detail: "合并结果",
type: "keyword",
info: "合并两个查询的结果并去重,两个查询的列数和类型要一致。",
boost: 38,
apply: "UNION ",
},
{
label: "CASE",
detail: "条件表达式",
type: "keyword",
info: "类似 if/else 的条件判断,语法 CASE WHEN 条件 THEN 值 ELSE 值 END。",
boost: 36,
apply: "CASE ",
},
{
label: "WHEN",
detail: "当条件成立",
type: "keyword",
info: "和 CASE 搭配,写判断条件,成立时返回 THEN 后面的值。",
boost: 34,
apply: "WHEN ",
},
{
label: "THEN",
detail: "返回值",
type: "keyword",
info: "和 WHEN 搭配,条件成立时返回的结果。",
boost: 32,
apply: "THEN ",
},
{
label: "ELSE",
detail: "否则",
type: "keyword",
info: "CASE 中所有 WHEN 都不成立时返回的默认值。",
boost: 30,
apply: "ELSE ",
},
{
label: "END",
detail: "结束 CASE",
type: "keyword",
info: "标记 CASE 表达式的结束,写 CASE 时不要漏掉。",
boost: 28,
},
{
label: "NULL",
detail: "空值",
type: "keyword",
info: "表示没有值(缺失),判断时要用 IS NULL / IS NOT NULL。",
boost: 26,
},
{
label: "COUNT",
detail: "统计行数",
type: "function",
info: "统计行数,COUNT(*) 统计所有行,COUNT(列名) 不统计 NULL。",
boost: 92,
apply: "COUNT()",
},
{
label: "SUM",
detail: "求和",
type: "function",
info: "对某一列求和,只能用于数字列,如 SUM(score)。",
boost: 87,
apply: "SUM()",
},
{
label: "AVG",
detail: "平均值",
type: "function",
info: "计算某一列的平均值,会自动忽略 NULL,如 AVG(score)。",
boost: 85,
apply: "AVG()",
},
{
label: "MAX",
detail: "最大值",
type: "function",
info: "求某一列的最大值,如 MAX(score) 找最高分。",
boost: 83,
apply: "MAX()",
},
{
label: "MIN",
detail: "最小值",
type: "function",
info: "求某一列的最小值,如 MIN(score) 找最低分。",
boost: 81,
apply: "MIN()",
},
{
label: "LENGTH",
detail: "字符串长度",
type: "function",
info: "返回字符串的字符个数,如 LENGTH(name)。",
boost: 79,
apply: "LENGTH()",
},
{
label: "UPPER",
detail: "转大写",
type: "function",
info: "把字符串中的字母转成大写,如 UPPER('abc') 得到 'ABC'。",
boost: 77,
apply: "UPPER()",
},
{
label: "LOWER",
detail: "转小写",
type: "function",
info: "把字符串中的字母转成小写,如 LOWER('ABC') 得到 'abc'。",
boost: 75,
apply: "LOWER()",
},
{
label: "SUBSTR",
detail: "截取子串",
type: "function",
info: "截取字符串的一部分,语法 SUBSTR(字符串, 起始位置, 长度),位置从 1 开始。",
boost: 73,
apply: "SUBSTR()",
},
{
label: "REPLACE",
detail: "替换字符串",
type: "function",
info: "把字符串中的内容替换成新内容,语法 REPLACE(字符串, 旧内容, 新内容)。",
boost: 71,
apply: "REPLACE()",
},
{
label: "ROUND",
detail: "四舍五入",
type: "function",
info: "按指定小数位四舍五入,如 ROUND(3.456, 2) 得到 3.46。",
boost: 69,
apply: "ROUND()",
},
{
label: "ABS",
detail: "绝对值",
type: "function",
info: "返回数字的绝对值,把负数变成正数,如 ABS(-5) 得到 5。",
boost: 67,
apply: "ABS()",
},
{
label: "IFNULL",
detail: "空值替代",
type: "function",
info: "如果第一个值是 NULL 就返回第二个值,如 IFNULL(score, 0) 把空分数当 0。",
boost: 65,
apply: "IFNULL()",
},
{
label: "TRIM",
detail: "去首尾空格",
type: "function",
info: "去掉字符串首尾的空格,常用于清理输入数据。",
boost: 63,
apply: "TRIM()",
},
]
+10 -10
View File
@@ -114,15 +114,6 @@ const options = computed<MenuOption[]>(() => {
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
),
key: "admin comment list",
},
{
label: () =>
h(
@@ -141,6 +132,15 @@ const options = computed<MenuOption[]>(() => {
),
key: "admin tutorial list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/achievement/list" },
{ default: () => "成就" },
),
key: "admin achievement list",
},
{
label: () =>
h(
@@ -164,10 +164,10 @@ const active = computed(() => {
if (path.startsWith("/admin/config")) return "admin config"
if (path.startsWith("/admin/problemset")) return "admin problemset list"
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
if (path.startsWith("/admin/problem/tags")) 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/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/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
+18
View File
@@ -4,6 +4,23 @@ import Header from "../components/Header.vue"
import Login from "../components/Login.vue"
import Signup from "../components/Signup.vue"
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
import AchievementToast from "../components/AchievementToast.vue"
import { useAchievementStore } from "shared/store/achievement"
import { useUserStore } from "shared/store/user"
const achievementStore = useAchievementStore()
const userStore = useUserStore()
const route = useRoute()
// WebSocket
//
watch(
() => route.path,
() => {
if (userStore.isAuthed) achievementStore.fetchPending()
},
{ immediate: true },
)
</script>
<template>
@@ -19,6 +36,7 @@ import LoginSummaryModal from "../components/LoginSummaryModal.vue"
<Login />
<Signup />
<LoginSummaryModal />
<AchievementToast />
<Beian />
</n-layout>
</template>
+64
View File
@@ -0,0 +1,64 @@
import {
getPendingAchievements,
markAchievementsRead,
} from "oj/achievement/api"
import type { PendingAchievement } from "utils/types"
/**
*
*
* UserAchievement.notified
* - pending
* - WebSocket
*
* WebSocket
*
*/
export const useAchievementStore = defineStore("achievement", () => {
const queue = ref<PendingAchievement[]>([])
const current = ref<PendingAchievement | null>(null)
// 成就和题单奖章的 id 来自两张不同的表,数值会重叠,
// 只按 id 去重会让奖章 5 把成就 5 挤掉
function keyOf(item: PendingAchievement) {
return `${item.kind ?? "achievement"}:${item.id}`
}
function enqueue(items: PendingAchievement[]) {
if (!items?.length) return
// 去重:WebSocket 推来的和 pending 拉来的可能是同一批
const known = new Set([
...queue.value.map(keyOf),
...(current.value ? [keyOf(current.value)] : []),
])
queue.value.push(...items.filter((i) => !known.has(keyOf(i))))
}
async function fetchPending() {
try {
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
const res = await getPendingAchievements()
enqueue(res.data ?? [])
} catch {
// 拉取失败静默处理,下次路由切换会再拉
}
}
function next() {
current.value = queue.value.shift() ?? null
return current.value
}
async function markRead(item: PendingAchievement) {
// 奖章不在 UserAchievement 表里,它的 id 传给标记接口会被当成成就 id,
// 把一个恰好同号、还没弹过的成就静默标记为已弹——那个奖杯就再也不会出现
if (item.kind === "badge") return
try {
await markAchievementsRead([item.id])
} catch {
// 标记失败下次会重复弹一次,可接受
}
}
return { queue, current, enqueue, fetchPending, next, markRead }
})
-15
View File
@@ -25,15 +25,11 @@ export const useAuthModalStore = defineStore("authModal", () => {
email: "",
password: "",
passwordAgain: "",
captcha: "",
})
const signupLoading = ref(false)
const signupError = ref("")
// ==================== 验证码 ====================
const captchaSrc = ref("")
// ==================== 模态框操作 ====================
/**
*
@@ -123,13 +119,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupError.value = ""
}
/**
*
*/
function setCaptchaSrc(src: string) {
captchaSrc.value = src
}
return {
// 模态框状态
loginModalOpen,
@@ -145,9 +134,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupLoading,
signupError,
// 验证码
captchaSrc,
// 模态框操作
openLoginModal,
closeLoginModal,
@@ -165,6 +151,5 @@ export const useAuthModalStore = defineStore("authModal", () => {
setSignupLoading,
setSignupError,
clearSignupError,
setCaptchaSrc,
}
})
+34 -11
View File
@@ -11,30 +11,49 @@ export const useUserStore = defineStore("user", () => {
const [isFinished] = useToggle(false)
const user = computed<User | null>(() => profile.value?.user ?? null)
const isAuthed = computed(() => !!user.value?.email)
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
const realIsSuperAdmin = computed(
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
)
const isAdminRole = computed(
() =>
user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
!demoMode.value &&
(user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
)
const isStudentAdmin = computed(
() => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
() => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
)
const isTeacherAdmin = computed(
() => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
() => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
)
const isTeacherOrAbove = computed(
() =>
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
)
const isSuperAdmin = computed(
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
!demoMode.value &&
(user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
)
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
const hasProblemPermission = computed(
() => user.value?.problem_permission !== PROBLEM_PERMISSION.NONE,
() =>
!demoMode.value &&
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE,
)
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
function toggleDemoMode() {
demoMode.value = !demoMode.value
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
}
const showSubmissions = computed(() => {
let flag = configStore.config.submission_list_show_all
if (isAdminRole.value) flag = true
@@ -51,6 +70,7 @@ export const useUserStore = defineStore("user", () => {
function clearProfile() {
profile.value = null
demoMode.value = false
storage.clear()
}
return {
@@ -63,6 +83,9 @@ export const useUserStore = defineStore("user", () => {
isTeacherOrAbove,
isSuperAdmin,
hasProblemPermission,
demoMode,
canToggleDemoMode,
toggleDemoMode,
isAuthed,
showSubmissions,
getMyProfile,
+83 -1
View File
@@ -1,4 +1,4 @@
import type { SUBMISSION_RESULT } from "./types"
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
export enum SubmissionStatus {
compile_error = -2,
@@ -150,6 +150,7 @@ export const STORAGE_KEY = {
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode",
}
export const DIFFICULTY = {
@@ -275,6 +276,44 @@ export enum ChartType {
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:1,12px 的稀有度标签根本看不清。取色见 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 = [
{ label: "本节课内", value: "hours:1" },
@@ -286,3 +325,46 @@ export const DURATION_OPTIONS = [
{ label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" },
] 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" },
]
+24 -1
View File
@@ -1,7 +1,14 @@
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types"
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 { customAlphabet } from "nanoid"
@@ -208,6 +215,22 @@ export function atou(base64: string): string {
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
-2
View File
@@ -12,7 +12,6 @@ export function usePermissions() {
canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin),
@@ -65,7 +64,6 @@ export function checkRoutePermission(routeName: string): boolean {
"admin announcement list",
"admin announcement create",
"admin announcement edit",
"admin comment list",
"admin message list",
"admin tutorial list",
"admin tutorial create",
+109 -56
View File
@@ -12,15 +12,6 @@ export interface Profile {
}
}
}
oi_problems_status: {
problems: {
[key: string]: {
_id: string
score: number
status: number
}
}
}
avatar: string
blog: null
mood: string
@@ -29,15 +20,11 @@ export interface Profile {
major: string
language: string
accepted_number: number
total_score: number
submission_number: number
}
export type UserAdminType =
| "Regular User"
| "Student Admin"
| "Teacher Admin"
| "Super Admin"
"Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
export interface User {
id: number
@@ -48,7 +35,6 @@ export interface User {
problem_permission: string
create_time: Date
last_login: Date
two_factor_auth: boolean
open_api: boolean
is_disabled: boolean
password?: string
@@ -72,23 +58,37 @@ export interface SQLConfig {
order_sensitive: boolean
}
export interface SQLDisplayColumn {
name: string
type?: string
}
export interface SQLDisplayTable {
name: string
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
total_rows: number
truncated: boolean
dropped?: boolean
}
export interface SQLDisplay {
tables: SQLDisplayTable[]
expected:
| {
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
total_rows: number
truncated: boolean
}
| { changed_tables: SQLDisplayTable[] }
}
export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
export type SUBMISSION_RESULT =
| -2
| -1
| 0
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
-2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
export type ProblemStatus = "passed" | "failed" | "not_test"
@@ -103,6 +103,12 @@ export interface Tag {
name: string
}
export interface AdminTag {
id: number
name: string
problem_count: number
}
export interface TestcaseUploadedReturns {
id: string
info: Testcase[]
@@ -134,17 +140,10 @@ export interface Problem {
last_update_time: null
time_limit: number
memory_limit: number
io_mode: {
input: string
output: string
io_mode: string
}
rule_type: string
difficulty: "Low" | "Mid" | "High"
source: string
prompt: string
answers: { language: LANGUAGE; code: string }[]
total_score: number
submission_number: number
accepted_number: number
statistic_info: { [key: string]: number }
@@ -173,9 +172,16 @@ export interface Problem {
// SQL 题配置(非 SQL 题为 null
sql_config?: SQLConfig | null
// SQL 题展示数据(后端保存题目时自动生成)
sql_display?: SQLDisplay | null
}
export type AdminProblem = Problem & AlterProblem
export type AdminProblem = Problem &
AlterProblem & {
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
top_reaction?: { type: ReactionKey; count: number } | null
}
interface AlterProblem {
test_case_id: string
@@ -193,7 +199,6 @@ type ExcludeKeys =
| "statistic_info"
| "accepted_number"
| "submission_number"
| "total_score"
export type BlankProblem = Omit<Problem, ExcludeKeys> &
AlterProblem & { id?: number }
@@ -225,6 +230,8 @@ export interface AdminProblemFiltered {
has_ast_rules: boolean
allow_flowchart: boolean
show_flowchart: boolean
// 比赛题目列表接口不返回这个字段
top_reaction?: { type: ReactionKey; count: number } | null
}
// 题单相关类型
@@ -486,7 +493,6 @@ export interface Rank {
}
}
}
oi_problems_status: {}
real_name: null | string
avatar: string
blog: null
@@ -496,7 +502,6 @@ export interface Rank {
major: null | string
language: null | string
accepted_number: number
total_score: number
submission_number: number
}
@@ -595,16 +600,20 @@ export interface CreateMessage {
message: string
}
export interface Comment {
id: number
problem: string
submission: string
content: string
description_rating: 1 | 2 | 3 | 4 | 5
difficulty_rating: 1 | 2 | 3 | 4 | 5
comprehensive_rating: 1 | 2 | 3 | 4 | 5
create_time: Date
user: SampleUser
export type ReactionKey =
| "too_easy"
| "too_hard"
| "confusing"
| "buggy"
| "learned"
| "interesting"
| "want_explain"
export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState {
mine: ReactionKey | null
counts: ReactionCounts | null
}
export interface Tutorial {
@@ -664,13 +673,7 @@ export interface ExerciseGroupData {
}
export type ExerciseType =
| "mcq"
| "sort"
| "fill"
| "match"
| "predict"
| "debug"
| "group"
"mcq" | "sort" | "fill" | "match" | "predict" | "debug" | "group"
export interface Exercise {
id: number
@@ -735,3 +738,53 @@ export interface DetailsData {
}
export type Grade = "S" | "A" | "B" | "C"
// ==================== 成就相关类型 ====================
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
export interface Achievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
hidden: boolean
// 隐藏成就未解锁时,后端已做掩码处理,以下四个字段为 null
metric: string | null
operator: "gte" | "lte" | null
threshold: number | null
unlocked: boolean
unlock_time: string | null
backfilled: boolean
progress: number | null
unlock_rate: number
}
export interface AchievementRarityStat {
rarity: AchievementRarity
label: string
total: number
unlocked: number
}
export interface PendingAchievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
// 弹窗队列里混着两种东西:全站成就和题单奖章。它们的 id 来自两张不同的表,
// 数值会重叠,所以去重和标记已读都必须带上 kind 一起判断。
// pending 接口只返回成就,不带这个字段,缺省按 achievement 处理。
kind?: "achievement" | "badge"
}
export interface AchievementSummary {
username: string
total: number
unlocked: number
percent: number
rarity: AchievementRarityStat[]
recent: PendingAchievement[]
}
+64 -1
View File
@@ -6,6 +6,69 @@ import AutoImport from "unplugin-auto-import/vite"
import Components from "unplugin-vue-components/vite"
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 模板的等价实现)
function injectMaxkb(maxkbUrl: string | undefined): Plugin {
return {
@@ -48,7 +111,7 @@ export default defineConfig(({ mode }) => {
legacy({
renderLegacyChunks: false,
modernTargets: "chrome>=90",
modernPolyfills: true,
modernPolyfills: polyfills,
}),
AutoImport({
imports: [