Compare commits

..
96 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
68 changed files with 4712 additions and 1707 deletions
+3 -2
View File
@@ -12,6 +12,7 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
include: include:
- name: debian - name: debian
@@ -23,9 +24,9 @@ jobs:
remote_port: 8822 remote_port: 8822
target: /root/OJ/data/dist target: /root/OJ/data/dist
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/setup-node@v4 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 24
cache: npm 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/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1", "@vue-flow/node-resizer": "^1.5.1",
"@vue-flow/node-toolbar": "^1.1.1", "@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"@vueuse/router": "^14.3.0", "@vueuse/router": "^14.4.0",
"@wangeditor-next/editor": "^5.7.13", "@wangeditor-next/editor": "^6.2.0",
"@wangeditor-next/editor-for-vue": "^5.1.14", "@wangeditor-next/editor-for-vue": "^6.2.0",
"axios": "^1.18.1", "axios": "^1.19.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5", "chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"md-editor-v3": "^6.5.3", "md-editor-v3": "^6.5.6",
"mermaid": "^11.16.0", "mermaid": "^11.16.1",
"mermaid-legacy": "npm:mermaid@^9.4.3", "mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"nanoid": "^5.1.16", "nanoid": "^6.0.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^3.0.4", "pinia": "^4.0.2",
"skulpt": "^1.2.0", "skulpt": "^1.2.0",
"vue": "^3.5.39", "vue": "^3.5.41",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.4",
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
"vue-router": "^5.1.0", "vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.5", "y-codemirror.next": "^0.3.5",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.31" "yjs": "^13.6.32"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.1", "@iconify/vue": "^5.0.1",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^26.1.0", "@types/node": "^26.1.2",
"@vitejs/plugin-legacy": "^8.1.0", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.4", "prettier": "^3.9.6",
"typescript": "^6.0.3", "typescript": "^7.0.2",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.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>
+77 -10
View File
@@ -2,6 +2,7 @@ import http from "utils/http"
import { toProblemListItem } from "admin/transforms" import { toProblemListItem } from "admin/transforms"
import type { import type {
AdminProblem, AdminProblem,
AdminTag,
Announcement, Announcement,
AnnouncementEdit, AnnouncementEdit,
BlankContest, BlankContest,
@@ -31,6 +32,7 @@ export async function getProblemList(
keyword: string, keyword: string,
author?: string, author?: string,
contestID?: string, contestID?: string,
tagId?: number,
) { ) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem" const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get<{ results: AdminProblem[]; total: number }>( const res = await http.get<{ results: AdminProblem[]; total: number }>(
@@ -43,6 +45,7 @@ export async function getProblemList(
keyword, keyword,
author, author,
contest_id: contestID, contest_id: contestID,
tag_id: tagId,
}, },
}, },
) )
@@ -84,6 +87,35 @@ export function getContestProblem(id: number) {
return http.get("admin/contest/problem", { params: { id } }) 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( export function getUserList(
offset = 0, offset = 0,
@@ -255,16 +287,6 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
return http.post("admin/announcement", announcement) return http.post("admin/announcement", announcement)
} }
export function getCommentList(offset = 0, limit = 10, problem: string) {
return http.get("admin/comment", {
params: { offset, limit, problem },
})
}
export function deleteComment(id: number) {
return http.delete("admin/comment", { params: { id } })
}
export async function getTutorialList() { export async function getTutorialList() {
const res = await http.get<Tutorial[]>("admin/tutorial") const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data return res.data
@@ -531,3 +553,48 @@ export function pinAIReport(id: number) {
export function getPinnedAIReports() { export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } }) 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>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types" import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue" import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import { import {
generateSQLTestcase, generateSQLTestcase,
@@ -51,11 +51,16 @@ const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim())) const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = 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 canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim()) const filled = scripts.value.filter((s) => s.sql.trim())
return ( return (
!isPreviewing.value && !isPreviewing.value &&
filled.length > 0 && // 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
filled.length >= 2 &&
filled.every((s) => s.display && !s.error && !s.stale) filled.every((s) => s.display && !s.error && !s.stale)
) )
}) })
@@ -151,15 +156,13 @@ async function preview() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = scripts.value const data = scripts.value
.filter((s) => s.sql.trim()) .filter((s) => s.sql.trim())
.map((s, i) => ({ .map((s, i) => ({
name: `${i + 1}.sql`, name: `${i + 1}.sql`,
input: s.sql, content: s.sql,
lastModified: now,
})) }))
const blob = await downloadZip(data).blob() const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true }) const res = await uploadTestcases(file, { sql: true })
@@ -241,7 +244,11 @@ async function upload() {
</n-button> </n-button>
</span> </span>
</template> </template>
所有脚本预览验证通过后才能上传 {{
filledCount < 2
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
: "所有脚本预览验证通过后才能上传"
}}
</n-tooltip> </n-tooltip>
</n-flex> </n-flex>
@@ -288,9 +295,7 @@ INSERT INTO ...;"
<p class="previewTitle">期望结果</p> <p class="previewTitle">期望结果</p>
<SQLDataTable <SQLDataTable
v-if="expectedQuery(s.display)" v-if="expectedQuery(s.display)"
:columns=" :columns="expectedQuery(s.display)!.columns"
expectedQuery(s.display)!.columns.map((name) => ({ name }))
"
:rows="expectedQuery(s.display)!.rows" :rows="expectedQuery(s.display)!.rows"
:total-rows="expectedQuery(s.display)!.total_rows" :total-rows="expectedQuery(s.display)!.total_rows"
:truncated="expectedQuery(s.display)!.truncated" :truncated="expectedQuery(s.display)!.truncated"
@@ -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"> <script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, Testcase } from "utils/types" import type { LANGUAGE, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api" import { uploadTestcases } from "../../api"
@@ -158,15 +158,14 @@ async function run() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = files.value const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error) .filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [ .flatMap((f, i) => [
{ name: `${i + 1}.in`, input: f.in, lastModified: now }, { name: `${i + 1}.in`, content: f.in },
{ name: `${i + 1}.out`, input: f.out, lastModified: now }, { name: `${i + 1}.out`, content: f.out },
]) ])
const blob = await downloadZip(data).blob() const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file) const res = await uploadTestcases(file)
-8
View File
@@ -79,16 +79,10 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
], ],
test_case_id: "", test_case_id: "",
test_case_score: [] as Testcase[], test_case_score: [] as Testcase[],
rule_type: "ACM",
hint: "", hint: "",
source: "", source: "",
prompt: "", prompt: "",
answers: [] as { language: LANGUAGE; code: string }[], answers: [] as { language: LANGUAGE; code: string }[],
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
contest_id: "", contest_id: "",
allow_flowchart: false, allow_flowchart: false,
mermaid_code: "", mermaid_code: "",
@@ -240,7 +234,6 @@ async function getProblemDetail() {
problem.value.samples = data.samples problem.value.samples = data.samples
problem.value.test_case_id = data.test_case_id problem.value.test_case_id = data.test_case_id
problem.value.test_case_score = data.test_case_score problem.value.test_case_score = data.test_case_score
problem.value.rule_type = data.rule_type
problem.value.hint = data.hint problem.value.hint = data.hint
problem.value.source = data.source problem.value.source = data.source
problem.value.prompt = data.prompt problem.value.prompt = data.prompt
@@ -260,7 +253,6 @@ async function getProblemDetail() {
code: "", code: "",
})) }))
} }
problem.value.io_mode = data.io_mode
if (problem.value.contest_id) { if (problem.value.contest_id) {
problem.value.contest_id = problem.value.contest_id problem.value.contest_id = problem.value.contest_id
} }
+76 -4
View File
@@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { NFlex, NSwitch, NTag } from "naive-ui" import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions" import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types" import type { AdminProblemFiltered } from "utils/types"
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api" import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue" import Modal from "./components/Modal.vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue" import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue"
interface Props { interface Props {
contestID?: string contestID?: string
@@ -36,6 +38,30 @@ const { count, inc } = useCounter(0)
const total = ref(0) const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([]) 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(() => { const nextDisplayID = computed(() => {
if (!isContestProblemList.value) return "" if (!isContestProblemList.value) return ""
if (problems.value.length === 0) return "1" if (problems.value.length === 0) return "1"
@@ -57,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
author: useRouteQuery("author", "").value, author: useRouteQuery("author", "").value,
}) })
const columns: DataTableColumn<AdminProblemFiltered>[] = [ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "ID", key: "id", width: 100 }, { title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 }, { title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 }, { title: "标题", key: "title", minWidth: 200 },
@@ -109,6 +135,21 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
: null, : null,
]), ]),
}, },
{
title: "反馈",
key: "top_reaction",
width: 60,
render: (row) => {
const top = row.top_reaction
if (!top) return null
const reaction = REACTIONS.find((it) => it.key === top.type)
if (!reaction) return null
return h(NTooltip, null, {
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
default: () => `${reaction.label} ${top.count}`,
})
},
},
{ title: "出题人", key: "username", width: 120 }, { title: "出题人", key: "username", width: 120 },
{ {
title: "创建时间", title: "创建时间",
@@ -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() { async function listProblems() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
@@ -212,8 +260,20 @@ watch(() => [query.page, query.limit, query.author], listProblems)
> >
年度趋势 年度趋势
</n-button> </n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin tag list' })"
>
标签管理
</n-button>
</n-flex> </n-flex>
<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 v-if="isContestProblemList" @click="createContestProblem">
新建比赛题目 新建比赛题目
</n-button> </n-button>
@@ -238,7 +298,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
</div> </div>
</n-flex> </n-flex>
</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 <Pagination
:total="total" :total="total"
v-model:limit="query.limit" v-model:limit="query.limit"
@@ -250,6 +316,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
:next-display-id="nextDisplayID" :next-display-id="nextDisplayID"
@change="listProblems" @change="listProblems"
/> />
<BatchTagModal
v-model:show="showBatchTag"
:problem-ids="selectedProblemIds"
:action="batchTagAction"
@done="onBatchTagDone"
/>
</template> </template>
<style scoped> <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>
+25
View File
@@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Server } from "utils/types" import type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
} }
async function saveWebsiteConfig() { async function saveWebsiteConfig() {
// 班级号要和用户名里 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) await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功") message.success("网站配置保存成功")
getWebsiteConfig() getWebsiteConfig()
configStore.getConfig() configStore.getConfig()
@@ -214,7 +233,13 @@ onMounted(() => {
</n-form> </n-form>
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="班级列表"> <n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags v-model:value="websiteConfig.class_list" /> <n-dynamic-tags v-model:value="websiteConfig.class_list" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-flex align="center"> <n-flex align="center">
+2
View File
@@ -14,5 +14,7 @@ export function toProblemListItem(result: AdminProblem) {
has_ast_rules: result.has_ast_rules, has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart, allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart, show_flowchart: result.show_flowchart,
// 比赛题目列表接口不返回这个字段
top_reaction: result.top_reaction ?? null,
} }
} }
+19 -2
View File
@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api" import { importUsers } from "../api"
const message = useMessage() const message = useMessage()
@@ -12,6 +18,17 @@ function generateUsers() {
message.info("请填写相关内容") message.info("请填写相关内容")
return false return false
} }
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入,
// 这里先拦一道,省得填完一屏用户名才被打回来
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
}
let className = !!prefix.value ? `ks${prefix.value}` : "" let className = !!prefix.value ? `ks${prefix.value}` : ""
rawInput.value = rawInput.value.trim() rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n") const inputs = rawInput.value.split("\n")
@@ -64,9 +81,9 @@ async function submit() {
style="width: 170px" style="width: 170px"
v-model:value="prefix" v-model:value="prefix"
clearable clearable
:max="9999" :max="CLASS_NAME_MAX_VALUE"
:min="0" :min="0"
placeholder="班级号" :placeholder="`班级号${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
/> />
</n-flex> </n-flex>
<n-input <n-input
+10 -2
View File
@@ -172,7 +172,6 @@ function createNewUser() {
problem_permission: "None", problem_permission: "None",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
two_factor_auth: false,
open_api: false, open_api: false,
is_disabled: false, is_disabled: false,
password: "", password: "",
@@ -197,6 +196,9 @@ async function handleEditUser() {
message.error("密码长度不得小于 6") message.error("密码长度不得小于 6")
return return
} }
// http 拦截器只对 login-required / permission-denied 自动弹提示,
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
try {
if (create.value) { if (create.value) {
const newUser = [ const newUser = [
[ [
@@ -209,9 +211,15 @@ async function handleEditUser() {
await importUsers(newUser) await importUsers(newUser)
listUsers() listUsers()
} else { } else {
const user = Object.assign(userEditing.value, { password: password.value }) const user = Object.assign(userEditing.value, {
password: password.value,
})
await editUser(user) await editUser(user)
} }
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
userEditing.value = null userEditing.value = null
password.value = "" password.value = ""
toggleCreate(false) toggleCreate(false)
+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 { import type {
Exercise, Exercise,
Problem, Problem,
ReactionKey,
ReactionState,
Submission, Submission,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
@@ -223,22 +225,17 @@ export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } }) return http.get("message", { params: { limit, offset } })
} }
export function createComment(data: { export function getReaction(problemID: number) {
problem_id: number return http.get<ReactionState>("reaction", {
description_rating: number params: { problem_id: problemID },
difficulty_rating: number })
comprehensive_rating: number
content: string
}) {
return http.post("comment", data)
} }
export function getComment(problemID: number) { export function setReaction(problemID: number, type: ReactionKey) {
return http.get("comment", { params: { problem_id: problemID } }) return http.post<ReactionState>("reaction", {
} problem_id: problemID,
type,
export function getCommentStatistics(problemID: number) { })
return http.get("comment/statistics", { params: { problem_id: problemID } })
} }
// TODO: 这个API有问题 // TODO: 这个API有问题
@@ -254,8 +251,8 @@ export function getTutorial(id: number) {
return http.get("tutorial", { params: { id } }) return http.get("tutorial", { params: { id } })
} }
export function getTutorials() { export function getTutorials(type: "python" | "c") {
return http.get("tutorials") return http.get("tutorials", { params: { type } })
} }
export function getAIDetailData(start: string, end: string, username?: string) { export function getAIDetailData(start: string, end: string, username?: string) {
+3 -22
View File
@@ -1,11 +1,7 @@
<script setup lang="ts"> <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" import type { Exercise, ExerciseFillData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
hljs.registerLanguage("python", python) import "./exercise-highlight.css"
hljs.registerLanguage("c", c)
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseFillData) 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 BlankSeg = { type: "blank"; answers: string[]; index: number }
type Segment = CodeSeg | BlankSeg 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 segments = computed<Segment[]>(() => {
const blanks: string[][] = [] const blanks: string[][] = []
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => { const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
@@ -25,18 +17,7 @@ const segments = computed<Segment[]>(() => {
return `____${blanks.length - 1}____` return `____${blanks.length - 1}____`
}) })
const lang = const highlighted = highlight(markedCode, props.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 parts = highlighted.split(/____(\d+)____/) const parts = highlighted.split(/____(\d+)____/)
const result: Segment[] = [] const result: Segment[] = []
+1 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Exercise, ExerciseGroupData } from "utils/types" import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseGroupData) 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 dragIdx = ref<number | null>(null)
const submitted = ref(false) 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() { function init() {
order.value = shuffle(data.value.items.map((_, i) => i)) order.value = shuffle(data.value.items.map((_, i) => i))
placement.value = Array(data.value.items.length).fill(-1) placement.value = Array(data.value.items.length).fill(-1)
+1 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Exercise, ExerciseMatchData } from "utils/types" import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData) 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 selectedLeft = ref<number | null>(null)
const submitted = ref(false) 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() { function init() {
const n = data.value.right.length const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i)) rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
+14 -100
View File
@@ -1,11 +1,8 @@
<script setup lang="ts"> <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" import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
hljs.registerLanguage("python", python) import { highlightLines } from "../composables/useCodeHighlight"
hljs.registerLanguage("c", c) import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>() const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseSortData) const data = computed(() => props.exercise.data as ExerciseSortData)
@@ -15,21 +12,16 @@ type LineItem = { originalIdx: number; text: string }
const lines = ref<LineItem[]>([]) const lines = ref<LineItem[]>([])
const submitted = ref(false) 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() { function init() {
lines.value = shuffle( const shuffled = shuffle(
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })), 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 submitted.value = false
} }
@@ -69,35 +61,9 @@ function reset() {
init() init()
} }
function escapeHtml(text: string): string { const lineHtml = computed<string[]>(() =>
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") highlightLines(data.value.lines, props.lang),
} )
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
})
</script> </script>
<template> <template>
@@ -143,7 +109,7 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
@drop="onDrop(idx)" @drop="onDrop(idx)"
> >
<span style="color: #bbb; cursor: grab"></span> <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> </div>
</n-space> </n-space>
@@ -162,55 +128,3 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
</n-space> </n-space>
</n-card> </n-card>
</template> </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>
+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
}
+35 -10
View File
@@ -57,7 +57,7 @@
content-style="height: calc(100% - 44px); padding: 0;" content-style="height: calc(100% - 44px); padding: 0;"
> >
<CodeEditor <CodeEditor
language="Python3" :language="editorLanguage"
v-model="tutorial.code" v-model="tutorial.code"
height="100%" height="100%"
/> />
@@ -102,7 +102,7 @@
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code"> <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-tab-pane>
</n-tabs> </n-tabs>
@@ -128,16 +128,23 @@
</n-button> </n-button>
</n-flex> </n-flex>
</template> </template>
<n-empty
v-if="isEmpty"
description="该教程还没有公开"
style="margin-top: 80px"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" 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 { getTutorial, getTutorials, getExercises } from "../api"
import { parseExercises } from "./composables/useExerciseParse" import { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
const ExerciseWidget = defineAsyncComponent( const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"), () => import("./components/ExerciseWidget.vue"),
@@ -150,21 +157,32 @@ const isDark = useDark()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const step = computed(() => { const step = computed(() => {
if (!route.params.step || !route.params.step.length) return 1 const value = route.params.step as string | undefined
return parseInt(route.params.step[0]) if (!value) return 1
return parseInt(value)
}) })
const type = computed<"python" | "c">(() =>
route.params.type === "c" ? "c" : "python",
)
const tutorial = ref<Partial<Tutorial>>({ const tutorial = ref<Partial<Tutorial>>({
id: 0, id: 0,
title: "", title: "",
content: "", content: "",
code: "", code: "",
}) })
const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([]) const titles = ref<{ id: number; title: string }[]>([])
const exercises = ref<Exercise[]>([]) const exercises = ref<Exercise[]>([])
const activeTab = ref("content") const activeTab = ref("content")
const isEmpty = ref(false)
const segments = computed(() => const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value), parseExercises(tutorial.value.content ?? "", exercises.value),
@@ -175,7 +193,9 @@ const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) { function goToLesson(lessonNumber: number) {
activeTab.value = "content" activeTab.value = "content"
router.push("/learn/" + lessonNumber.toString().padStart(2, "0")) router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
)
} }
function goToPrevLesson() { function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1) if (step.value > 1) goToLesson(step.value - 1)
@@ -185,9 +205,10 @@ function goToNextLesson() {
} }
async function init() { async function init() {
const res1 = await getTutorials() const res1 = await getTutorials(type.value)
titles.value = res1.data 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 id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([ const [res2, exs] = await Promise.allSettled([
getTutorial(id), getTutorial(id),
@@ -195,10 +216,11 @@ async function init() {
]) ])
if (res2.status === "fulfilled") tutorial.value = res2.value.data if (res2.status === "fulfilled") tutorial.value = res2.value.data
exercises.value = exs.status === "fulfilled" ? exs.value : [] exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
} }
watch( watch(
() => route.params.step, () => [route.params.type, route.params.step],
async () => { async () => {
if (route.name === "learn") init() if (route.name === "learn") init()
}, },
@@ -207,8 +229,11 @@ watch(
</script> </script>
<style scoped> <style scoped>
.learn-container { /* 桌面端固定高度,让目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 138px); height: calc(100vh - 138px);
}
} }
.learn-grid { .learn-grid {
+91 -46
View File
@@ -17,7 +17,6 @@ import { useUserStore } from "shared/store/user"
import storage from "utils/storage" import storage from "utils/storage"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import StatisticsPanel from "shared/components/StatisticsPanel.vue" import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import IconButton from "shared/components/IconButton.vue"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui" import { NFlex } from "naive-ui"
import SubmitCode from "./SubmitCode.vue" import SubmitCode from "./SubmitCode.vue"
@@ -72,6 +71,71 @@ const showGoSubmissionButton = computed(() => {
else return false 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) => ({ const languageOptions: DropdownOption[] = languages.value.map((it) => ({
label: () => label: () =>
h(NFlex, { align: "center" }, () => [ h(NFlex, { align: "center" }, () => [
@@ -160,52 +224,40 @@ onMounted(() => {
<SubmitCode v-else /> <SubmitCode v-else />
<IconButton <n-button
v-if="showGoSubmissionButton" v-if="isDesktop && showGoSubmissionButton"
icon="streamline-ultimate-color:task-list-text-1" :size="buttonSize"
tip="提交信息"
@click="goSubmissions" @click="goSubmissions"
/> >
提交信息
</n-button>
<IconButton <n-button
v-if="userStore.isTeacherOrAbove" v-if="isDesktop && userStore.isTeacherOrAbove"
icon="streamline-ultimate-color:analytics-pie-2" :size="buttonSize"
tip="统计信息"
@click="statisticPanel = true" @click="statisticPanel = true"
/> >
课堂统计
</n-button>
<template v-if="codeStore.code.language !== 'Flowchart'"> <!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单移动端再加上提交信息 / 课堂统计 -->
<IconButton <n-dropdown
v-if="codeStore.code.language !== 'SQL'" v-if="menuOptions.length"
icon="streamline-ultimate-color:business-lucky-cat" trigger="click"
tip="自测猫" :options="menuOptions"
@click="goTestCat" @select="handleMenuSelect"
/> >
<n-button :size="buttonSize">更多操作</n-button>
<IconButton </n-dropdown>
icon="streamline-ultimate-color:copy-paste-1"
tip="复制代码"
@click="copy"
/>
<IconButton
icon="streamline-ultimate-color:synchronize-arrow"
tip="重置代码"
@click="reset"
/>
</template>
<template v-if="showSyncFeature"> <template v-if="showSyncFeature">
<IconButton <n-button
:icon=" :size="buttonSize"
syncEnabled
? 'streamline-ultimate-color:wifi-off'
: 'streamline-ultimate-color:wifi-signal-2'
"
:tip="syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF"
:type="syncEnabled ? 'warning' : 'default'" :type="syncEnabled ? 'warning' : 'default'"
@click="toggleSync" @click="toggleSync"
/> >
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
</n-button>
<!-- 同步状态标签 --> <!-- 同步状态标签 -->
<template v-if="isConnected"> <template v-if="isConnected">
@@ -224,13 +276,6 @@ onMounted(() => {
</n-tag> </n-tag>
</template> </template>
</template> </template>
<IconButton
v-if="isDesktop && userStore.isSuperAdmin"
icon="streamline-ultimate-color:common-file-edit"
tip="编辑题目"
@click="goEdit"
/>
</n-flex> </n-flex>
<n-modal <n-modal
@@ -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>
+1 -1
View File
@@ -333,7 +333,7 @@ function type(status: ProblemStatus) {
</p> </p>
<template v-if="sqlExpectedQuery"> <template v-if="sqlExpectedQuery">
<SQLDataTable <SQLDataTable
:columns="sqlExpectedQuery.columns.map((name) => ({ name }))" :columns="sqlExpectedQuery.columns"
:rows="sqlExpectedQuery.rows" :rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows" :total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated" :truncated="sqlExpectedQuery.truncated"
@@ -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>
@@ -35,10 +35,12 @@ const msg = computed(() => {
let msg = "" let msg = ""
const result = props.submission.result const result = props.submission.result
// //
// SQL ""err_info
if ( if (
result === SubmissionStatus.compile_error || (result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error result === SubmissionStatus.runtime_error) &&
props.submission.language !== "SQL"
) { ) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n" msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
} }
+28 -9
View File
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { import {
formatCode, formatCode,
getComment, getReaction,
submitCode, submitCode,
updateProblemSetProgress, updateProblemSetProgress,
} from "oj/api" } from "oj/api"
@@ -17,11 +17,14 @@ import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState" import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck" import {
checkPythonSyntax,
prefetchPythonSyntaxChecker,
} from "oj/problem/utils/pythonSyntaxCheck"
// ==================== ==================== // ==================== ====================
const ProblemComment = defineAsyncComponent( const ProblemReaction = defineAsyncComponent(
() => import("./ProblemComment.vue"), () => import("./ProblemReaction.vue"),
) )
// ==================== ==================== // ==================== ====================
@@ -37,6 +40,10 @@ const router = useRouter()
const [commentPanel] = useToggle() const [commentPanel] = useToggle()
const message = useMessage() const message = useMessage()
function closeCommentPanel() {
commentPanel.value = false
}
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
// ==================== ==================== // ==================== ====================
@@ -50,6 +57,16 @@ const showResult = ref(false)
const isFormatting = ref(false) const isFormatting = ref(false)
const isSubmittingRequest = ref(false) const isSubmittingRequest = ref(false)
// ==================== Python ====================
// Python3 Skulpt
watch(
() => codeStore.code.language,
(language) => {
if (language === "Python3") prefetchPythonSyntaxChecker()
},
{ immediate: true },
)
// ==================== ==================== // ==================== ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, { const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
controls: true, controls: true,
@@ -59,8 +76,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
// ==================== AC ==================== // ==================== AC ====================
const { start: showCommentPanelDelayed } = useTimeoutFn( const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => { async () => {
const res = await getComment(problem.value!.id) const res = await getReaction(problem.value!.id)
if (!res.data) { if (res.data.mine === null) {
commentPanel.value = true commentPanel.value = true
} }
}, },
@@ -99,7 +116,7 @@ async function submit() {
// 0. Python3 // 0. Python3
if (codeStore.code.language === "Python3") { if (codeStore.code.language === "Python3") {
const syntaxError = checkPythonSyntax(codeStore.code.value) const syntaxError = await checkPythonSyntax(codeStore.code.value)
if (syntaxError) { if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`) message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
return return
@@ -246,11 +263,13 @@ watch(
<!-- 评价弹窗 --> <!-- 评价弹窗 -->
<n-modal <n-modal
preset="card" preset="card"
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)" title="恭喜你成功提交,说说你对这道题的感受吧"
:mask-closable="false" :mask-closable="false"
:closable="false"
:close-on-esc="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel" v-model:show="commentPanel"
> >
<ProblemComment :showStatistics="false" /> <ProblemReaction @submitted="closeCommentPanel" />
</n-modal> </n-modal>
</template> </template>
+44 -27
View File
@@ -138,6 +138,7 @@ export function useFireworks() {
confetti({ confetti({
particleCount: 12, particleCount: 12,
angle: 270,
spread: 100, spread: 100,
startVelocity: 12, startVelocity: 12,
gravity: 0.6, gravity: 0.6,
@@ -145,7 +146,7 @@ export function useFireworks() {
ticks: 200, ticks: 200,
scalar: 1.2, scalar: 1.2,
shapes: ["star"], shapes: ["star"],
origin: { x: Math.random(), y: -0.1 }, origin: { x: Math.random(), y: 0 },
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"], colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
}) })
}, 150) }, 150)
@@ -285,29 +286,32 @@ export function useFireworks() {
// 效果7: 螺旋上升 // 效果7: 螺旋上升
() => { () => {
const defaults = { const steps = 16
spread: 360, const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
ticks: 100,
gravity: 0, for (let step = 0; step < steps; step++) {
decay: 0.94, runLater(() => {
startVelocity: 30, const progress = step / (steps - 1)
} const phase = step * (Math.PI / 4)
function shoot() {
confetti({ confetti({
...defaults, particleCount: 7,
particleCount: 50, angle: 90 + Math.cos(phase) * 18,
scalar: 1.2, spread: 25,
startVelocity: 16,
gravity: 0.25,
decay: 0.94,
ticks: 100,
scalar: 0.9,
shapes: ["circle", "square"], shapes: ["circle", "square"],
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"], origin: {
x: 0.5 + Math.sin(phase) * 0.14,
y: 0.85 - progress * 0.55,
},
colors,
}) })
}, step * 65)
} }
runLater(shoot, 0)
runLater(shoot, 100)
runLater(shoot, 200)
runLater(shoot, 300)
runLater(shoot, 400)
}, },
// 效果8: 表情包烟花 // 效果8: 表情包烟花
@@ -315,6 +319,7 @@ export function useFireworks() {
loadEmojiShapes().then((emojiShapes) => { loadEmojiShapes().then((emojiShapes) => {
confetti({ confetti({
shapes: emojiShapes, shapes: emojiShapes,
flat: true,
scalar: 6, scalar: 6,
particleCount: 18, particleCount: 18,
spread: 360, spread: 360,
@@ -349,16 +354,28 @@ export function useFireworks() {
// 效果10: 礼花瀑布 // 效果10: 礼花瀑布
() => { () => {
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({ confetti({
particleCount: 150, particleCount: 2,
spread: 180, angle: 270,
startVelocity: 0, spread: 30,
gravity: 1.2, startVelocity: 5,
decay: 0.95, gravity: 1,
ticks: 300, decay: 0.96,
origin: { x: 0.5, y: 0 }, ticks: 240,
colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"], 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 { useIntervalFn, useTimeoutFn } from "@vueuse/core"
import { getSubmission } from "oj/api" import { getSubmission } from "oj/api"
import { SubmissionStatus } from "utils/constants" 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 { import {
useSubmissionWebSocket, useSubmissionWebSocket,
type SubmissionUpdate, type SubmissionUpdate,
@@ -45,6 +46,20 @@ export function useSubmissionMonitor() {
// ==================== WebSocket 处理 ==================== // ==================== WebSocket 处理 ====================
const handleSubmissionUpdate = (data: SubmissionUpdate) => { 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) console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
if (data.submission_id !== submissionId.value) { if (data.submission_id !== submissionId.value) {
+5 -5
View File
@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
const ProblemSubmission = defineAsyncComponent( const ProblemSubmission = defineAsyncComponent(
() => import("./components/ProblemSubmission.vue"), () => import("./components/ProblemSubmission.vue"),
) )
const ProblemComment = defineAsyncComponent( const ProblemReaction = defineAsyncComponent(
() => import("./components/ProblemComment.vue"), () => import("./components/ProblemReaction.vue"),
) )
const ProblemFlowchart = defineAsyncComponent( const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"), () => import("./components/ProblemFlowchart.vue"),
@@ -174,7 +174,7 @@ watch(
tab="题目点评" tab="题目点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemReaction />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
@@ -226,7 +226,7 @@ watch(
tab="题目点评" tab="题目点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemReaction />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
@@ -266,7 +266,7 @@ watch(
tab="点评" tab="点评"
:disabled="!!problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemReaction />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="myFlowchartStore.showing" v-if="myFlowchartStore.showing"
+36 -4
View File
@@ -1,15 +1,47 @@
// @ts-ignore - skulpt has no type definitions
import Sk from "skulpt"
export interface PythonSyntaxError { export interface PythonSyntaxError {
line: number line: number
} }
let skulptPromise: Promise<any> | null = null
/**
* Skulpt 233KB gzip Python3
*
*/
function loadSkulpt(): Promise<any> {
if (!skulptPromise) {
// @ts-ignore - skulpt has no type definitions
skulptPromise = import("skulpt").then((m) => m.default ?? m)
}
return skulptPromise
}
/**
* Skulpt
*
*/
export function prefetchPythonSyntaxChecker() {
loadSkulpt().catch(() => {
skulptPromise = null
})
}
/** /**
* Skulpt Python * Skulpt Python
* input() IO * input() IO
* null
*/ */
export function checkPythonSyntax(code: string): PythonSyntaxError | null { export async function checkPythonSyntax(
code: string,
): Promise<PythonSyntaxError | null> {
let Sk: any
try {
Sk = await loadSkulpt()
} catch {
skulptPromise = null
return null
}
Sk.configure({ output: () => {} }) Sk.configure({ output: () => {} })
try { try {
Sk.compile(code, "prog.py", "exec") Sk.compile(code, "prog.py", "exec")
+27 -2
View File
@@ -198,8 +198,9 @@ const columns: DataTableColumn<Rank>[] = [
"streamline-emojis:smiling-face-with-sunglasses", "streamline-emojis:smiling-face-with-sunglasses",
), ),
key: "username", key: "username",
width: 200, width: 240,
render: (row) => render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h( h(
NButton, NButton,
{ {
@@ -209,6 +210,18 @@ const columns: DataTableColumn<Rank>[] = [
}, },
() => row.user.username, () => row.user.username,
), ),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () =>
router.push("/achievement?name=" + row.user.username),
},
() => "🏆",
),
]),
}, },
{ {
title: renderTableTitle( title: renderTableTitle(
@@ -378,8 +391,9 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
{ {
title: "用户名", title: "用户名",
key: "username", key: "username",
width: 200, width: 240,
render: (row) => render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h( h(
NButton, NButton,
{ {
@@ -404,6 +418,17 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
) )
: row.username, : row.username,
), ),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () => router.push("/achievement?name=" + row.username),
},
() => "🏆",
),
]),
}, },
{ {
title: "已解决", title: "已解决",
@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props { interface Props {
type: "题目" | "用户" type: "题目" | "用户"
@@ -10,12 +11,14 @@ const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"]) const emits = defineEmits(["click", "search", "filterClass"])
// kstest ks
//
const showFilterClass = computed(() => { const showFilterClass = computed(() => {
return props.type === "用户" && props.username?.startsWith("ks") return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
}) })
function filterClass() { function filterClass() {
const match = props.username!.match(/^ks\d{3,4}/) const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : "" const classname = match ? match[0] : ""
if (!classname) return if (!classname) return
emits("filterClass", classname) emits("filterClass", classname)
+79 -75
View File
@@ -4,9 +4,10 @@ import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { Profile, UserBadge as UserBadgeType } from "utils/types" import type { AchievementSummary, Profile } from "utils/types"
import { getMetrics, getUserBadges } from "../api" import { getAchievementSummary } from "oj/achievement/api"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue" import { getMetrics } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
const route = useRoute() const route = useRoute()
@@ -18,7 +19,7 @@ const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("") const latestSubmissionAt = ref("")
const toLatestAt = ref("") const toLatestAt = ref("")
const learnDuration = ref("") const learnDuration = ref("")
const userBadges = ref<GroupedBadge[]>([]) const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
@@ -58,74 +59,23 @@ const hasMoreProblems = computed(
() => problems.value.length > itemsPerRow.value * 3, () => problems.value.length > itemsPerRow.value * 3,
) )
//
interface GroupedBadge {
icon: string
count: number
badges: UserBadgeType[]
latestEarnedTime: Date
}
//
function groupBadgesByIcon(badges: UserBadgeType[]): GroupedBadge[] {
const grouped = new Map<string, UserBadgeType[]>()
//
badges.forEach((badge) => {
const icon = badge.badge.icon
if (!grouped.has(icon)) {
grouped.set(icon, [])
}
grouped.get(icon)!.push(badge)
})
//
return Array.from(grouped.entries())
.map(([icon, badgeList]) => ({
icon,
count: badgeList.length,
badges: badgeList,
latestEarnedTime: new Date(
Math.max(...badgeList.map((b) => new Date(b.earned_time).getTime())),
),
}))
.sort((a, b) => a.icon.localeCompare(b.icon))
}
async function init() { async function init() {
toggle(true) toggle(true)
try { try {
const res = await getProfile(route.query.name as string) const res = await getProfile(route.query.name as string)
profile.value = res.data profile.value = res.data
const acm = res.data.acm_problems_status.problems || {} const acm = res.data.acm_problems_status.problems || {}
const oi = res.data.oi_problems_status.problems || {}
const ac: string[] = [] const ac: string[] = []
for (let problems of [acm, oi]) { Object.keys(acm).forEach((id) => {
Object.keys(problems).forEach((id) => { if (acm[id]["status"] === 0) {
if (problems[id]["status"] === 0) { ac.push(acm[id]["_id"])
ac.push(problems[id]["_id"])
} }
}) })
}
ac.sort() ac.sort()
problems.value = ac problems.value = ac
const promises: Promise<{ data: any }>[] = []
if (profile.value.submission_number > 0) { if (profile.value.submission_number > 0) {
promises.push(getMetrics(profile.value.user.id)) const metricsRes = await getMetrics(profile.value.user.id)
}
if (route.query.name) {
promises.push(getUserBadges(route.query.name as string))
} else {
promises.push(getUserBadges())
}
const results = await Promise.all(promises)
// metrics
if (profile.value.submission_number > 0) {
const metricsRes = results[0]
firstSubmissionAt.value = parseTime(metricsRes.data.first) firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest) latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays( toLatestAt.value = durationToDays(
@@ -137,14 +87,24 @@ async function init() {
metricsRes.data.latest, metricsRes.data.latest,
) )
} }
// badges
userBadges.value = groupBadgesByIcon(results[1].data)
} finally { } finally {
toggle(false) 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(() => { const metrics = computed(() => {
if (loading.value) return [] if (loading.value) return []
return [ return [
@@ -183,7 +143,10 @@ const metrics = computed(() => {
] ]
}) })
onMounted(init) onMounted(() => {
init()
loadAchievementSummary()
})
</script> </script>
<template> <template>
<n-flex <n-flex
@@ -225,14 +188,14 @@ onMounted(init)
<n-grid <n-grid
v-if="profile && profile.submission_number > 0" v-if="profile && profile.submission_number > 0"
class="wrapper" class="wrapper"
:cols="2" :cols="isDesktop ? 2 : 1"
:x-gap="10" :x-gap="10"
:y-gap="10" :y-gap="10"
> >
<n-gi v-for="item in metrics" :key="item.content"> <n-gi v-for="item in metrics" :key="item.content">
<n-card hoverable> <n-card hoverable>
<n-flex align="center"> <n-flex align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="50" /> <Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
<div> <div>
<Component :is="isDesktop ? NH2 : NH3" class="number"> <Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" /> <n-number-animation v-if="item.animate" :to="item.title" />
@@ -247,21 +210,50 @@ onMounted(init)
</n-gi> </n-gi>
</n-grid> </n-grid>
<!-- 徽章展示卡片 --> <!-- 成就摘要 -->
<n-card <n-card
v-if="!loading && profile && userBadges.length > 0" v-if="!loading && profile && achievementSummary"
class="wrapper" class="wrapper"
hoverable hoverable
> >
<template #header> <n-flex align="center" justify="space-between">
<n-h4 style="margin: 0">获得的徽章</n-h4> <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> </template>
<n-flex wrap> {{ a.name }}
<GroupedUserBadge </n-tooltip>
v-for="groupedBadge in userBadges" <n-text v-if="!achievementSummary.recent.length" depth="3">
:key="groupedBadge.icon" 还没有获得成就
:grouped-badge="groupedBadge" </n-text>
/>
</n-flex> </n-flex>
</n-card> </n-card>
@@ -324,4 +316,16 @@ h2 {
word-wrap: break-word; word-wrap: break-word;
max-width: 100%; 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> </style>
+21 -7
View File
@@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/user/index.vue"), component: () => import("oj/user/index.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "achievement",
name: "achievement",
component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true },
},
{ {
path: "setting", path: "setting",
component: () => import("oj/user/setting.vue"), component: () => import("oj/user/setting.vue"),
@@ -93,7 +99,7 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: "learn/:step+", path: "learn/:type/:step",
name: "learn", name: "learn",
component: () => import("oj/learn/index.vue"), component: () => import("oj/learn/index.vue"),
props: true, props: true,
@@ -165,6 +171,12 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/problem/list.vue"), component: () => import("admin/problem/list.vue"),
meta: { requiresProblemPermission: true }, meta: { requiresProblemPermission: true },
}, },
{
path: "problem/tags",
name: "admin tag list",
component: () => import("admin/problem/tags.vue"),
meta: { requiresProblemPermission: true },
},
{ {
path: "problem/create", path: "problem/create",
name: "admin problem create", name: "admin problem create",
@@ -245,12 +257,6 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{
path: "comment/list",
name: "admin comment list",
component: () => import("admin/communication/comments.vue"),
meta: { requiresSuperAdmin: true },
},
{ {
path: "message/list", path: "message/list",
name: "admin message list", name: "admin message list",
@@ -295,6 +301,14 @@ export const admins: RouteRecordRaw = {
component: () => import("admin/problemset/list.vue"), component: () => import("admin/problemset/list.vue"),
meta: { requiresTeacherAdmin: true }, 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", path: "problemset/create",
name: "admin problemset create", name: "admin problemset create",
-5
View File
@@ -9,7 +9,6 @@ export function signup(data: {
username: string username: string
email: string email: string
password: string password: string
captcha: string
}) { }) {
return http.post("register", data) return http.post("register", data)
} }
@@ -26,10 +25,6 @@ export function getProblemTagList() {
return http.get<Tag[]>("problem/tags") return http.get<Tag[]>("problem/tags")
} }
export function getCaptcha() {
return http.get("captcha")
}
export function getHitokoto() { export function getHitokoto() {
return http.get("hitokoto") return http.get("hitokoto")
} }
+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>
+22 -3
View File
@@ -3,17 +3,21 @@
v-if="!hiddenICP" v-if="!hiddenICP"
justify="center" justify="center"
align="center" align="center"
:vertical="isMobile" :size="isMobile ? 4 : 'medium'"
:wrap="false"
class="beian" class="beian"
:class="{ 'beian--mobile': isMobile }"
> >
<n-flex justify="center" align="center" :vertical="isMobile" size="small"> <n-flex justify="center" align="center" :size="isMobile ? 4 : 'small'">
<n-text>&copy; 2022 - 2026 判题狗 保留所有权利</n-text> <n-text>{{ copyrightText }}</n-text>
<n-button text @click="goCC">CC BY-NC 4.0</n-button> <n-button text @click="goCC">CC BY-NC 4.0</n-button>
</n-flex> </n-flex>
<template v-if="!isMobile">
<n-button text @click="goICP">浙ICP备2023044109号-1</n-button> <n-button text @click="goICP">浙ICP备2023044109号-1</n-button>
<n-button text @click="goPublicSecurity"> <n-button text @click="goPublicSecurity">
浙公网安备33100402331786号 浙公网安备33100402331786号
</n-button> </n-button>
</template>
</n-flex> </n-flex>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -25,6 +29,9 @@ const hiddenICP = computed(() =>
["problem", "contest problem"].includes(route.name as string), ["problem", "contest problem"].includes(route.name as string),
) )
const currentYear = new Date().getFullYear()
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
function goICP() { function goICP() {
window.open("https://beian.miit.gov.cn", "_blank") window.open("https://beian.miit.gov.cn", "_blank")
} }
@@ -47,4 +54,16 @@ function goPublicSecurity() {
.beian { .beian {
margin: 12px 0; 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> </style>
@@ -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 { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router" import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import { useScreenModeStore } from "shared/store/screenMode" import { useScreenModeStore } from "shared/store/screenMode"
import { logout } from "../api" import { logout } from "../api"
@@ -17,15 +18,14 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints() const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const isDark = useDark() const isDark = useDark()
function toggleDark(event: MouseEvent) { function toggleDark() {
const { clientX: x, clientY: y } = event const x = window.innerWidth / 2
const radius = Math.hypot( const y = window.innerHeight / 2
Math.max(x, window.innerWidth - x), const radius = Math.hypot(x, y)
Math.max(y, window.innerHeight - y),
)
if (!document.startViewTransition) { if (!document.startViewTransition) {
isDark.value = !isDark.value isDark.value = !isDark.value
return return
@@ -112,15 +112,44 @@ async function handleLogout() {
router.replace("/") router.replace("/")
} }
function handleToggleDemoMode() {
const entering = !userStore.demoMode
userStore.toggleDemoMode()
// 退
if (entering && route.path.startsWith("/admin")) {
router.push("/")
}
}
function renderIcon(icon: string) { function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 20 }) 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[]>(() => [ const menus = computed<MenuOption[]>(() => [
{ {
label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }), label: "自学",
key: "learn", key: "learn",
icon: renderIcon("fluent-emoji:books"), 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: () => "题目" }), 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: "我的主页", label: "我的主页",
key: "home", key: "home",
@@ -217,6 +246,13 @@ const options: Array<DropdownOption | DropdownDividerOption> = [
onClick: () => router.push("/ai-analysis"), 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" }, { type: "divider" },
{ {
label: "退出", label: "退出",
@@ -224,7 +260,7 @@ const options: Array<DropdownOption | DropdownDividerOption> = [
icon: renderIcon("streamline-ultimate-color:coffee-cold"), icon: renderIcon("streamline-ultimate-color:coffee-cold"),
props: { onClick: handleLogout }, props: { onClick: handleLogout },
}, },
] ])
function goHome() { function goHome() {
router.push("/") router.push("/")
+14 -2
View File
@@ -21,8 +21,20 @@ const limit = ref(initialLimit)
const page = ref(initialPage) const page = ref(initialPage)
const sizes = [10, 30, 50] const sizes = [10, 30, 50]
watch(limit, () => emit("update:limit", limit)) // emit emit ref ref
watch(page, () => emit("update:page", page)) // emit
watch(limit, (value) => emit("update:limit", value))
watch(page, (value) => emit("update:page", value))
// /
watch(
() => initialLimit,
(value) => (limit.value = value),
)
watch(
() => initialPage,
(value) => (page.value = value),
)
</script> </script>
<template> <template>
+2 -38
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { getCaptcha, signup } from "../api" import { signup } from "../api"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal" import { useAuthModalStore } from "../store/authModal"
@@ -10,7 +10,6 @@ const {
signupForm: form, signupForm: form,
signupLoading: isLoading, signupLoading: isLoading,
signupError: msg, signupError: msg,
captchaSrc,
} = storeToRefs(authStore) } = storeToRefs(authStore)
const signupRef = useTemplateRef("signupRef") const signupRef = useTemplateRef("signupRef")
@@ -31,9 +30,6 @@ const rules: FormRules = {
trigger: "blur", trigger: "blur",
}, },
], ],
captcha: [
{ required: true, message: "验证码必填", trigger: "blur", min: 1, max: 10 },
],
} }
function goLogin() { function goLogin() {
@@ -50,20 +46,15 @@ function submit() {
username: form.value.username, username: form.value.username,
email: form.value.email, email: form.value.email,
password: form.value.password, password: form.value.password,
captcha: form.value.captcha,
}) })
} catch (err: any) { } catch (err: any) {
if (err.data === "Invalid captcha") { if (err.data === "Username already exists") {
authStore.setSignupError("验证码不正确")
} else if (err.data === "Username already exists") {
authStore.setSignupError("用户名已存在") authStore.setSignupError("用户名已存在")
} else if (err.data === "Email already exists") { } else if (err.data === "Email already exists") {
authStore.setSignupError("邮箱已存在") authStore.setSignupError("邮箱已存在")
} else { } else {
authStore.setSignupError("无法注册") authStore.setSignupError("无法注册")
} }
getCaptchaSrc()
form.value.captcha = ""
} finally { } finally {
authStore.setSignupLoading(false) authStore.setSignupLoading(false)
} }
@@ -74,14 +65,6 @@ function submit() {
}) })
} }
async function getCaptchaSrc() {
const res = await getCaptcha()
authStore.setCaptchaSrc(res.data)
}
watch(signupModalOpen, (v) => {
if (v) getCaptchaSrc()
})
</script> </script>
<template> <template>
@@ -134,18 +117,6 @@ watch(signupModalOpen, (v) => {
autocomplete="new-password" autocomplete="new-password"
/> />
</n-form-item> </n-form-item>
<n-form-item label="验证码" path="captcha">
<n-space>
<n-input
v-model:value="form.captcha"
clearable
name="captcha"
id="signup-captcha"
autocomplete="off"
/>
<img class="captcha" :src="captchaSrc" @click="getCaptchaSrc" />
</n-space>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert> <n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert>
<n-form-item> <n-form-item>
<n-space> <n-space>
@@ -158,10 +129,3 @@ watch(signupModalOpen, (v) => {
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
<style scoped>
.captcha {
height: 34px;
cursor: pointer;
}
</style>
+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,
)
}
+29
View File
@@ -5,6 +5,7 @@ import type {
CompletionSource, CompletionSource,
} from "@codemirror/autocomplete" } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view" import type { EditorView } from "@codemirror/view"
import { useProblemStore } from "oj/store/problem"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { c } from "./c" import { c } from "./c"
import { python } from "./python" import { python } from "./python"
@@ -22,6 +23,30 @@ const chineseAnnotations: Record<string, ChineseCompletion[]> = {
sql, 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 { export function enhanceCompletion(language: LANGUAGE): CompletionSource {
return async function ( return async function (
context: CompletionContext, context: CompletionContext,
@@ -69,6 +94,10 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
return completion return completion
}) })
if (trulyLanguage === "sql") {
completions.push(...sqlSchemaCompletions())
}
return { return {
from: word ? word.from : context.pos, from: word ? word.from : context.pos,
options: completions, options: completions,
+10 -10
View File
@@ -114,15 +114,6 @@ const options = computed<MenuOption[]>(() => {
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }), h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list", key: "admin user list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/comment/list" },
{ default: () => "评论" },
),
key: "admin comment list",
},
{ {
label: () => label: () =>
h( h(
@@ -141,6 +132,15 @@ const options = computed<MenuOption[]>(() => {
), ),
key: "admin tutorial list", key: "admin tutorial list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/achievement/list" },
{ default: () => "成就" },
),
key: "admin achievement list",
},
{ {
label: () => label: () =>
h( h(
@@ -164,10 +164,10 @@ const active = computed(() => {
if (path.startsWith("/admin/config")) return "admin config" if (path.startsWith("/admin/config")) return "admin config"
if (path.startsWith("/admin/problemset")) return "admin problemset list" if (path.startsWith("/admin/problemset")) return "admin problemset list"
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems" 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/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list" if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list" if (path.startsWith("/admin/user")) return "admin user list"
if (path.startsWith("/admin/comment")) return "admin comment list"
if (path.startsWith("/admin/announcement")) return "admin announcement list" if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list" if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports" if (path.startsWith("/admin/ai")) return "admin ai reports"
+18
View File
@@ -4,6 +4,23 @@ import Header from "../components/Header.vue"
import Login from "../components/Login.vue" import Login from "../components/Login.vue"
import Signup from "../components/Signup.vue" import Signup from "../components/Signup.vue"
import LoginSummaryModal from "../components/LoginSummaryModal.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> </script>
<template> <template>
@@ -19,6 +36,7 @@ import LoginSummaryModal from "../components/LoginSummaryModal.vue"
<Login /> <Login />
<Signup /> <Signup />
<LoginSummaryModal /> <LoginSummaryModal />
<AchievementToast />
<Beian /> <Beian />
</n-layout> </n-layout>
</template> </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: "", email: "",
password: "", password: "",
passwordAgain: "", passwordAgain: "",
captcha: "",
}) })
const signupLoading = ref(false) const signupLoading = ref(false)
const signupError = ref("") const signupError = ref("")
// ==================== 验证码 ====================
const captchaSrc = ref("")
// ==================== 模态框操作 ==================== // ==================== 模态框操作 ====================
/** /**
* *
@@ -123,13 +119,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupError.value = "" signupError.value = ""
} }
/**
*
*/
function setCaptchaSrc(src: string) {
captchaSrc.value = src
}
return { return {
// 模态框状态 // 模态框状态
loginModalOpen, loginModalOpen,
@@ -145,9 +134,6 @@ export const useAuthModalStore = defineStore("authModal", () => {
signupLoading, signupLoading,
signupError, signupError,
// 验证码
captchaSrc,
// 模态框操作 // 模态框操作
openLoginModal, openLoginModal,
closeLoginModal, closeLoginModal,
@@ -165,6 +151,5 @@ export const useAuthModalStore = defineStore("authModal", () => {
setSignupLoading, setSignupLoading,
setSignupError, setSignupError,
clearSignupError, clearSignupError,
setCaptchaSrc,
} }
}) })
+33 -10
View File
@@ -11,30 +11,49 @@ export const useUserStore = defineStore("user", () => {
const [isFinished] = useToggle(false) const [isFinished] = useToggle(false)
const user = computed<User | null>(() => profile.value?.user ?? null) const user = computed<User | null>(() => profile.value?.user ?? null)
const isAuthed = computed(() => !!user.value?.email) 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( const isAdminRole = computed(
() => () =>
user.value?.admin_type === USER_TYPE.STUDENT_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.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN, user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
) )
const isStudentAdmin = computed( const isStudentAdmin = computed(
() => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN, () => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
) )
const isTeacherAdmin = computed( const isTeacherAdmin = computed(
() => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN, () => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
) )
const isTeacherOrAbove = computed( const isTeacherOrAbove = computed(
() => () =>
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN || !demoMode.value &&
user.value?.admin_type === USER_TYPE.SUPER_ADMIN, (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,
) )
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
const hasProblemPermission = computed( 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(() => { const showSubmissions = computed(() => {
let flag = configStore.config.submission_list_show_all let flag = configStore.config.submission_list_show_all
if (isAdminRole.value) flag = true if (isAdminRole.value) flag = true
@@ -51,6 +70,7 @@ export const useUserStore = defineStore("user", () => {
function clearProfile() { function clearProfile() {
profile.value = null profile.value = null
demoMode.value = false
storage.clear() storage.clear()
} }
return { return {
@@ -63,6 +83,9 @@ export const useUserStore = defineStore("user", () => {
isTeacherOrAbove, isTeacherOrAbove,
isSuperAdmin, isSuperAdmin,
hasProblemPermission, hasProblemPermission,
demoMode,
canToggleDemoMode,
toggleDemoMode,
isAuthed, isAuthed,
showSubmissions, showSubmissions,
getMyProfile, 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 { export enum SubmissionStatus {
compile_error = -2, compile_error = -2,
@@ -150,6 +150,7 @@ export const STORAGE_KEY = {
LEARN_CURRENT_STEP: "learnStep", LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem", ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags", ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode",
} }
export const DIFFICULTY = { export const DIFFICULTY = {
@@ -275,6 +276,44 @@ export enum ChartType {
Activity, Activity,
} }
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
// 青铜往红偏(色相 23),黄金往纯黄推(色相 47),中间隔开两档亮度,
// 不然两个都落在橙棕区,扫一眼分不出来
export const RARITY_COLOR: Record<AchievementRarity, string> = {
bronze: "#c2703d",
silver: "#9fa6b2",
gold: "#f2c012",
platinum: "#7dd3fc",
}
// 文字要另配一套。上面那组是照深色底调的,搬到白底上白金只有 1.7:1、
// 黄金 2.2: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 = [ export const DURATION_OPTIONS = [
{ label: "本节课内", value: "hours:1" }, { label: "本节课内", value: "hours:1" },
@@ -286,3 +325,46 @@ export const DURATION_OPTIONS = [
{ label: "半年内", value: "months:6" }, { label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" }, { label: "一年内", value: "years:1" },
] as const ] as const
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
// 网站配置里的班级列表对得上。
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
// 是同一条规则的另一份,改这里必须同时改那边。
export const CLASS_NAME_MIN_DIGITS = 3
export const CLASS_NAME_MAX_DIGITS = 4
/** 合法班级号:3~4 位纯数字 */
export const CLASS_NAME_RE = new RegExp(
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
)
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
export const USERNAME_CLASS_RE = new RegExp(
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
)
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
export const REACTIONS: {
key: ReactionKey
label: string
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
},
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
]
+24 -1
View File
@@ -1,7 +1,14 @@
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types" import { User } from "./types"
import { USER_TYPE } from "./constants" import { USER_TYPE } from "./constants"
import { strFromU8, strToU8, unzlibSync, zlibSync } from "fflate" import {
strFromU8,
strToU8,
unzlibSync,
zipSync,
zlibSync,
type Zippable,
} from "fflate"
import copyTextFallback from "copy-text-to-clipboard" import copyTextFallback from "copy-text-to-clipboard"
import { customAlphabet } from "nanoid" import { customAlphabet } from "nanoid"
@@ -208,6 +215,22 @@ export function atou(base64: string): string {
return strFromU8(unzipped) return strFromU8(unzipped)
} }
/**
* zip Blob
* @param files
* @param mtime
*/
export function createZipBlob(
files: { name: string; content: string }[],
mtime: Date = new Date(),
): Blob {
const entries: Zippable = {}
for (const f of files) {
entries[f.name] = strToU8(f.content)
}
return new Blob([zipSync(entries, { mtime })], { type: "application/zip" })
}
/** /**
* *
* 使 Clipboard API modal 使退 copy-text-to-clipboard * 使 Clipboard API modal 使退 copy-text-to-clipboard
-2
View File
@@ -12,7 +12,6 @@ export function usePermissions() {
canManageUsers: computed(() => userStore.isSuperAdmin), canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin), canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin), canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin), canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin), canSendMessages: computed(() => userStore.isSuperAdmin),
@@ -65,7 +64,6 @@ export function checkRoutePermission(routeName: string): boolean {
"admin announcement list", "admin announcement list",
"admin announcement create", "admin announcement create",
"admin announcement edit", "admin announcement edit",
"admin comment list",
"admin message list", "admin message list",
"admin tutorial list", "admin tutorial list",
"admin tutorial create", "admin tutorial create",
+78 -33
View File
@@ -12,15 +12,6 @@ export interface Profile {
} }
} }
} }
oi_problems_status: {
problems: {
[key: string]: {
_id: string
score: number
status: number
}
}
}
avatar: string avatar: string
blog: null blog: null
mood: string mood: string
@@ -29,7 +20,6 @@ export interface Profile {
major: string major: string
language: string language: string
accepted_number: number accepted_number: number
total_score: number
submission_number: number submission_number: number
} }
@@ -45,7 +35,6 @@ export interface User {
problem_permission: string problem_permission: string
create_time: Date create_time: Date
last_login: Date last_login: Date
two_factor_auth: boolean
open_api: boolean open_api: boolean
is_disabled: boolean is_disabled: boolean
password?: string password?: string
@@ -87,7 +76,7 @@ export interface SQLDisplay {
tables: SQLDisplayTable[] tables: SQLDisplayTable[]
expected: expected:
| { | {
columns: string[] columns: SQLDisplayColumn[]
rows: (string | number | null)[][] rows: (string | number | null)[][]
total_rows: number total_rows: number
truncated: boolean truncated: boolean
@@ -114,6 +103,12 @@ export interface Tag {
name: string name: string
} }
export interface AdminTag {
id: number
name: string
problem_count: number
}
export interface TestcaseUploadedReturns { export interface TestcaseUploadedReturns {
id: string id: string
info: Testcase[] info: Testcase[]
@@ -145,17 +140,10 @@ export interface Problem {
last_update_time: null last_update_time: null
time_limit: number time_limit: number
memory_limit: number memory_limit: number
io_mode: {
input: string
output: string
io_mode: string
}
rule_type: string
difficulty: "Low" | "Mid" | "High" difficulty: "Low" | "Mid" | "High"
source: string source: string
prompt: string prompt: string
answers: { language: LANGUAGE; code: string }[] answers: { language: LANGUAGE; code: string }[]
total_score: number
submission_number: number submission_number: number
accepted_number: number accepted_number: number
statistic_info: { [key: string]: number } statistic_info: { [key: string]: number }
@@ -189,7 +177,11 @@ export interface Problem {
sql_display?: SQLDisplay | null sql_display?: SQLDisplay | null
} }
export type AdminProblem = Problem & AlterProblem export type AdminProblem = Problem &
AlterProblem & {
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
top_reaction?: { type: ReactionKey; count: number } | null
}
interface AlterProblem { interface AlterProblem {
test_case_id: string test_case_id: string
@@ -207,7 +199,6 @@ type ExcludeKeys =
| "statistic_info" | "statistic_info"
| "accepted_number" | "accepted_number"
| "submission_number" | "submission_number"
| "total_score"
export type BlankProblem = Omit<Problem, ExcludeKeys> & export type BlankProblem = Omit<Problem, ExcludeKeys> &
AlterProblem & { id?: number } AlterProblem & { id?: number }
@@ -239,6 +230,8 @@ export interface AdminProblemFiltered {
has_ast_rules: boolean has_ast_rules: boolean
allow_flowchart: boolean allow_flowchart: boolean
show_flowchart: boolean show_flowchart: boolean
// 比赛题目列表接口不返回这个字段
top_reaction?: { type: ReactionKey; count: number } | null
} }
// 题单相关类型 // 题单相关类型
@@ -500,7 +493,6 @@ export interface Rank {
} }
} }
} }
oi_problems_status: {}
real_name: null | string real_name: null | string
avatar: string avatar: string
blog: null blog: null
@@ -510,7 +502,6 @@ export interface Rank {
major: null | string major: null | string
language: null | string language: null | string
accepted_number: number accepted_number: number
total_score: number
submission_number: number submission_number: number
} }
@@ -609,16 +600,20 @@ export interface CreateMessage {
message: string message: string
} }
export interface Comment { export type ReactionKey =
id: number | "too_easy"
problem: string | "too_hard"
submission: string | "confusing"
content: string | "buggy"
description_rating: 1 | 2 | 3 | 4 | 5 | "learned"
difficulty_rating: 1 | 2 | 3 | 4 | 5 | "interesting"
comprehensive_rating: 1 | 2 | 3 | 4 | 5 | "want_explain"
create_time: Date
user: SampleUser export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState {
mine: ReactionKey | null
counts: ReactionCounts | null
} }
export interface Tutorial { export interface Tutorial {
@@ -743,3 +738,53 @@ export interface DetailsData {
} }
export type Grade = "S" | "A" | "B" | "C" 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 Components from "unplugin-vue-components/vite"
import { NaiveUiResolver } from "unplugin-vue-components/resolvers" import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
// 显式保留 Chrome 90 所需的运行时兼容项,避免 plugin-legacy 对每个产物执行 Babel 扫描。
// 升级前端依赖后需要重新审计此列表。
const polyfills = [
"es.aggregate-error.cause",
"es.array-buffer.detached",
"es.array-buffer.transfer-to-fixed-length",
"es.array-buffer.transfer",
"es.array.at",
"es.array.find-last-index",
"es.array.push",
"es.array.to-reversed",
"es.array.to-sorted",
"es.array.to-spliced",
"es.array.with",
"es.error.cause",
"es.iterator.constructor",
"es.iterator.drop",
"es.iterator.every",
"es.iterator.filter",
"es.iterator.find",
"es.iterator.flat-map",
"es.iterator.for-each",
"es.iterator.map",
"es.iterator.reduce",
"es.iterator.some",
"es.iterator.to-array",
"es.json.parse",
"es.json.stringify",
"es.map.get-or-insert-computed",
"es.map.get-or-insert",
"es.object.has-own",
"es.regexp.flags",
"es.set.difference.v2",
"es.set.intersection.v2",
"es.set.is-disjoint-from.v2",
"es.set.is-subset-of.v2",
"es.set.is-superset-of.v2",
"es.set.symmetric-difference.v2",
"es.set.union.v2",
"es.string.at-alternative",
"es.typed-array.at",
"es.typed-array.find-last-index",
"es.typed-array.find-last",
"es.typed-array.set",
"es.typed-array.to-reversed",
"es.typed-array.to-sorted",
"es.typed-array.with",
"es.uint8-array.set-from-base64",
"es.uint8-array.set-from-hex",
"es.uint8-array.to-base64",
"es.uint8-array.to-hex",
"es.weak-map.get-or-insert-computed",
"es.weak-map.get-or-insert",
"esnext.array.group",
"web.dom-exception.stack",
"web.immediate",
"web.structured-clone",
"web.url-search-params.delete",
"web.url-search-params.has",
"web.url-search-params.size",
"web.url.can-parse",
]
// index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现) // index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现)
function injectMaxkb(maxkbUrl: string | undefined): Plugin { function injectMaxkb(maxkbUrl: string | undefined): Plugin {
return { return {
@@ -48,7 +111,7 @@ export default defineConfig(({ mode }) => {
legacy({ legacy({
renderLegacyChunks: false, renderLegacyChunks: false,
modernTargets: "chrome>=90", modernTargets: "chrome>=90",
modernPolyfills: true, modernPolyfills: polyfills,
}), }),
AutoImport({ AutoImport({
imports: [ imports: [