Compare commits

...
58 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
48 changed files with 2400 additions and 1607 deletions
+450 -571
View File
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -20,47 +20,46 @@
"@vue-flow/minimap": "^1.5.4", "@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1", "@vue-flow/node-resizer": "^1.5.1",
"@vue-flow/node-toolbar": "^1.1.1", "@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"@vueuse/router": "^14.3.0", "@vueuse/router": "^14.4.0",
"@wangeditor-next/editor": "^6.0.2", "@wangeditor-next/editor": "^6.2.0",
"@wangeditor-next/editor-for-vue": "^5.1.14", "@wangeditor-next/editor-for-vue": "^6.2.0",
"axios": "^1.18.1", "axios": "^1.19.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5", "chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"md-editor-v3": "^6.5.4", "md-editor-v3": "^6.5.6",
"mermaid": "^11.16.0", "mermaid": "^11.16.1",
"mermaid-legacy": "npm:mermaid@^9.4.3", "mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"nanoid": "^6.0.0", "nanoid": "^6.0.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"skulpt": "^1.2.0", "skulpt": "^1.2.0",
"vue": "^3.5.40", "vue": "^3.5.41",
"vue-chartjs": "^5.3.4", "vue-chartjs": "^5.3.4",
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
"vue-router": "^5.2.0", "vue-router": "^5.2.0",
"y-codemirror.next": "^0.3.5", "y-codemirror.next": "^0.3.5",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.31" "yjs": "^13.6.32"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.1", "@iconify/vue": "^5.0.1",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^26.1.1", "@types/node": "^26.1.2",
"@vitejs/plugin-legacy": "^8.2.2", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5" "vite": "^8.2.1"
} }
} }
@@ -7,6 +7,12 @@ import {
type MetricOption, type MetricOption,
} from "admin/api" } from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_LABEL } from "utils/constants"
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
label,
value,
}))
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
@@ -118,15 +124,7 @@ async function save() {
</n-flex> </n-flex>
</n-form-item> </n-form-item>
<n-form-item label="稀有度"> <n-form-item label="稀有度">
<n-select <n-select v-model:value="form.rarity" :options="rarityOptions" />
v-model:value="form.rarity"
:options="[
{ label: '青铜', value: 'bronze' },
{ label: '白银', value: 'silver' },
{ label: '黄金', value: 'gold' },
{ label: '白金', value: 'platinum' },
]"
/>
</n-form-item> </n-form-item>
<n-form-item label="指标" required> <n-form-item label="指标" required>
<n-select <n-select
+10 -7
View File
@@ -7,6 +7,7 @@ import {
} from "admin/api" } from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue" import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
@@ -16,12 +17,14 @@ const loading = ref(false)
const showModal = ref(false) const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null) const editing = ref<AdminAchievement | null>(null)
const RARITY_LABEL: Record<string, string> = { // 下架的只在有的时候才提,全部上架时标题不啰嗦
bronze: "青铜", const title = computed(() => {
silver: "白银", if (!list.value.length) return "成就管理"
gold: "黄金", const offline = list.value.filter((a) => !a.visible).length
platinum: "白金", return offline
} ? `成就管理(${list.value.length} 条,${offline} 条下架)`
: `成就管理(${list.value.length} 条)`
})
async function load() { async function load() {
loading.value = true loading.value = true
@@ -115,7 +118,7 @@ onMounted(load)
</script> </script>
<template> <template>
<n-card title="成就管理"> <n-card :title="title">
<template #header-extra> <template #header-extra>
<n-button type="primary" @click="create">新建成就</n-button> <n-button type="primary" @click="create">新建成就</n-button>
</template> </template>
+32 -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
-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,
@@ -156,15 +156,13 @@ async function preview() {
async function upload() { async function upload() {
isUploading.value = true isUploading.value = true
try { try {
const now = new Date()
const data = scripts.value const data = scripts.value
.filter((s) => s.sql.trim()) .filter((s) => s.sql.trim())
.map((s, i) => ({ .map((s, i) => ({
name: `${i + 1}.sql`, name: `${i + 1}.sql`,
input: s.sql, content: s.sql,
lastModified: now,
})) }))
const blob = await downloadZip(data).blob() const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" }) const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true }) const res = await uploadTestcases(file, { sql: true })
@@ -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)
+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>
+27 -2
View File
@@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Server } from "utils/types" import type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
} }
async function saveWebsiteConfig() { async function saveWebsiteConfig() {
await editWebsite(websiteConfig) // 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
if (invalid.length) {
message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return
}
try {
await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功") message.success("网站配置保存成功")
getWebsiteConfig() getWebsiteConfig()
configStore.getConfig() configStore.getConfig()
@@ -214,7 +233,13 @@ onMounted(() => {
</n-form> </n-form>
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="班级列表"> <n-form-item label="班级列表">
<n-dynamic-tags v-model:value="websiteConfig.class_list" /> <n-flex vertical size="small">
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-flex align="center"> <n-flex align="center">
+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
+23 -15
View File
@@ -172,7 +172,6 @@ function createNewUser() {
problem_permission: "None", problem_permission: "None",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
two_factor_auth: false,
open_api: false, open_api: false,
is_disabled: false, is_disabled: false,
password: "", password: "",
@@ -197,20 +196,29 @@ async function handleEditUser() {
message.error("密码长度不得小于 6") message.error("密码长度不得小于 6")
return return
} }
if (create.value) { // http 拦截器只对 login-required / permission-denied 自动弹提示,
const newUser = [ // 其余业务错误(比如班级号位数不对)不接住就什么都不显示
[ try {
userEditing.value.username, if (create.value) {
password.value, const newUser = [
userEditing.value.email, [
userEditing.value.real_name, userEditing.value.username,
], password.value,
] userEditing.value.email,
await importUsers(newUser) userEditing.value.real_name,
listUsers() ],
} else { ]
const user = Object.assign(userEditing.value, { password: password.value }) await importUsers(newUser)
await editUser(user) listUsers()
} else {
const user = Object.assign(userEditing.value, {
password: password.value,
})
await editUser(user)
}
} catch (err: any) {
message.error("保存失败:" + err.data)
return
} }
userEditing.value = null userEditing.value = null
password.value = "" password.value = ""
@@ -1,22 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { Achievement } from "utils/types" import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>() const props = defineProps<{ achievement: Achievement }>()
const RARITY_COLOR: Record<string, string> = { // 边框用原色,tag 里的文字用跟主题走的那套
bronze: "#b87333", const rarityTextColor = useRarityColor()
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
const RARITY_LABEL: Record<string, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null, // 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条 // 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
@@ -67,96 +58,70 @@ const unlockDate = computed(() => {
:class="{ locked: !achievement.unlocked, rare: isRare }" :class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }" :style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
> >
<div class="row"> <n-thing>
<div class="icon"> <template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" /> <AchievementIcon :icon="achievement.icon" :size="32" />
</div> </template>
<div class="body">
<div class="title"> <template #header>
<span class="name">{{ achievement.name }}</span> <n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag <n-tag
size="tiny" size="tiny"
:color="{ :color="{
borderColor: RARITY_COLOR[achievement.rarity], borderColor: RARITY_COLOR[achievement.rarity],
textColor: RARITY_COLOR[achievement.rarity], textColor: rarityTextColor[achievement.rarity],
}" }"
> >
{{ RARITY_LABEL[achievement.rarity] }} {{ RARITY_LABEL[achievement.rarity] }}
</n-tag> </n-tag>
</div> </n-flex>
<div class="desc">{{ achievement.description }}</div> </template>
<div v-if="achievement.unlocked" class="meta"> <template #description>
<span>{{ unlockDate }}</span> <n-text depth="3">{{ achievement.description }}</n-text>
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span> </template>
</div>
<div v-else-if="showProgressBar" class="meta"> <n-flex align="center" :size="8" :wrap="false">
<template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</template>
<template v-else-if="showProgressBar">
<n-progress <n-progress
style="flex: 1"
type="line" type="line"
:percentage="percent" :percentage="percent"
:height="6" :height="6"
:show-indicator="false" :show-indicator="false"
/> />
<span class="progress-text"> <n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }} {{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</span> </n-text>
</div> </template>
<div v-else-if="showBestSoFar" class="meta"> <template v-else-if="showBestSoFar">
<span class="progress-text"> <n-text depth="3" class="nowrap">
目标 {{ achievement.threshold }} 目标 {{ achievement.threshold }}
</span> </n-text>
<span v-if="achievement.progress !== null" class="rate"> <n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
当前最好 {{ achievement.progress }} 当前最好 {{ achievement.progress }}
</span> </n-text>
</div> </template>
<div v-else class="meta"> <n-text v-else depth="3" class="nowrap">
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span> {{ achievement.unlock_rate }}% 的人获得
</div> </n-text>
</div> </n-flex>
</div> </n-thing>
</n-card> </n-card>
</template> </template>
<style scoped> <style scoped>
.row { .nowrap {
display: flex;
gap: 12px;
align-items: flex-start;
}
.icon {
font-size: 32px;
line-height: 1;
}
.body {
flex: 1;
min-width: 0;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.desc {
margin-top: 4px;
font-size: 13px;
opacity: 0.75;
}
.meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.65;
}
.meta :deep(.n-progress) {
flex: 1;
}
.progress-text {
white-space: nowrap; white-space: nowrap;
} }
.locked { .locked {
+141 -84
View File
@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { getAchievements, getAchievementSummary } from "oj/achievement/api" import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api" import { getUserBadges } from "oj/api"
import type { Achievement, AchievementSummary } from "utils/types" import { useBreakpoints } from "shared/composables/breakpoints"
import { useRarityColor } from "shared/composables/rarity"
import type {
Achievement,
AchievementRarity,
AchievementSummary,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue" import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge { interface UserBadge {
@@ -13,14 +19,37 @@ interface UserBadge {
description: string description: string
icon: string icon: string
} }
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
} }
const route = useRoute() const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined) const name = computed(() => (route.query.name as string) || undefined)
// 标签和进度条同色,整行读作一个单位
const rarityColor = useRarityColor()
const achievements = ref<Achievement[]>([]) const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null) const summary = ref<AchievementSummary | null>(null)
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
const RARITY_RANK: Record<AchievementRarity, number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
}
const rarities = computed(() =>
[...(summary.value?.rarity ?? [])].sort(
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
),
)
const badges = ref<UserBadge[]>([]) const badges = ref<UserBadge[]>([])
const { isDesktop } = useBreakpoints()
const tab = ref("all") const tab = ref("all")
const loading = ref(true) const loading = ref(true)
@@ -55,26 +84,52 @@ watch(name, load)
<template> <template>
<div class="hall"> <div class="hall">
<n-spin :show="loading"> <!-- delay 50ms缓存命中时数据几乎立刻回来不闪一下转圈 -->
<n-card v-if="summary" class="overview"> <n-spin :show="loading" :delay="50" style="min-height: 240px">
<div class="overview-row"> <n-card v-if="summary">
<div class="percent"> <n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
<div class="big">{{ summary.percent }}%</div> <n-flex vertical align="center" :size="6">
<div class="sub"> <n-progress
{{ summary.unlocked }} / {{ summary.total }} 已获得 type="circle"
</div> :percentage="summary.percent"
</div> :stroke-width="8"
<div class="rarity">
<div
v-for="r in summary.rarity"
:key="r.rarity"
class="rarity-item"
> >
<div class="rarity-label">{{ r.label }}</div> <n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
<div class="rarity-count">{{ r.unlocked }} / {{ r.total }}</div> {{ summary.percent }}%
</div> </n-text>
</div> </n-progress>
</div> <n-text depth="3" class="nowrap">
已获得 {{ summary.unlocked }} / {{ summary.total }}
</n-text>
</n-flex>
<n-flex vertical :size="8" class="rarity">
<n-flex
v-for="r in rarities"
:key="r.rarity"
align="center"
:wrap="false"
:size="10"
>
<n-text strong :style="{ color: rarityColor[r.rarity] }">
{{ r.label }}
</n-text>
<n-progress
style="flex: 1"
type="line"
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
:height="6"
:border-radius="3"
:fill-border-radius="3"
:color="rarityColor[r.rarity]"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ r.unlocked }} / {{ r.total }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card> </n-card>
<n-tabs v-model:value="tab" type="line" class="tabs"> <n-tabs v-model:value="tab" type="line" class="tabs">
@@ -84,23 +139,61 @@ watch(name, load)
<n-tab name="badges">题单奖章</n-tab> <n-tab name="badges">题单奖章</n-tab>
</n-tabs> </n-tabs>
<div v-if="tab !== 'badges'" class="grid"> <template v-if="tab !== 'badges'">
<AchievementCard v-for="a in filtered" :key="a.id" :achievement="a" /> <n-grid
<n-empty v-if="!filtered.length" description="这里还什么都没有" /> v-if="filtered.length"
</div> responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="a in filtered" :key="a.id">
<AchievementCard :achievement="a" />
</n-gi>
</n-grid>
<!-- 加载中不显示空态不然首屏会闪一下"什么都没有" -->
<n-empty v-else-if="!loading" description="这里还什么都没有" />
</template>
<div v-else class="grid"> <template v-else>
<n-card v-for="b in badges" :key="b.id" size="small"> <n-grid
<div class="badge-row"> v-if="badges.length"
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" /> responsive="screen"
<div> cols="1 s:2 l:3"
<div class="badge-name">{{ b.badge?.name }}</div> :x-gap="12"
<div class="badge-desc">{{ b.badge?.description }}</div> :y-gap="12"
</div> >
</div> <n-gi v-for="b in badges" :key="b.id">
</n-card> <n-card size="small">
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" /> <n-thing
</div> :title="b.badge?.name"
:description="b.badge?.description"
>
<template #avatar v-if="b.badge?.icon">
<n-avatar
:size="40"
:src="b.badge.icon"
color="transparent"
object-fit="contain"
/>
</template>
<n-text v-if="b.problemset" depth="3" class="source">
来自题单
<router-link
:to="{
name: 'problemset',
params: { problemSetId: b.problemset.id },
}"
>
{{ b.problemset.title }}
</router-link>
</n-text>
</n-thing>
</n-card>
</n-gi>
</n-grid>
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
</template>
</n-spin> </n-spin>
</div> </div>
</template> </template>
@@ -111,60 +204,24 @@ watch(name, load)
margin: 0 auto; margin: 0 auto;
padding: 16px; padding: 16px;
} }
.overview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.big {
font-size: 40px;
font-weight: 700;
line-height: 1;
}
.sub {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
}
.rarity { .rarity {
display: flex; flex: 1;
gap: 20px; max-width: 420px;
} }
.rarity-item { .nowrap {
text-align: center; white-space: nowrap;
}
.rarity-label {
font-size: 12px;
opacity: 0.7;
}
.rarity-count {
font-weight: 600;
margin-top: 2px;
} }
.tabs { .tabs {
margin: 16px 0; margin: 16px 0;
} }
.grid { .source {
display: grid; display: block;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-top: 6px;
gap: 12px; font-size: 13px;
} }
.badge-row { .source a {
display: flex; color: inherit;
gap: 12px; text-decoration: underline;
align-items: center; text-underline-offset: 2px;
}
.badge-icon {
width: 40px;
height: 40px;
}
.badge-name {
font-weight: 600;
}
.badge-desc {
font-size: 12px;
opacity: 0.7;
} }
</style> </style>
+11 -14
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有问题
+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
}
@@ -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>
@@ -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>
+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>
+49 -32
View File
@@ -138,6 +138,7 @@ export function useFireworks() {
confetti({ confetti({
particleCount: 12, particleCount: 12,
angle: 270,
spread: 100, spread: 100,
startVelocity: 12, startVelocity: 12,
gravity: 0.6, gravity: 0.6,
@@ -145,7 +146,7 @@ export function useFireworks() {
ticks: 200, ticks: 200,
scalar: 1.2, scalar: 1.2,
shapes: ["star"], shapes: ["star"],
origin: { x: Math.random(), y: -0.1 }, origin: { x: Math.random(), y: 0 },
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"], colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
}) })
}, 150) }, 150)
@@ -285,29 +286,32 @@ export function useFireworks() {
// 效果7: 螺旋上升 // 效果7: 螺旋上升
() => { () => {
const defaults = { const steps = 16
spread: 360, const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
ticks: 100,
gravity: 0,
decay: 0.94,
startVelocity: 30,
}
function shoot() { for (let step = 0; step < steps; step++) {
confetti({ runLater(() => {
...defaults, const progress = step / (steps - 1)
particleCount: 50, const phase = step * (Math.PI / 4)
scalar: 1.2,
shapes: ["circle", "square"],
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
})
}
runLater(shoot, 0) confetti({
runLater(shoot, 100) particleCount: 7,
runLater(shoot, 200) angle: 90 + Math.cos(phase) * 18,
runLater(shoot, 300) spread: 25,
runLater(shoot, 400) startVelocity: 16,
gravity: 0.25,
decay: 0.94,
ticks: 100,
scalar: 0.9,
shapes: ["circle", "square"],
origin: {
x: 0.5 + Math.sin(phase) * 0.14,
y: 0.85 - progress * 0.55,
},
colors,
})
}, step * 65)
}
}, },
// 效果8: 表情包烟花 // 效果8: 表情包烟花
@@ -315,6 +319,7 @@ export function useFireworks() {
loadEmojiShapes().then((emojiShapes) => { loadEmojiShapes().then((emojiShapes) => {
confetti({ confetti({
shapes: emojiShapes, shapes: emojiShapes,
flat: true,
scalar: 6, scalar: 6,
particleCount: 18, particleCount: 18,
spread: 360, spread: 360,
@@ -349,16 +354,28 @@ export function useFireworks() {
// 效果10: 礼花瀑布 // 效果10: 礼花瀑布
() => { () => {
confetti({ const animationEnd = Date.now() + 1800
particleCount: 150, const colors = ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"]
spread: 180,
startVelocity: 0, const interval = setInterval(() => {
gravity: 1.2, if (Date.now() >= animationEnd) return stopTimer(interval)
decay: 0.95,
ticks: 300, for (let column = 0; column < 6; column++) {
origin: { x: 0.5, y: 0 }, confetti({
colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"], particleCount: 2,
}) angle: 270,
spread: 30,
startVelocity: 5,
gravity: 1,
decay: 0.96,
ticks: 240,
scalar: 1.1,
origin: { x: (column + Math.random()) / 6, y: 0 },
colors,
})
}
}, 120)
timers.add(interval)
}, },
] ]
+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")
@@ -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)
+8 -80
View File
@@ -4,15 +4,10 @@ import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { import type { AchievementSummary, Profile } from "utils/types"
AchievementSummary,
Profile,
UserBadge as UserBadgeType,
} from "utils/types"
import { getAchievementSummary } from "oj/achievement/api" import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics, getUserBadges } from "../api" import { getMetrics } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
const route = useRoute() const route = useRoute()
@@ -24,7 +19,6 @@ const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("") const latestSubmissionAt = ref("")
const toLatestAt = ref("") const toLatestAt = ref("")
const learnDuration = ref("") const learnDuration = ref("")
const userBadges = ref<GroupedBadge[]>([])
const achievementSummary = ref<AchievementSummary | null>(null) const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
@@ -65,40 +59,6 @@ const hasMoreProblems = computed(
() => problems.value.length > itemsPerRow.value * 3, () => problems.value.length > itemsPerRow.value * 3,
) )
//
interface GroupedBadge {
icon: string
count: number
badges: UserBadgeType[]
latestEarnedTime: Date
}
//
function groupBadgesByIcon(badges: UserBadgeType[]): GroupedBadge[] {
const grouped = new Map<string, UserBadgeType[]>()
//
badges.forEach((badge) => {
const icon = badge.badge.icon
if (!grouped.has(icon)) {
grouped.set(icon, [])
}
grouped.get(icon)!.push(badge)
})
//
return Array.from(grouped.entries())
.map(([icon, badgeList]) => ({
icon,
count: badgeList.length,
badges: badgeList,
latestEarnedTime: new Date(
Math.max(...badgeList.map((b) => new Date(b.earned_time).getTime())),
),
}))
.sort((a, b) => a.icon.localeCompare(b.icon))
}
async function init() { async function init() {
toggle(true) toggle(true)
try { try {
@@ -113,23 +73,9 @@ async function init() {
}) })
ac.sort() ac.sort()
problems.value = ac problems.value = ac
const promises: Promise<{ data: any }>[] = []
if (profile.value.submission_number > 0) { if (profile.value.submission_number > 0) {
promises.push(getMetrics(profile.value.user.id)) const metricsRes = await getMetrics(profile.value.user.id)
}
if (route.query.name) {
promises.push(getUserBadges(route.query.name as string))
} else {
promises.push(getUserBadges())
}
const results = await Promise.all(promises)
// metrics
if (profile.value.submission_number > 0) {
const metricsRes = results[0]
firstSubmissionAt.value = parseTime(metricsRes.data.first) firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest) latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays( toLatestAt.value = durationToDays(
@@ -141,9 +87,6 @@ async function init() {
metricsRes.data.latest, metricsRes.data.latest,
) )
} }
// badges
userBadges.value = groupBadgesByIcon(results[1].data)
} finally { } finally {
toggle(false) toggle(false)
} }
@@ -245,14 +188,14 @@ onMounted(() => {
<n-grid <n-grid
v-if="profile && profile.submission_number > 0" v-if="profile && profile.submission_number > 0"
class="wrapper" class="wrapper"
:cols="2" :cols="isDesktop ? 2 : 1"
:x-gap="10" :x-gap="10"
:y-gap="10" :y-gap="10"
> >
<n-gi v-for="item in metrics" :key="item.content"> <n-gi v-for="item in metrics" :key="item.content">
<n-card hoverable> <n-card hoverable>
<n-flex align="center"> <n-flex align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="50" /> <Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
<div> <div>
<Component :is="isDesktop ? NH2 : NH3" class="number"> <Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" /> <n-number-animation v-if="item.animate" :to="item.title" />
@@ -297,6 +240,9 @@ onMounted(() => {
</n-button> </n-button>
</n-flex> </n-flex>
<n-flex align="center" :size="10" class="achievement-recent"> <n-flex align="center" :size="10" class="achievement-recent">
<n-text v-if="achievementSummary.recent.length" depth="3">
最近获得
</n-text>
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id"> <n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger> <template #trigger>
<span class="achievement-icon"> <span class="achievement-icon">
@@ -311,24 +257,6 @@ onMounted(() => {
</n-flex> </n-flex>
</n-card> </n-card>
<!-- 徽章展示卡片 -->
<n-card
v-if="!loading && profile && userBadges.length > 0"
class="wrapper"
hoverable
>
<template #header>
<n-h4 style="margin: 0">获得的徽章</n-h4>
</template>
<n-flex wrap>
<GroupedUserBadge
v-for="groupedBadge in userBadges"
:key="groupedBadge.icon"
:grouped-badge="groupedBadge"
/>
</n-flex>
</n-card>
<n-descriptions v-if="!loading && profile" class="wrapper" bordered> <n-descriptions v-if="!loading && profile" class="wrapper" bordered>
<n-descriptions-item v-if="!!problems.length"> <n-descriptions-item v-if="!!problems.length">
<template #label> <template #label>
+6 -6
View File
@@ -171,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",
@@ -251,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",
-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")
} }
+1 -7
View File
@@ -1,18 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue" import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useAchievementStore } from "shared/store/achievement" import { useAchievementStore } from "shared/store/achievement"
import { RARITY_COLOR } from "utils/constants"
const store = useAchievementStore() const store = useAchievementStore()
const { current, queue } = storeToRefs(store) const { current, queue } = storeToRefs(store)
const visible = ref(false) const visible = ref(false)
const RARITY_COLOR: Record<string, string> = {
bronze: "#b87333",
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let gapTimer: ReturnType<typeof setTimeout> | null = null let gapTimer: ReturnType<typeof setTimeout> | null = null
@@ -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>
+4 -6
View File
@@ -22,12 +22,10 @@ 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
+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 { 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,
)
}
+1 -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(
@@ -173,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"
-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,
} }
}) })
+82 -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,
@@ -276,6 +276,44 @@ export enum ChartType {
Activity, Activity,
} }
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
// 青铜往红偏(色相 23),黄金往纯黄推(色相 47),中间隔开两档亮度,
// 不然两个都落在橙棕区,扫一眼分不出来
export const RARITY_COLOR: Record<AchievementRarity, string> = {
bronze: "#c2703d",
silver: "#9fa6b2",
gold: "#f2c012",
platinum: "#7dd3fc",
}
// 文字要另配一套。上面那组是照深色底调的,搬到白底上白金只有 1.7:1、
// 黄金 2.2: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" },
@@ -287,3 +325,46 @@ export const DURATION_OPTIONS = [
{ label: "半年内", value: "months:6" }, { label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" }, { label: "一年内", value: "years:1" },
] as const ] as const
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
// 网站配置里的班级列表对得上。
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
// 是同一条规则的另一份,改这里必须同时改那边。
export const CLASS_NAME_MIN_DIGITS = 3
export const CLASS_NAME_MAX_DIGITS = 4
/** 合法班级号:3~4 位纯数字 */
export const CLASS_NAME_RE = new RegExp(
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
)
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
export const USERNAME_CLASS_RE = new RegExp(
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
)
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
export const REACTIONS: {
key: ReactionKey
label: string
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
},
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
]
+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",
+27 -12
View File
@@ -35,7 +35,6 @@ export interface User {
problem_permission: string problem_permission: string
create_time: Date create_time: Date
last_login: Date last_login: Date
two_factor_auth: boolean
open_api: boolean open_api: boolean
is_disabled: boolean is_disabled: boolean
password?: string password?: string
@@ -104,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[]
@@ -172,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
@@ -221,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
} }
// 题单相关类型 // 题单相关类型
@@ -589,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 {
+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: [