Compare commits

...

41 Commits

Author SHA1 Message Date
1f4cfc280e R194: 作品管理改「小组维度」列表 + 成绩/提交列表 includeIdle 透传
- src/views/Admin.vue
  · 作品管理一行 = 一个小组(小组编号/作品目录 + 提交状态 + 该组文件与评分 + 人数 + 提交人),
    替代原「按人平铺」(同组 6 人 6 行、5 行未提交)
  · 搜索口径改「小组编号」(姓名已不在列表展示);计数单位 人→组
  · PDF 预览常驻说明 + 「在新标签打开」出口(iframe 白屏时不误导「系统没加载」)
- src/api/admin.js:getSubmissions/getScores 增加 params,供 includeIdle 口径透传
2026-10-22 09:41:57 +08:00
4fd04f106a fix(ui): 组锁成员可从提交页返回工作台 + 免假警告(R192)
现象(用户实测):本科组其他成员进入提交页 → 组锁生效不能提交,但页面无任何返回
入口,只能浏览器后退;移动端/内嵌浏览器更退不出去 ⇒ 被困在提交页。

根因三层,缺一不可:
1. /competition 是全屏页(App.vue 只有 <router-view />,无全局导航)⇒ 页面内不给
   按钮就真的没有出口。
2. 上传模式的「被锁」分支只有 el-alert 文案 + disabled 上传控件,从来没有动作按钮
   (客观题模式被锁是 router.push('/dashboard') 直接踢走,故历史上未暴露)。
3. onBeforeRouteLeave 对 examStarted && !isTimeUp 一律弹「离开将丢失当前提交进度」——
   对被锁用户是假警告(本机没有任何可提交内容),加了按钮也会被二次劝退。

改动(唯一文件):
- 上传卡片 header 新增常驻「← 返回工作台」——不依赖 examStarted(顶部倒计时栏在未开始
  时不存在,放那儿会漏)。
- 组锁 prompt 下方新增 .team-locked-actions:主按钮 + 引导「本组作品已由队友提交,您无需
  任何操作」;alert description 补「您本人无需提交,可直接返回工作台」。
- 新增 leaveCompetition():仅「已选文件未上传」才确认一次 → resetExam() → push('/dashboard')。
  ⛔ 必须先 resetExam() 再 push:否则 ①守卫再弹一次(双确认)②store 是全局单例,
  定时器残留 ⇒ 回工作台后倒计时仍在跑。用 resetExam 而非只 stopExam,是为了与守卫里
  「确认离开后」的清理同口径(两套清理逻辑必漏项)。
- onBeforeRouteLeave 新增豁免 lockedNoContent = isUploadMode && teamLocked。
  ⛔ 豁免面必须写窄,不能只判 teamLocked:当前两者等价(考试组不拉 uploadStatus),
  但只写 teamLocked 会把考试组一并纳入,将来考试组一旦拉 status 就静默取消「交卷前离开」的确认。

验证(真实组锁数据 + 真 chromium,非构造):
- 库里本就有现场:benke_submissions 归属 contestant 11(郑佳怡),contestant 12 同
  team_uid=1 ⇒ 真接口返回 teamLocked=true / memberCount=5 / canUpload=false。
- 登录态用 jwt_fallback_secret 自签 token 注入 localStorage(未改任何人口令)。
- 组锁视角 8/8 PASS(header 出口 · 组锁区块 + 引导 · 上传仍 disabled · 点击落 /dashboard ·
  messageBox=0 · console 零错误);归属人视角 5/5 PASS(无组锁区块 · 出口仍在 · 不误弹)。
- 变异验证:两按钮加 v-if="false" ⇒ 4 条断言转红;备份还原后 diff 为空、复跑 8/8 复绿。
- 数据零改动:12 号口令未变 · benke_submissions 仍 1 行 · 落盘 PDF 字节数原样。
- 静态:verify_frontend_static.mjs PASS(TDZ 0 / F2 0)· SFC compileTemplate 0 error。
2026-10-21 16:49:11 +08:00
96d16129f7 feat(ui): 上传页组锁提示 + 作品列表小组编号与单组下载(R190)
- Competition.vue:新增「组状态」面板(本组是否已有人上传 / 小组编号 / 组内人数);
  被组锁时置灰上传框与上传按钮,并显示归属人提示与组内已上传文件清单。
  文案一律取后端返回(⛔ 不在前端另拼)。删除本人作品后刷新组状态。
- Admin.vue:作品表新增「小组编号 / 作品目录」列 + 「下载本组压缩包」按钮
  (teamUid 优先、回落 aggregationUid,走 /api/admin/team-zip/:group)。
- api/upload.js:getUploadStatus 支持传 axios config(用于 silent 抑制重复弹窗)。

验证:前端 TDZ 扫描 3 文件 PASS;deploy/build-frontend.sh 构建通过,
产物含 team-status-panel / 小组编号 / 下载本组压缩包 等新文案。
2026-10-21 15:20:47 +08:00
ab1c67dbc5 feat(admin-ui): 时间并发冲突自动回读 + 轮播排序脏标记 + 权限热校准
- api/admin.js:setTimes(data, expected);⚠️ 只在基线**非空**时才带 expectedTimes
  (空对象会让后端误判"所有组都要求校验"而恒冲突)。
- Admin.vue(时间设置):timesBaseline 记录 fetchTimes 载入时的服务端旧值,
  ⛔ 不用表单当前值(那样永远等于提交值 ⇒ 校验恒真 = 假保护)。冲突时自动
  fetchTimes 重载(表单已过期,拿它重试只会再冲突);其它失败保留用户输入。
- Admin.vue(轮播):carouselOrderDirty —— 拖拽只改前端顺序、不落库,
  此前卡片"排序: N"用的是渲染下标,拖完立刻变新序号,看起来已生效、刷新却回退。
  现加"排序未保存"标签 + 按钮文案变化,未保存状态可见。
- Admin.vue(权限):切菜单时 8s 节流重拉 /api/admin/me,权限变化即提示。
  原先前端只在 onMounted 拉一次 ⇒ 被改权限后必须手动刷新页面,
  否则停在旧菜单上每个请求都撞 403 且无任何提示。⛔ 不用 location.reload()
  (会丢未提交的表单输入)。
2026-10-21 13:44:38 +08:00
45e77d9f69 build(deps): vite 5.4.21 → 8.3.4(含 plugin-vue 6)+ 修 CSS 拆块导致的主题/悬停回归
安全:消掉 2 条 high。vite 跨 3 个 major(5→8,rollup→rolldown);
@vitejs/plugin-vue 5.2.4 → 6.0.10(peer 覆盖 vite ^5||^6||^7||^8);
锁文件同步刷新使 nanoid / postcss / source-map-js 三条一并消解。

🔴 升级引入的 CSS 顺序回归(本轮实测发现并修复,不是理论风险):
rolldown 把 Element Plus 基础样式拆成独立 css-*.css 块,且 index.html 中
排在入口 CSS **之后**;旧 rollup 把 EP 与 global.css 合并在同一入口 CSS,
EP 在前、项目在后。两者覆盖语句特异性相同 ⇒ 谁在后谁赢 ⇒ 新顺序下反被 EP 压过。
取证:生产旧包 index-CVAb-2kd.css 内 EP 规则 @27768 < 项目覆盖 @57506(旧=正确);
新包 index-rMbH9bHe.css(先)vs css-CNpL_Sag.css(后)。
· --el-color-primary 退回 #409eff ⇒ 登录按钮由深藏青变 Element 亮蓝;
· 文字型按钮 hover 底退回不透明 #f5f7fa ⇒ 此前修好的「白底白字」缺陷复现。

修法=让覆盖与加载顺序无关(⛔ 不是调顺序,顺序由打包器决定不可靠):
· :root → html:root   (0,0,1,1 > 0,0,1,0)
· .el-button.is-text:not(.is-disabled):hover/:focus 加 :root 前缀(4 → 5 个类级)

验证(四级对照,全部可复现):
1. 顺序单变量实验(两组页面除 <link> 顺序外完全相同):
   修复前 A=SAME? → primary #1a3a5c vs #409eff、hover rgba(...) vs #f5f7fa(DIFF);
   修复后两种顺序计算样式完全相同(PASS)。
2. 与生产旧包(旧 vite5 构建)逐项比对 computed style:primary / light-3 /
   主按钮底色 / hover 底色 全部 SAME。
3. 登录页截图 sha256 与生产旧包**逐字节相同**(f5052aa7260b7b47)——
   修复前为 9f074bb1c44329b3。
4. 真 chromium 加载构建产物(vite preview):/login、/admin 首屏、
   成绩管理、选手管理全部 0 console error / 0 pageerror;
   TDZ 静态门禁 38 文件 PASS。
产物:入口 index-DTFvE02d.js · 157 资源 · 73 个 .gz(两次构建 hash 一致=可复现)。
全量电池 57 步:退出码 0 · FAIL 0 · SKIP 0 · 未运行 0(1 项 N/A 为数据前提不成立)。
2026-10-21 12:05:29 +08:00
c8097fab00 feat(admin): R176-R180 公示表设置/新闻附件与撤回/管理员权限页 + 小组编号列 + 筛选清空修复
- R176 公示表设置区(赛项名称/编号两个配置项)+ 成绩管理下载半成品
- R177 新闻详情附件块 + 「发布 ↔ 撤回」按钮成对(v-if/v-else),撤回不删除
- R178 管理员账号页:新增/编辑权限/重置口令/禁用/删除;侧栏身份区显示角色
- R178 无权限空态;菜单按 can() 裁剪(体验层,最终以后端 403 为准)
- R178 成绩总览新增「小组编号」列(三态:编号 / 队伍 #N / 未编组)
- R178 筛选清空修复:Element Plus value-on-clear 默认 undefined ⇒ 改白名单判定 + value-on-clear=""
2026-10-20 11:07:31 +08:00
b7ec541223 feat(admin): R173/R174 大题成绩导入预览(三小问四列制)
- Admin.vue 预览弹窗:由「分数 + 均分到三小问」改为逐项展示第 1/2/3 小问原始填报值 + 合计列
  · 合计由三项求和实时显示,⛔ 表格不再出现均分口径
  · 新增 bigScoreSubCell(row, idx):非数值原样显示,便于定位"填错的是哪格"
  · 状态映射补 SUB_MISSING(小问留空)/ SUB_INVALID(小问非法)/ SUB_OUT_OF_RANGE(小问超上限)
  · 弹窗宽 920 → 1080(四列 + 合计 + 说明)
- src/api/admin.js:previewBigScoreImport 返回结构注释同步(subRaws / subs / total)
2026-10-19 13:03:54 +08:00
4e7dfe09c8 feat(admin): 账号表新增「编辑」入口与选手信息编辑弹窗(R108)
· 账号表新增操作列(fixed=right)+ 编辑按钮
  · 编辑弹窗:可改 name/phone/id_number;group 只读并说明原因(改组别会令
    已作答答卷与新组别抽题/发布开关错配);口径说明 alert + warnings 区
  · api/admin.js 新增 updateContestant(silent: true,调用方负责提示)
  · 修正一处死代码:初版按 e.response.status 判 409/404 —— 实测证伪:
    helper.error() 不改 HTTP 状态,业务错 = HTTP 200 + body.code != 0,
    拦截器以 new Error(res.message) reject(该 Error 无 response 属性)
    => 改用 e.message
2026-10-12 17:20:47 +08:00
1b569a38e6 fix(download): 哨兵按 x-file-size → CD size= 回落(R103 收口)
配套 backend app/utils/sendFile.js 的同一处修复(见该提交):X-Accel-Redirect
分支下 nginx 会丢弃上游自定义响应头 X-File-Size,故后端把同一字节数写进
RFC 6266 §4.1 的 disposition 扩展参数 `size=`(实测 CD 能穿透 X-Accel)。

本提交:
  · 新增 readDeclaredSize(headers):先取 x-file-size,缺失或非正数时回落解析
    Content-Disposition 的 `; size=<n>`;两通道都取不到返回 NaN(调用方跳过校验,
    优雅降级,不误伤)。
  · assertRealFile() 改用它,替换原先只读 x-file-size 的 parseInt。
  · 判据可靠性:filename*= 的值是百分号编码的,其中不可能出现字面量 `size=`,
    且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。

验证:
  · 本地判别力自证 verify_cd_size_channel.js:pass=12 fail=0
    (F0 断言探针判据与本源码逐字同源;F0b 断言确实经由 readDeclaredSize;
     C1 变异测试证明旧形态解不出 ⇒ 判据有判别力;D1/D2/D3 覆盖无误伤边界)
  · 生产 ARM B(经 nginx)实测 CD 的 size=19009 穿透成功
    (r106_prod_sentinel.js pass=11 fail=0)
2026-10-12 11:06:10 +08:00
3085616605 feat(admin): 小组视图「已激活」筛选改按「已有成员激活(可参赛)」口径
R90续三:与后端 getTeams 口径修正同步。

- 筛选下拉文案:已激活(全组已激活)→ 已有成员激活(可参赛)
                 未激活(含部分激活)→ 暂无成员激活
- 汇总行「已激活」改为「全员激活」(与「部分激活」并列,避免与新口径混淆)
- 「当前筛选后」补充全员激活 / 部分激活分布
- 说明行改解释新语义(不再解释上下两处人数差额)
- 删除失效的 teamPartialExtra;teamFilteredSummary 增补
  filteredActivated / filteredPartial 的防御式读取

生产已部署并复验:线上产物与本地产物逐字节一致(sha256 4604d3f1…),
新文案全部在线上 JS 中检出、旧文案完全消失。
2026-10-09 15:53:21 +08:00
092910404a feat(admin): 选手列表新增小组编号/所属学校列,小组视图汇总双口径
R90 人工测试反馈修复(方案 1 + 方案 3-A):

- 选手列表新增「小组编号」「所属学校」两列,便于与小组视图横向对照
- 小组视图汇总行改为「全集 + 当前筛选后」三段式,并新增差额说明行
  · 明确解释「选手列表激活人数」与「已激活小组成员数」的差额来源
    (差额 = 部分激活小组内已激活的选手)
- 新增 teamFilteredSummary / teamPartialExtra computed
  · 防御式读取:后端未同步时自动退回全量口径,不显示 undefined
- 新增 accountTeamLabel() 显示助手与 .team-summary-filtered / .team-summary-hint 样式

生产已部署并复验:线上产物与本地产物逐字节一致(sha256 3d09ea90…),
新增 UI 文案均已在线上 JS 中检出。
2026-10-09 15:27:39 +08:00
27665c2186 feat(admin): 作品管理新增「提交状态」筛选(已提交 / 未提交)
- 工具栏新增「提交状态」下拉(已提交/未提交,clearable),class 与文案均与「成绩总览」同款逐字对齐

- 判据抽为单一真相源 isWorkSubmitted = (row.files||[]).length > 0,同时供表格「未提交」文案 / 计数 / 筛选三处使用,杜绝列表与筛选打架

- filteredSubmissions 增加状态分支(先姓名后状态),分页与 total 自动联动

- 新增计数「共 N 人 · 已提交 X · 未提交 Y |当前筛选出 M 人」;批量下载按钮补 title 明示「下载全组、不受筛选影响」

- 线上验证:R87 已部署(入口 index-Dhsp29Gt.js,gzip_static 命中 CL=106660,旧入口 404)
2026-10-09 10:40:55 +08:00
e7179bfe54 feat(admin): 选手管理新增「小组视图」—— 激活状态与所属学校清单
- api/admin.js:新增 getTeams({ group, activated, includeTest })

- Admin.vue:列表/小组视图切换、组别 + 激活状态 + 含测试三筛选、汇总条、小组表格(所属学校 / 成员数 / 已激活 / 状态)

线上验证:R86 已部署(dist 157 文件 / 70 个 .gz),线上入口 index-DQf5Bj8S.js
2026-10-09 09:33:07 +08:00
680faacd1d feat(admin): 成绩管理 Tab 分区 + 作品管理移除中职/高职 + 分页最小档补 5
R80 三项 UI 改造(单文件 src/views/Admin.vue,+78/-37):

1. 作品管理:移除「中职组 / 高职组」两个选项 —— 这两组已取消 Word 作品上传,
   留着会让人误以为可以下载到作品。顺带清掉因此变成恒真的死判据
   v-if="submissionGroup === 'benke' || submissionGroup === 'jiaoshi'"(批量下载作品按钮)。
   后端 getWorks 仍支持 4 组,保留兼容不删。

2. 分页:9 处 page-sizes 统一补最小 5 档([5,10,20,50];账号管理 [5,10,20,50,100])。
   试卷管理 3 处原本已是 5 档,无需改。动因:成绩管理表体量大、翻页粒度需要更细。

3. 成绩管理:由「一段纵向长页」改为「顶栏 + 三页签」——
   - 顶栏只留组别级动作:组别选择 / 发布·撤回成绩 / 导出Excel / 批量下载作品
   - 表级筛选(姓名 / 提交状态 / 日期范围)下沉到「成绩总览」页签,
     否则切到别的页签时筛选框仍在,会造成「筛的是哪张表」的歧义
   - el-tabs:成绩总览(始终)/ 考生答题记录(仅中职、高职)/ 考生大题作答查证(同上)
   - 大题作答详情 el-dialog 放在 el-tabs 之外:留在页签内会随 v-if 卸载而销毁弹窗状态

   🔴 唯一高危边界:activeScoreTab 停在后两个页签时切到本科/教师,两个页签 v-if 消失
   ⇒ el-tabs 无 active 内容 ⇒ 页签栏在、内容区全空(白屏)。治法:在
   watch(scoreExportGroup) 内复位为 'overview' —— 用 watch 而非 onScoreGroupChange,
   因为 watch 默认 pre-flush 会在组件重渲染之前执行,不会先闪一帧空白。

验证:verify_frontend_tdz PASS(activeScoreTab 声明顺序,本页历史真炸过的坑)·
@vue/compiler-sfc parse/compileTemplate 0 错 · ui-runtime-check 真 chromium 17 步 17/17 PASS。
变异自证 2 轮:恢复中职/高职选项 ⇒ expectNoText 2 项变红;拆掉切组复位 ⇒ 切本科组后
expectText("提交状态") 命中 0 处变红。
2026-10-08 15:19:50 +08:00
4e84257ed8 fix(auth): 密码框反自动填充兜底 —— 进入「设置密码」步骤必须为空
测试人员反馈:激活流程进入「设置密码」步骤时,密码框内已出现 123456(用户未做任何输入)。

取证结论 —— 前端源码不存在任何向密码框写值的赋值点:
  - step3Form.password 初值为空,密码框只有 v-model、无 :value 绑定
  - 全站唯一 .password = 赋值点是 Login.vue 的清空逻辑
  - git log -S "123456" -- Activate.vue 显示该字面量历史上仅出现一次,
    且是 d16c261 新增的 placeholder 文案,从无任何版本把它当默认值
  => 残留只能来自浏览器密码管理器自动填充(测试账号默认口令恰为 123456,
     浏览器保存后会在同源同类表单上回填)

治法(行为级保证,不依赖成因判定):
  - 新增 utils/passwordField.js:purgeAutofilledPasswords()
    仅在表单模型为空时才清 DOM 密码框并同步 v-model,绝不覆盖用户真实输入
  - Activate.vue / ForgotPassword.vue 对称改造:
    1) placeholder 去掉 123456,弱口令示例移到独立 .pwd-tip 提示行
    2) 新增 enterStep3():清空密码字段 -> 进第三步 -> DOM 挂载后再兜底清一次
    3) 第三步 el-form 增加 autocomplete="off"

验证:
  - TDZ 静态门禁 verify_frontend_tdz.mjs PASS
  - deploy/build-frontend.sh EXIT=0(入口 assets/index-DwFQsYjy.js)
  - 部署后线上核验:入口 hash 与本地一致、关键 chunk 全 200、
    旧 chunk 全部保留(新旧共存,无缓存期 404)、gzip_static 命中预压缩产物、
    线上 Activate chunk md5 与本地构建逐字节一致
2026-10-08 11:10:54 +08:00
ac7637f3e1 feat(admin): 综合大题页签增加 Excel 模板下载与批量导入(R73)
api/admin.js 新增 importBigQuestionsExcel(silent:true,错误提示由调用方统一给出,避免同族双弹)。
Admin.vue 大题工具栏新增「下载Excel模板」「Excel批量导入」两按钮 + 隐藏 file input,
复用既有 safeDownload 与 ref 模式;按钮 title 明确提示「先下载模板、按模板列填写、
组别以当前选中组别为准」,解决测试人员找不到模板入口的问题。
2026-10-08 10:08:44 +08:00
39d3da0c93 fix(exam): 选择题选项等宽居中 + 成绩发布门控加固 + 已交卷禁止再进入 2026-09-30 15:46:41 +08:00
0fca4bd996 fix(ui): 其余视图错误提示收敛为「拦截器统一弹」,消除同族双弹
承接 R64(Admin.vue)已定口径,本轮覆盖其余全部视图。

根因:src/api/request.js 响应拦截器已覆盖全部失败路径,且每种失败只弹一次
⇒ 调用方 catch 内再 ElMessage.error 即必然双弹。helper.error 不改 HTTP 状态,
故删除调用方提示不会丢后端文案。

删除 11 处重复提示(跨 5 文件):
- Login.vue        管理员登录 catch(adminLogin 未传 silent ⇒ 拦截器必弹)
- Activate.vue     handleStep1 / handleActivate / resendSms
- ForgotPassword.vue  handleStep1 / handleReset / resendSms
- Competition.vue  submitUploadFiles / deleteUploadedWork / onMounted 外层 catch
- Dashboard.vue    enterContest(warning 形态,同族)

刻意保留(已有显式理由,不得误删):
- silent 路径调用方唯一提示:Login 选手登录、Competition 大题加载/保存/交卷/自动交卷
- 客户端校验(未发请求)、原生 fetch(MockExam 样例模板)、独立 axios 实例(Materials)、
  el-upload on-success(Admin)

另:
- src/api/request.js 增补「错误提示口径」权威块(全站单一源 + 四类例外),
  五个视图各加一行口径指针注释,防回退。
- 无行为变更:仅移除重复提示,finally/return/refreshCaptcha/router.push 全部保留。
2026-09-30 12:57:55 +08:00
ff7137aab6 fix(admin): 错误提示收敛为「拦截器统一弹」,消除同族双弹
背景:Admin.vue 大量 catch { ElMessage.error(...) } 与 api/request.js 响应拦截器重复——\n一次失败请求用户会同时看到两条报错。拦截器已覆盖全部失败路径(业务码≠0 / 403/404/500 /\n超时 / 网络异常),故调用方 catch 不再弹提示。

分类(23 处 → 删 15 / 留 8):\n- 删 15:通用失败(拦截器已弹),保留状态回滚与 console.error\n- 留 2 + 请求加 silent:true:两个导入接口——后端 admin.js 明写「前端已统一加\n  『Excel 导入失败:』前缀,这里只回传纯错误,避免重复前缀」,删调用方会破坏跨层契约\n- 留 1:resetTestData(已 silent);留 3:客户端校验(未发请求);留 2:非 axios 路径

Admin.vue 文件头新增「错误提示口径(R64 收敛)」约定块防回退。\n验证:静态终态 23→9(应删项 0 残留)/ TDZ 15 文件 / 真浏览器 dev+产物 max=1 /\n变异自证 max=2 且还原 md5 一致 / ui-runtime-check 5-5 / build 157 文件 70 .gz /\nci-gates.sh 10 步 EXIT=0。
2026-09-30 12:41:28 +08:00
d16c261273 fix(auth): 口令输入框提示与后端策略对齐(6-20 位,勿用弱口令)
配套 R63 后端口令强度策略:两处 placeholder 由「至少6位」改为「6-20 位,勿用 123456 等弱口令」,让考生在前端即知道限制,避免填完才被后端拒绝。

刻意不在前端实现黑名单校验:那会与后端 lib/password_blacklist.json 形成两份会漂移的清单(后端才是权威闸门)。
2026-09-30 11:56:45 +08:00
a47e823277 fix(auth): 激活页清除残留登录态 + 全站密码框显式 autocomplete 语义
R61/R62:两个独立缺陷(测试人员反馈「设置密码框已有 123456 + 激活后直接进管理后台」)。

① 激活后进管理后台 —— 安全缺陷(公用电脑 = 越权)
Activate.vue 激活成功与「返回登录」都跳 /login,而 router guard
(router/index.js:203-222)见到 isLoggedIn && role==='admin' 会重定向到 /admin。
浏览器残留 admin 登录态时(测试人员刚用 admin 操作过后台),考生会被
直接送进管理后台;且 /api/admin/* 全部挂 adminAuth(router.js:25)
⇒ 残留 token 有效时管理接口真能调。
修法:激活页按「未登录」语义处理 —— onMounted 与激活成功后均 clearAuth()。

② 设置密码页密码框被浏览器预填
密码框未显式声明 autocomplete,而 EP el-input 该 prop 默认 "off",
Chromium 对 type=password 会忽略 off,改用该站点已保存凭据自动填充。
密码规则仅 min:6 ⇒ "123456" 恰好合法 ⇒ 用户不知情下即把密码设成 123456。
修法:6 个密码框显式声明语义 —— 设密码/重置密码 new-password(4 处),
登录 current-password(2 处,含管理员 tab)。

验证:真浏览器 21/21 断言通过(含「残留 admin 态直接访问 /login 必跳 /admin」
对照臂,证明断言非恒真);TDZ 扫描 PASS(26 文件);权威脚本构建
157 文件 / 70 .gz;生产零中断上线(MainPID/NRestarts/ActiveEnterTimestamp 未变)。
2026-09-30 11:39:53 +08:00
ef32d30a57 fix(admin): 「重置测试数据」报错只提示一次(收敛重复弹窗)
resetTestData() 未传 silent,生产环境下后端 403(生产环境禁止重置测试数据)会被响应拦截器(request.js:166)与调用方 catch(Admin.vue:1707)各弹一次,用户在后台看到两条完全相同的报错(R58 反馈)。

改为 silent: true —— 由调用方按上下文统一弹一次「重置失败:<后端原文>」。
2026-09-30 11:06:08 +08:00
f77330ad27 fix(frontend): 重置测试数据文案对齐后端契约(未激活+需短信激活)并透传后端 message
- Admin.vue 确认弹窗:「已激活 + 默认口令」→「未激活 + 默认口令」
  (后端 service/admin.js:1619 实际置 activated:false)
- Admin.vue 提示行:「重置后可直接用默认口令登录重测」→「需先用默认口令完成短信激活才能登录重测」
- Admin.vue 成功提示: 改为透传后端 message(后端 :1730 已含正确指引),不再自拼后丢弃
- api/request.js: 新增可选开关 returnMessage(不传时默认行为完全不变)
- api/admin.js: resetTestData 启用该开关,后端成为唯一真相源

依据: service/admin.js:1619/:1730 + scripts/verify_testdata_mgmt.py:5-9「Req2 重置须重置账号激活状态」
2026-09-30 10:27:40 +08:00
4e7493a809 chore(frontend): 删除分片上传死代码(BACKLOG C3 附带清理)
src/api/upload.js: 删除 uploadChunk / mergeChunks / getChunkStatus
- 三者全仓 0 调用点(唯一调用方 Upload.vue 已在 B2 清理孤儿路由时删除)
- 后端路由 /api/upload/:group/{chunk,merge,chunk-status} 与对应控制器方法**刻意保留**
  —— 只清前端死代码,不动服务端契约(避免影响外部调用方与后续复用)
- 在用的作品上传走 uploadWork()(单次流式 POST /api/upload/:group),不是分片通道

验证:
- vite build exit 0(11.21s)
- 51 个 JS chunk 的 sha256 与改前**完全一致**(Admin-C4QmBgiQ.js 均 231,715 B)
  => 删的是被 tree-shaking 早已剔除的代码,产物字节级无变化
- 管理端功能回归 dist-admin-check.mjs 9/9
- TDZ 静态扫描 37 文件 PASS
2026-09-29 13:17:55 +08:00
3f8316da47 perf(frontend): RichEditor 异步化拆分 Admin chunk(1021 kB → 232 kB)(BACKLOG C6)
定性:Admin-*.js 达单 chunk 1,021 kB(gzip 340 kB),而其中约 800 kB 是**进入管理端
根本用不到**的富文本编辑器。
- Admin.vue 静态 import RichEditor.vue,后者静态 import @wangeditor/editor
  (dist/index.esm.js = 818,777 B)⇒ 编辑器全量并进 Admin chunk。
- 实测三处使用点(446 大题题干 / 1384 新闻 / 1476 轮播)**全部位于 el-dialog 内**,
  而 el-dialog 初始 rendered=false 不渲染内容 ⇒ 首屏不该付这份成本。
- 全仓反证:RichEditor 只被 Admin.vue 引用;@wangeditor 特征串在入口 chunk 0 命中。

处置:改为 defineAsyncComponent 按需加载(最小改动 16+/2−,只动 Admin.vue)
- src/views/Admin.vue: import RichEditor → defineAsyncComponent({ loader: () => import(...) })
- 加 loadingComponent:用 **render 函数**而非 template 字符串 —— 本工程为 runtime-only
  构建,字符串模板需要运行时编译器,会直接报错。
- vue import 列表补 defineAsyncComponent 与 h。
- 无需 manualChunks:唯一引用点 + 动态 import ⇒ Vite 自动分片。

实测(真 chromium + 静态服务 + /api 反代真实后端):
  Admin-*.js        1,045,614 B → 231,715 B(−77.8%)
  RichEditor-*.js   新增 810,862 B(按需加载;gzip 281,958 B)
  首屏 JS 合计      1,684,778 B → 872,736 B(−48.1%)
  首屏最大单 chunk  1,045,614 B → 283,833 B(−72.9%)

验证:
- 新增 perf-r34/c6-chunk-split-check.mjs:21/21 全绿(首屏不含 wangeditor;打开新闻与
  轮播两个弹窗后编辑器 chunk 按需请求且渲染成功;0 pageerror / 0 Failed to resolve)
- 🔴 判别力(变异测试):对**拆分前**的 dist 跑同一脚本,恰好 5 项判红(A4 首屏含
  wangeditor / A5a 1,684,778 B 超预算 / A5b 1,045,614 B 超 500 kB / B2·B3 无按需请求)
  —— 证明断言真的在守,而非「守卫根本没跑」。
- 回归:perf-r34/dist-admin-check.mjs 9/9(el-table 20 行 / 分页中文 / ElMessageBox
  中文按钮 / 零 Failed to resolve)⇒ 管理端功能零回归。
- TDZ 扫描 37 文件 PASS · vite build exit 0 · .gz 预压缩 70 个(含 RichEditor.js.gz)
2026-09-29 12:49:58 +08:00
ecf8d39f03 chore(frontend): 移除孤儿路由 /upload 与 Upload.vue(BACKLOG B2)
定性:/upload 是全仓零 UI 入口的孤儿路由。
- Dashboard.enterContest() 对所有组别统一 router.push('/competition');
  全仓 push('/upload') / replace('/upload') / to="/upload" / name:'Upload' 0 命中,
  Upload.vue 只被 router 一处 import。
- 本科/教师的答案文件上传链路早已由 Competition.vue 的 exam-upload-mode 完整承担
  (zip 独占 / pdf-docx 互斥 / 组合校验 / 覆盖式重传 / 删除)。
- 该页对中职/高职会调用后端不支持的分组接口
  (GET /api/upload/{zhongzhi,gaozhi}/status|submissions → 「无效的组别,仅支持 benke/jiaoshi」),
  因守卫只弹 warning 且注释明写"不强制跳转",随后仍无条件发请求 ⇒ 3 条 console 报错。

处置(三方案中的 ①;②接线会使 Competition.vue 上传模式被孤立,属功能倒退;③被①涵盖):
- src/router/index.js: 删 import Upload 与 /upload 路由块(-9 行)
- src/views/Upload.vue: git rm(-735 行)
- 保留 src/api/upload.js:stores/exam.js 与 Competition.vue 仍在用
  getUploadStatus / uploadWork / deleteWork / getSubmissions

验证:
- 前置:全项目(docs/deploy/nginx/bat/ps1/后端)零外部引用;Upload.vue 无具名导出
- vite build exit 0;dist 异步分片列表已无 Upload-*.js(残留 upload-*.js 是 MD5 库分片,0 页面文案)
- TDZ 扫描 37 文件 PASS(原 38,正好少一)
- 真浏览器:/login 9/9 · /upload 现落 404 页且 0 pageerror / 0 console error
  (修复前:渲染旧上传页 + 3 条 console 报错)
2026-09-29 12:17:48 +08:00
0416ac3360 fix(frontend): Upload.vue 3 处 h('el-button') 改为显式 ElButton(BACKLOG B1)
修复前:Vue3 中 h('el-button') 的字符串标签不解析为组件,创建原生自定义元素
<el-button> ⇒ 无 EP 样式 · type/circle/text 失效 · 「开始上传」的 loading 与
primary 全失效(点击后无进行中反馈)· icon: h(Delete) 被写成垃圾属性。

修法:ElButton 已在同文件 309 行导入 ⇒ 3 处字符串改标识符(ElProgress/ElTag/
ElIcon 已在 R36 迁移)。最小改动:3 insertions / 3 deletions。

验证(新增 perf-r34/upload-check.mjs:静态服务 + 真后端代理 + 真 chromium +
拦截 POST /api/upload/** 防真实写库):
  18/18 全绿 —— 「开始上传」cls='el-button el-button--primary is-loading'
  bg=rgb(26,58,92) == --el-color-primary · 删除按钮 is-circle+el-button--danger ·
  「重新上传」is-text+el-button--danger · 0 个原生 el-button 自定义元素 ·
  0 Failed to resolve · 0 pageerror · 2 个写请求被拦截(零真实写库)
  TDZ 扫描 38 文件 PASS · vite build exit 0

⚠️ 另发现(不在本次修复范围,见 BACKLOG B2):/upload 是全仓零 UI 入口的
孤儿路由,且对中职/高职会调用后端不支持的分组接口(3 条 console 错误)。
2026-09-29 12:01:57 +08:00
86f3b6c28c chore(frontend): 删除孤儿 auto-imports.d.ts
该文件由 unplugin-auto-import 生成(Jun 17 遗留),但该插件既不在
package.json 也未安装,文件永不再被重生成 ⇒ 孤儿。

危害:它把 76 个「运行时并不存在」的全局(ref/ElMessage/h/...)声明为合法,
漏写 import 时 IDE/tsc 不报错、运行到该行才 ReferenceError —— 陷阱制造机。
(R36 已实测:本项目全部走显式 import,且 R36 明确否决 auto-import 方案。)

闭环取证:
- 全项目(含 backend/deploy/database/scripts)grep 'auto-imports' = 0 引用
- 扫描器核验:无任何源码「未导入即使用」这 76 个全局(0 命中)
- 删除后 vite build 成功(exit 0),主包 hash index-C_gVjC9x.js 与 R36 完全一致
  ⇒ 不影响任何运行时/产物(.d.ts 不参与打包)
- 项目无 typescript/vue-tsc 依赖,build 不做类型检查 ⇒ 删除无类型回归面
2026-09-29 11:50:19 +08:00
ea350fb2e0 feat(frontend): Element Plus 改按需引入,首屏 425.1->158.8KB(R36)
- vite.config.js: 新增 unplugin-vue-components + ElementPlusResolver;
  optimizeDeps.include 动态生成 118 项无扩展名 specifier(glob/.mjs 皆静默失效)
- main.js: 删 app.use(ElementPlus) 与全量 CSS;改 provideGlobalConfig(locale, app, true);
  显式引入 4 个函数式组件样式(message/message-box/notification/loading)
- views/Upload.vue: 3 处 h(resolveComponent('el-xxx')) 改显式组件引用;清 2 处死导入
- devDep: unplugin-vue-components@^32.1.0
- .gitignore: 忽略 vite.config.js.timestamp-*.mjs

验证:dist 渲染 9/9 · dist 登录后 9/9 · dev 通道致命错误 0/告警 0
瘦身:首屏 425.1->158.8KB(-62.6%);主包 JS 1112.9->281.09KB
2026-09-29 11:44:15 +08:00
042e0a8e1f feat(frontend): SortableJS 改为本地依赖,移除公网 CDN 运行时依赖(R33)
- package.json: 新增 sortablejs@^1.15.0(dependencies)
- src/main.js: import Sortable + 挂载 window.Sortable
  (保留全局挂载形式 => Admin.vue 的 window.Sortable.* 零改动)
- index.html: 删除 cdn.jsdelivr.net 的 <script>,留注释说明原因
- 原因: 内网/离线部署时 CDN 不可达 => window.Sortable 为 undefined,
  而 Admin.vue:3089/3276 的 `if (!window.Sortable) return` 会静默吞掉
  新闻/轮播的拖拽排序(界面零提示)
- 验证: 真 chromium 16/16 PASS(含整站 0 次 CDN 请求、宿主元素 expando
  Sortable<数字> 探针、news/carousel handle 正确、0 pageerror/0 console.error);
  构建 ✓ built in 18.57s / VITE_EXIT=0; TDZ src 38 + dist 26 全 PASS
2026-09-29 09:57:48 +08:00
cb672b621a feat(admin): 导入结果提示区分「忽略非参赛选手」条数(R32)
- onExcelImport 读取 result.ignored 并在成功提示中单列展示
- 控制台 console.info 输出 ignoredBy 身份明细(报名人/指导教师/领队)
- 仅账号导入这一处;客观题导入提示未动
2026-09-29 09:40:51 +08:00
2ca82d0da3 refactor(admin): 评分入口收敛 + 大题小问得分可见(R30)
- 文案:「考生大题作答查证」提示由「作品评分」改为「大题评分」
  (保存后将写入该考生「大题评分」,总分 = 客观题得分 + 大题评分)
- 删除「考生答题记录」的评分操作:操作列(评分按钮)+ 评分弹窗 +
  examScoreDialog* / openExamScoreDialog / saveExamScore + .exam-score-form 样式
  ⇒ 大题评分统一由「考生大题作答查证」按小问给分,word_score 不再有两条口径
  ⛔ 刻意保留:adminApi.setScores(作品管理 batchSaveWorkScores 仍在用)
     与后端 PUT /api/admin/scores/:group(10+ 门禁脚本依赖:verify_full_e2e /
     exam_regression_test / verify_large_scale_17 / verify_agg_key_mismatch 等)
- 新增「小问得分」列(「大题得分」之后):后端 listAnswersForAdmin 本就返回
  score1/2/3 ⇒ 纯前端改动、未动后端。新增 subScoresOf / hasSubScores / fmtSubScores;
  hasSubScores 用于区分「未评分」与「三问都给 0 分」(二者不可都显示成 0)

验证:
- TDZ 顺序扫描:src 38 文件 + dist 打包产物 26 文件 全 PASS
- 真 chromium(playwright)ui-runtime-check.mjs:16/16 断言全过 / 致命错误 0 / 告警 0
  (含 expectText "10 / 8 / 9" 直证小问得分列渲染;DOM dump 证实答题记录表头
   已无「操作」列、大题查证表头含「小问得分」)
- dist 重建 built in 13.00s / VITE_EXIT=0;产物「作品评分」= 0,「小问得分」「大题评分」在
2026-09-28 17:44:55 +08:00
e5579f034e fix(admin): 修复成绩管理页 TDZ 崩溃——watch 早于 ref 声明(R29 回归)
浏览器实测报错:
  ReferenceError: Cannot access 'scoreExportGroup' before initialization
  at setup (Admin.vue:2559:7)

根因:R29 板块迁移时把 `watch(scoreExportGroup, …)` 写在 2559 行,而
`const scoreExportGroup = ref('')` 在 2810 行。**watch() 会立即执行一次
source 取值来收集依赖**,故 setup 阶段即触发 TDZ。
(对照:watch(questionGroup,…) 安全,其声明在 1784 行。)

修法:把该 watch 下移到 `scoreExportGroup` / `isExamScoreGroup` 声明之后
(成绩管理节内),原位置留注释说明原因,避免后人再次上移。

验证(本轮新增两道门禁工具,均在 .workbuddy/tools/,未入仓库):
- AST 顺序扫描 verify_frontend_tdz.mjs:对已提交的坏版本 c470e79 精确命中
  Admin.vue:2559(变异测试);修复后 src 38 文件 + dist 打包产物 26 文件全 PASS。
- 真 chromium(playwright)ui-runtime-check.mjs:致命运行错误 0、告警 0、
  18/18 断言通过(中职→本科→高职切组、迁移板块显隐、导出Excel、
  批量下载作品按组显隐、评分弹窗「客观分(卷面)」单行左对齐)。
- dist 已重建:built in 12.36s、VITE_EXIT=0。

教训:vite build 只打包、不执行 setup,构建绿对 TDZ 是假绿。
2026-09-28 17:20:37 +08:00
c470e7922e feat(admin): 功能分布优化——板块迁入成绩管理 + 导出 Excel + 废弃按钮清理(R29)
- 试卷管理只留试卷相关:「考生答题记录」(含评分弹窗)与「考生大题作答查证」
  整段迁至成绩管理页,外层 <template v-if="isExamScoreGroup"> 仅中职/高职可见
- 组别绑定改跟随成绩页 scoreExportGroup(避免串组):新增 isExamScoreGroup /
  refreshExamCtxData / refreshScorePageData + watch(scoreExportGroup);ensureBigData 收窄为只刷题库
- 导出按钮:导出CSV → 导出Excel(GET /api/admin/export-excel/:group,fallback .xlsx)
- 中职/高职已取消 Word 作品上传:成绩页与作品管理页「批量下载作品」按组别 v-if 隐藏
- 评分弹窗文字错位修复:label-width 120px + label-position="left" + 标签左对齐不换行
- 5 个纯白按钮补 type(info/primary plain)提升可见度
2026-09-28 16:42:39 +08:00
fb9c48aaac fix(admin): 复核页选择题选项与答案显示 + 阅卷导出 Word 按钮 + 评分两位小数(R28)
- 新增 reviewOptionList() / reviewOptionTextOf() / reviewAnswerCell():
  防御性解包 options(非数组一律降级空数组),选项列表改为 v-for 遍历对象数组;
  选择题的「考生作答」「正确答案」显示为「A. GPS」,彻底消除「只显示字母不知指向哪个选项」的疑虑;
  判断题仍显示「正确/错误」。两列宽 96 -> 130。
- 阅卷区:el-table 增加 type="selection" + row-key="contestantId";
  新增「导出选中 Word(n)」「导出全部 Word」及行内「导出Word」,
  沿用 safeDownload 直接命中 GET /api/admin/big-answers/:group/export?ids=。
- fmtScore / bigScoreTotal 改两位小数;3 处 el-input-number 改 :precision="2" :step="0.25"。

构建验证:node ./node_modules/vite/bin/vite.js build => VITE_EXIT=0(built in 19.61s),
产物中含 review-options / 导出全部 Word / big-answers/{group}/export 三元 fallbackName(.docx|.zip)。
2026-09-28 15:50:56 +08:00
397356f141 feat(admin): 大题录入题干加「不得含参考答案」提示(R27)
- 题干表单项下新增 .big-form-tip:明确「不得出现参考答案 / 标准答案 / 评分标准,
  也不得给出带具体参数值的指令示例(如 >OUTMSG,COM1,GGA,1*3B)—— 考生可直接照抄」;
  并给出正确做法:只写格式定义(参数用方括号占位,例 >OUTMSG[,Port],Message,Period*CS)。
- 新增对应 CSS(.big-form-tip / .big-form-tip code)。
- 后端拦截消息经既有 ElMessage.error(e?.message) 展示(Admin.vue:1968),无需改动。

构建验证:vite build ✓ built in 11.32s,VITE_EXIT=0。
2026-09-28 11:58:17 +08:00
37d2641e49 feat(admin): 后台新增「成绩复核」——检索考生 + 全卷逐题明细弹窗(R26)
- api/admin.js: 新增 reviewSearch(姓名/身份证/电话任一,可选限定组别)与 getContestantScore(group, id)
- Admin.vue: 菜单新增「成绩复核」(Search 图标);检索区(关键词 + 可选组别下拉)+ 结果列表 + 全卷明细弹窗
- 明细含客观题逐题:题号 / 题型 / 题干 / 选项 / 考生答案 / 正确答案 / 判定 / 得分/满分
- 异常标记:题干缺失(questionMissing)、答案与判定矛盾(answerMatches≠isCorrect)
- 判断题答案 'true'/'false' 显示为「正确/错误」;未作答显示「未作答」且不判错
- 综合大题展示三小问作答与得分;本科/教师组展示作品文件清单并支持下载(不回传 file_path)
- 弹窗用考生自身组别请求接口,避免前端传错组照错人(后端 409 兜底)
- 回传正确答案仅管理员可见,与后端 adminAuth 门禁配套

构建验证:vite build 通过(✓ built in 14.80s,退出码 0)
2026-09-28 10:15:50 +08:00
bdfa4a9618 R20:Admin.vue —— 页面状态刷新修复 + 大题导出 Word + 评分一位小数
C 页面状态刷新(修复测试人员反馈的「切组 / 评分后需手动刷新」):
- onQuestionTypeChange:移除 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空
  ⇒ 永不重新拉取,界面停留在上一组数据)
- saveBigScores / saveExamScore:原来各只刷新一侧列表(评分后「查看」侧仍是旧值)
- 收敛为唯一刷新入口 refreshQuestionData(),onMenuSelect / watch(questionGroup) /
  评分收尾 / 切组 全部走它
- 新增 ensureBigData():按「数据属于哪个组」(bigDataGroup) 判定是否拉取
- fetchBigQuestions / fetchBigAnswers:固定发起时的组别,响应回来若组别已变则丢弃
  (防切组瞬间旧响应串组显示)

B 大题评分一位小数:
- 3 处 el-input-number 加 :step="0.5" :precision="1"
- 新增 fmtScore():整数不带小数点(27),非整数保留一位(27.4),空/非法回 '--'
- bigScoreTotal 收敛到一位小数(防 0.1+0.2 浮点脏值显示)

D 大题导出 Word:
- 工具栏新增「导出选中Word(N)」「导出全部Word」
- 大题表加多选(reserve-selection)+ 选中态跟踪
- exportBigQuestions():走 safeDownload(已含 JSON 错误分支,避免把错误信封存成
  .docx);过滤不属于当前组别的陈旧 ID

计划外修复:
- exportBigQuestions 边界:选中 ID 全失效时原会因 qs 为空而静默退化成「导出全部」,
  与用户意图相反;已改为提示而不导出

验证:VITE_EXIT=0(多次构建,Admin chunk hash 已更新)。
未 push:须先上线验证通过再单独授权。
2026-09-23 18:30:01 +08:00
cddf91d13a fix(admin): 中职/高职管理后台「Word」口径改为「大题分」并清理死列
问题(用户 2026-09-22 报障):
  管理后台 试卷管理 → 考生答题记录 中,
  ①「答题情况」显示的是「Word分」,与现行综合大题口径不符;
  ② 多出一列「Word 答案」(针对已废弃的 Word 上传通道,中职/高职恒为空)。

改动(5 处):
  ①「答题情况」列标签:Word分 → 大题分
  ② 删除整列「Word 答案」(含 hasWordFile 判断与下载按钮)
  ③ 评分弹窗 label:Word 答案分 → 大题分
  ④ 删除孤儿方法 downloadExamWord()(删除后已无调用者)
  ⑤ 成绩管理区中职/高职分支:删「Word文件」列,Word分 → 大题分
     (v-else 本科/教师分支的「文档分(Word)」保持不变)

影响面:仅中职/高职分支显示口径;本科/教师组 Word 通道不受影响。
   数据列仍为 contestants.word_score(历史列名沿用),仅文案口径调整。

验证:vite build EXIT=0(9.88s);产物指纹「大题分」1 次 /「Word分」0 /「Word 答案」0;
     GET /api/admin/submissions/zhongzhi 返回 6 行字段齐全,零回归。
2026-09-22 13:05:30 +08:00
c2551b64e8 feat(auth/exam): 登录重试同源 + 静默刷新透传 + 交卷链路加固(R5/R8)
前端重试与后端闸门同源(须成对上线)
- src/api/request.js: 新增 parseRetryAfterMs / computeLoginRetryDelayMs,
  退避取 max(指数退避, Retry-After) + ±20% 抖动 + 服务端值封顶;
  silent 透传(doSilentRefresh 不再吞掉 silent 标记)。
- src/api/auth.js / src/stores/auth.js: 登录 429 走同源重试;错误分类区分
  "TCP 未握手(可重试)"与"业务码失败(不可重试)"。
- src/stores/exam.js: 交卷链路顺序红线(取卷 -> 作答 -> submit-all 严格顺序),
  答案不丢 / 重复不脏 / 窗口不锁死。
- src/views/Competition.vue: 中职/高职综合大题区块(3 x textarea,2000 字上限
  与右下角计数);本科/教师组保留 Word 上传入口。

验证:源码抽取测试 8/8(含判别力自证:不读 header 的对照实现必 FAIL);
npm run build BUILD_RC=0(12.82s / 1711 modules);真实交卷端到端 code=0。
2026-09-22 09:17:06 +08:00
1bfe007149 feat: 综合大题改造(替代 Word 上传)+ MockExam 按组分支
中职/高职组以「综合大题」替代原 Word 文件上传:管理员在后台录入题干
(wangEditor 富文本,含表格),系统按组随机抽题,考生在 3 个小问内作答,
每小题 10 分、大题合计 30 分。

- api/admin.js / api/exam.js / stores/exam.js:大题题库 CRUD、抽题、作答与评分链路
- Competition.vue:Word 上传区块替换为大题作答区(3 小问 + 字数上限 + 富文本题干渲染)
- Admin.vue:大题题库管理、考生作答查看与人工评分
- MockExam.vue:按组分支——中职/高职显示综合大题,其余组保持 Word 答题;侧栏与提交提示同步
- RichEditor.vue + utils/richtext.js:题干富文本渲染前做白名单消毒(零依赖,纵深防御)

说明:big_questions / big_question_answers 建表迁移 023/024 随后端仓库提交。
2026-09-21 11:17:59 +08:00
30 changed files with 8045 additions and 2102 deletions

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Vite 在 dev server 因配置变化重启时会生成该临时打包文件;
# 若进程异常退出(例如被沙箱 safe-delete 钩子中断)会残留,不应进入版本库。
vite.config.js.timestamp-*.mjs
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -47,7 +47,9 @@
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<!-- SortableJS 已改为 npm 依赖本地打包(见 src/main.js),不再从公网 CDN 引入:
内网/离线部署时 CDN 不可达会让 window.Sortable 缺失,
而 Admin.vue 中的 `if (!window.Sortable) return` 会静默吞掉新闻/轮播的拖拽排序功能。 -->
</head>
<body>
<div id="app"></div>

1853
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,12 +16,14 @@
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"pinia": "^2.1.0",
"sortablejs": "^1.15.0",
"spark-md5": "^3.0.2",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
"@vitejs/plugin-vue": "^6.0.10",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.3.4"
}
}

View File

@@ -19,6 +19,15 @@ export function getAccounts(params) {
return request.get('/admin/accounts', { params })
}
/**
* 获取小组(团队)激活状态清单(R84 续六)
* @param {object} params - { group, activated, includeTest }
* @returns {Promise} { list, summary, filteredTotal, filters }
*/
export function getTeams(params) {
return request.get('/admin/teams', { params })
}
/**
* 导入 Excel 批量创建账号
* @param {FormData} formData - 包含 Excel 文件的 FormData
@@ -28,8 +37,14 @@ export function importExcel(formData) {
// 注意:不要手动设置 Content-Type。axios/浏览器在发送 FormData 时会自动补上
// `multipart/form-data; boundary=...`;手动写死 'multipart/form-data'(无 boundary)
// 会导致后端 getFileStream 解析失败(Missing boundary)-> 500,且永不落库。
//
// silent: true —— 抑制响应拦截器的通用错误提示。本接口的**失败提示由调用方负责**:
// 后端刻意只回传纯错误(admin.js importExcel catch 注释:「前端已统一加
// 「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀」),故此处必须静默,
// 否则会与 Admin.vue 的「Excel 导入失败:<原因>」构成同族双弹。
return request.post('/admin/import-excel', formData, {
timeout: 60000,
silent: true,
})
}
@@ -38,8 +53,8 @@ export function importExcel(formData) {
* @param {string} group - 组别标识
* @returns {Promise} 作品列表
*/
export function getSubmissions(group) {
return request.get(`/admin/submissions/${group}`)
export function getSubmissions(group, params) {
return request.get(`/admin/submissions/${group}`, { params })
}
/**
@@ -53,9 +68,11 @@ export function getTimes() {
/**
* 设置比赛时间
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
* @returns {Promise}
*/
export function setTimes(data) {
export function setTimes(data, expected) {
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
const times = Object.entries(data).map(([group, item]) => ({
@@ -66,7 +83,21 @@ export function setTimes(data) {
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
}))
return request.put('/admin/times', { times })
const payload = { times }
// ⛔ 只在真的有基线时才带:空对象会让后端误以为「所有组都要求校验」而恒冲突
if (expected && Object.keys(expected).length > 0) payload.expectedTimes = expected
return request.put('/admin/times', payload)
}
/**
* 节点备份调度器状态(P1「生产备份静默失败」可见性)
*
* silent: true —— 本接口是「状态展示」而非「用户动作」,失败由页面内提示承载,
* 不希望拦截器再弹一次全局错误(同族双弹纪律)。
* @returns {Promise} { available, enabled, isProduction, buffer, keepDays, plans, history, artifacts, health, paths }
*/
export function getNodeBackupStatus() {
return request.get('/admin/node-backup/status', { silent: true })
}
/**
@@ -134,8 +165,26 @@ export function setQuestions(group, questionType, data) {
*/
export function importQuestionsExcel(group, formData) {
// 同上:不要手动设置 Content-Type,避免缺失 boundary 导致后端解析 500。
// silent: true —— 同 importExcel:失败提示由调用方(Admin.vue onExcelFileChange)
// 以「Excel 导入失败:<原因>」形式统一给出,此处静默以避免同族双弹。
return request.post(`/admin/exam/${group}/questions/import-excel`, formData, {
timeout: 120000,
silent: true,
})
}
/**
* 综合大题 Excel 批量导入(R73)
* 首 sheet 为数据表(表头在第 1 行),可携带「填写说明」sheet
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @param {FormData} formData - 包含 Excel 文件的 FormData(字段名 file)
* @returns {Promise} { imported, skipped, errors: [{ row, message }] }
*/
export function importBigQuestionsExcel(group, formData) {
// silent: true —— 与 importQuestionsExcel 同口径:错误提示由调用方统一给出,避免同族双弹
return request.post(`/admin/big-questions/${group}/import-excel`, formData, {
timeout: 300000,
silent: true,
})
}
@@ -207,8 +256,8 @@ export function deleteMaterial(group, fileName) {
* @param {string} group - 组别标识
* @returns {Promise} 成绩列表
*/
export function getScores(group) {
return request.get(`/admin/scores/${group}`)
export function getScores(group, params) {
return request.get(`/admin/scores/${group}`, { params })
}
/**
@@ -233,6 +282,91 @@ export function getPublishStatus(group) {
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined)
}
// ==================== 成绩公示表(R176) ====================
/**
* 公示表导出前的统计预检(⛔ 不下载文件,只回统计)
*
* 为什么要先预检:成绩公示表是**对外的正式文书**,缺数据不能静默带出。
* 本接口回传「无小组归属人数 / 测试数据人数 / 赛项名称是否已配置」,
* 前端据此在执行下载前让管理员知情并确认,而不是下载完才发现表格有空白列。
*
* ⚠️ 为什么不用响应头传这些字段:生产经 nginx `X-Accel-Redirect` 时
* 上游自定义响应头会被丢弃(本项目已实测),故改用 `?meta=1` 走 JSON。
*
* silent: true —— 属「预检」而非用户动作,失败由调用方按业务语境提示。
* @param {string} group - 组别标识
* @returns {Promise<{fileName:string,rowCount:number,testCount:number,
* noTeamCount:number,totalContestants:number,itemName:string,itemCode:string}>}
*/
export function getPublicityMeta(group) {
return request.get(`/admin/export-publicity/${group}`, {
params: { meta: 1 },
silent: true,
})
}
/**
* 下载成绩公示表半成品(按模板填充,获奖等级列留空由人工填写)
*
* 下载动作本身由 `utils/download.js#safeDownload` 发起(含 HTTP 状态 +
* content-type + 文件头三重校验),故这里不提供 axios 封装,
* 调用方直接用 `/api/admin/export-publicity/${group}` 走 safeDownload。
*
* 留此常量而非散落字符串:URL 前缀被前端(safeDownload)与后端路由共同约束,
* 改成单点引用可避免两处漂移。
* @param {string} group
* @returns {string} 下载 URL
*/
export function publicityExportUrl(group) {
return `/api/admin/export-publicity/${group}`
}
/**
* 读取赛项信息(赛项名称 / 赛项编号)
*
* 这两项用于公示表标题(模板第 1~3 行)。用户裁定:不做硬编码,
* 改由管理人员在后台填写 —— 因为赛项名称/编号每届都会变。
* @returns {Promise<{itemName:string,itemCode:string}>}
*/
export function getCompetitionItem() {
return request.get('/admin/competition-item', { silent: true })
}
/**
* 保存赛项信息
* @param {{itemName?: string, itemCode?: string}} data
* @returns {Promise<{itemName:string,itemCode:string}>}
*/
export function updateCompetitionItem(data) {
// silent: true —— 越界(名称 >120 字 / 编号 >60 字)是业务性 400,
// 后端 message 已可读,由调用方在弹窗内就地提示,避免拦截器再弹一次(同族双弹)
return request.put('/admin/competition-item', data, { silent: true })
}
// ==================== 指导教师回填(R176) ====================
/**
* 从报名表回填「指导教师」
*
* 背景:报名表导入时按用户裁定只录入参赛选手,指导教师的姓名没有落库。
* 公示表需要「指导教师」列,故提供一次性回填:重新上传同一份报名表,
* 服务端只读其中的「指导教师」行,按「组别 + 小组编号」定位到该组全体组员,
* 写入 `contestants.teacher_name`(组级属性,冗余到每位组员)。
*
* ⛔ 不新建账号、不改组别/成绩,只更新这一个列。
* silent: true —— 失败提示由调用方统一给出,避免同族双弹。
* @param {FormData} formData - 含报名表(字段名 file)
* @returns {Promise<{ok:boolean,code:string,message:string,stats:object,
* teams:object,updated:number,unchanged:number,unmatched:Array,warnings:string[]}>}
*/
export function backfillTeachers(formData) {
return request.post('/admin/contestants/backfill-teacher', formData, {
timeout: 120000,
silent: true,
})
}
// ==================== 新闻管理 ====================
/**
@@ -280,6 +414,28 @@ export function updateNewsOrder(orders) {
return request.put('/admin/news/order', { orders })
}
/**
* 上传新闻附件(R176「缺口2:新闻管理新增文件附件能力」)
*
* 场景:成绩公示表这类**文件类公示**要在新闻里对外提供下载。
* 附件与正文分开存:`attachment_url`(服务端返回的访问路径)+ `attachment_name`
* (展示用的原始文件名,中文原样保留)。
*
* ⚠️ 不要手动设置 Content-Type:axios 会在 FormData 上自动补 boundary,
* 写死 'multipart/form-data' 会让后端 getFileStream 报 Missing boundary → 500。
*
* silent: true —— 后端对「格式不支持 / 超 20MB」已给出可读文案,
* 由调用方就地提示,避免拦截器再弹一次(同族双弹)。
* @param {FormData} formData - 含文件(字段名 file)
* @returns {Promise<{url:string,name:string,size:number}>}
*/
export function uploadNewsFile(formData) {
return request.post('/admin/upload/news-file', formData, {
timeout: 120000,
silent: true,
})
}
// ==================== 轮播图管理 ====================
/**
@@ -341,10 +497,274 @@ export function toggleTestFlag(id, testFlag) {
/**
* 重置测试数据(仅重置 test_flag=1 的选手及其提交记录)
* @returns {Promise}
*
* returnMessage: true —— 保留后端 message(不再被响应拦截器丢弃)。
* 后端提示含「已恢复为『未激活 + 默认口令 X』,需先用默认口令完成短信激活即可登录重测」
* 这一关键指引;前端自拼文案曾写成「可直接用默认口令登录重测」,
* 与后端 activated:false 自相矛盾(用户反馈 Req2)。以后端文案为唯一真相源。
* @returns {Promise<{code:number,message:string,data:object}>}
*/
export function resetTestData() {
return request.post('/admin/reset-test-data')
// silent: true —— 抑制响应拦截器(request.js)的通用错误提示。
// 本调用方在 Admin.vue 的 catch 里已按上下文弹一次「重置失败:<后端原文>」,
// 若不再静默,同一 403(生产环境禁止重置测试数据)会被拦截器与 catch 各弹一次,
// 用户会看到两条完全相同的报错(R58 用户反馈)。
return request.post('/admin/reset-test-data', null, { returnMessage: true, silent: true })
}
// ==================== 综合大题管理(中职/高职) ====================
/**
* 获取综合大题题库(按组别)
*
* 仅中职/高职启用;本科/教师组调用返回 400(未启用综合大题)。
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @returns {Promise} {
* list: [{ id, questionUid, group, title, description,
* subPrompt1, subPrompt2, subPrompt3,
* scorePerSub, totalScore, sortOrder, status, createdAt, updatedAt }],
* total, scorePerSub, subCount, maxChars
* }
*/
export function getBigQuestions(group) {
return request.get(`/admin/big-questions/${group}`)
}
/**
* 新增大题
*
* 题干 description 为富文本 HTML(支持表格);三小问提示 subPrompt1~3 与题干同存一行。
* @param {string} group - 组别标识
* @param {object} data - { title, description, subPrompt1, subPrompt2, subPrompt3, scorePerSub, sortOrder, status }
* @returns {Promise} { id, questionUid }
*/
export function createBigQuestion(group, data) {
return request.post(`/admin/big-questions/${group}`, data)
}
/**
* 更新大题
*
* 注意:已被考生抽取的题目仍可修改,但考生端展示的是抽题时刻的题干快照,
* 不会受此修改影响(防纠纷设计)。
* @param {string} group - 组别标识
* @param {number|string} id - 大题 ID
* @param {object} data - 同 createBigQuestion
* @returns {Promise} { id }
*/
export function updateBigQuestion(group, id, data) {
return request.put(`/admin/big-questions/${group}/${id}`, data)
}
/**
* 删除大题
*
* 已被考生抽取的题目服务端拒绝删除(返回 409),避免作答记录悬空。
* @param {string} group - 组别标识
* @param {number|string} id - 大题 ID
* @returns {Promise} { deleted }
*/
export function deleteBigQuestion(group, id) {
return request.delete(`/admin/big-questions/${group}/${id}`)
}
/**
* 获取大题作答与查证列表(覆盖该组全部考生,含未抽题者)
*
* 每行含考生信息、抽题快照、三小问答案、已答数量、三小问得分、大题总分与提交时间,
* 供管理员核对考生实际抽到的题目与作答内容,避免考生纠纷。
* @param {string} group - 组别标识
* @returns {Promise} {
* list: [{ contestantId, aggregationUid, name, idNumber, phone, teamId,
* drawn, bigQuestionUid, questionSnapshot,
* answer1, answer2, answer3, answeredCount,
* score1, score2, score3, bigScore, wordScore, submitTime }],
* total, drawnCount
* }
*/
export function getBigAnswers(group) {
return request.get(`/admin/big-answers/${group}`)
}
/**
* 录入三小问得分(小题分)
*
* 服务端逐问校验 0 ~ scorePerSub 区间,累加后经唯一评分写路径
* service.admin.updateScores 落 contestants.word_score(中职/高职总分 = exam_score + word_score)。
* @param {string} group - 组别标识
* @param {number|string} uid - 考生聚合 ID(aggregation_uid)
* @param {{score_1: number, score_2: number, score_3: number}} scores
* @returns {Promise} { total, perSubScore }
*/
export function setBigAnswerScores(group, uid, scores) {
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
}
/**
* 下载「综合大题成绩导入」模板(R172)
*
* 模板两页:`成绩导入`(小组编号 / 分数)+ `填写说明`。
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @returns {Promise}
*/
export function getBigScoreImportTemplate(group) {
return request.get(`/admin/big-answers/${group}/import-template`, {
responseType: 'blob',
timeout: 60000,
})
}
/**
* 预览成绩导入(R172 · ⛔ 零写入;R174 改三小问四列制)
*
* 服务端逐行判定:可导入 / 将覆盖 / 被拒绝(附原因),前端据此让管理员确认后再导入。
* @param {string} group
* @param {FormData} formData - 含 Excel 文件(字段名 file)
* @returns {Promise} {
* total, importable, overwritable, blocked, skippedBlank,
* rows: [{ row, teamNumber, subRaws, subs, total, status, message, blocking,
* contestantId, contestantName, contestantIdNumber, teamUid,
* memberCount, operatorCount, currentScores }]
* }
* · subRaws = 三小问原始填报值(报错行也有,用于定位)
* · subs = 校验通过后的三小问得分;total = 三者之和(判定通过时才有)
*/
export function previewBigScoreImport(group, formData) {
// silent: true —— 错误提示由调用方统一给出,避免拦截器 + 调用方双弹
return request.post(`/admin/big-answers/${group}/import-preview`, formData, {
timeout: 120000,
silent: true,
})
}
/**
* 执行成绩导入(R172)
*
* 写入仍走唯一评分路径(setSubScores → service.admin.updateScores),
* 不新增第二条写成绩通路。
* @param {string} group
* @param {FormData} formData - 含 Excel 文件(字段名 file)
* @param {boolean} [overwrite=false] - 是否覆盖已评分
* @returns {Promise} { imported, skipped, overwritten, errors: [{ row, teamNumber, message }] }
*/
export function importBigScores(group, formData, overwrite = false) {
return request.post(`/admin/big-answers/${group}/import-scores`, formData, {
params: { overwrite: overwrite ? 1 : 0 },
timeout: 300000,
silent: true,
})
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
/**
* 检索考生(姓名 / 身份证号 / 电话 任一,模糊匹配)
*
* 不传 group 则全组别检索 —— 复核时通常只拿到一个身份证号,不应强迫先猜组别。
* @param {string} keyword - 检索词
* @param {string} [group] - 可选:限定组别
* @returns {Promise} { list: [{ contestantId, name, idNumber, phone, group, objectiveApplicable }], total, truncated }
*/
export function reviewSearch(keyword, group) {
const params = { keyword }
if (group) params.group = group
return request.get('/admin/review/search', { params })
}
/**
* 考生全卷明细(复核)
*
* 返回客观题逐题(题干 / 选项 / 考生答案 / **正确答案** / 对错 / 得分 / 满分)
* + 综合大题(题干快照 + 三小问作答与得分)+ 本科/教师组作品文件清单。
* ⚠️ 含正确答案,仅管理员可用。
* @param {string} group - 组别标识(须与考生实际组别一致)
* @param {number|string} contestantId - 考生 ID(contestants.id)
* @returns {Promise} { name, idNumber, phone, group, objectiveApplicable, scorePublished,
* objective: [{ seq, questionType, questionUid, question, options, correctAnswer, maxScore,
* answer, answered, isCorrect, score, answerMatches, questionMissing }],
* summary: { answered, total, correctCount, autoScore, wordScore, totalScore },
* bigQuestion, works, warnings, matchedKeys, questionOrderFixed }
*/
export function getContestantScore(group, contestantId) {
return request.get(`/admin/review/score/${group}/${contestantId}`)
}
// ==================== 选手信息编辑(R108 新增) ====================
/**
* 编辑已入库选手的身份字段(姓名 / 手机号 / 身份证号)
*
* 背景:此前选手信息只有「Excel 导入」一个写入口,录错了既改不了也删不掉。
*
* silent: true —— 抑制响应拦截器的通用错误提示,由调用方按业务码分支提示。
* 本接口的失败形态是**业务性**的(400 入参非法 / 404 选手不存在 / 409 证号被占用),
* 后端 message 已含可读原因(如「身份证号 X 已被占用:张三 / id=123 / 组别=zhongzhi」),
* 拦截器再弹一次「请求失败」即为同族双弹(见 frontend-error-notify.md 口径)。
*
* @param {number|string} id - contestants.id
* @param {{name?: string, phone?: string, id_number?: string}} payload
* 仅这三项可改;传其他字段后端会显式 400 拒绝(调用方切勿透传整行对象,
* 否则 group / score 等只读字段会把请求打成 400)。
* @returns {Promise<{code:number,message:string,data:{
* ok:boolean, changed:string[], warnings:string[],
* contestant:{id:number,name:string,phone:string,id_number:string,group:string,
* team_id:number|null,activated:boolean,test_flag:number}
* }}>}
* `changed` 为空数组 = 提交值与库内一致,未落库(后端 message 为「未检测到需要修改的内容」)。
* `warnings` 非空须展示(典型:改号后口令不重置,需走「忘记密码」)。
*/
export function updateContestant(id, payload) {
return request.put(`/admin/contestants/${id}`, payload, { silent: true })
}
// ==================== R178 多管理员 + 权限 ====================
/**
* 当前登录管理员的自助信息(权限点 + 权限目录)
*
* 为什么需要它:权限被最高管理员改动后,前端手里那份(登录时落盘的)会过期。
* 进页面时拉一次,能让「权限已被收回」被立刻感知,而不是点每个按钮都撞 403。
*
* silent: true —— 该接口在 401(会话失效)时由拦截器统一跳登录,业务上不产生
* 需要额外提示的失败形态,避免与拦截器双弹。
*/
export function getAdminMe() {
return request.get('/admin/me', { silent: true })
}
/** 管理员列表(不含口令) */
export function listAdminUsers() {
return request.get('/admin/admin-users')
}
/**
* 新增管理员
* @param {{username:string,password:string,permissions:string[],remark?:string}} data
*/
export function createAdminUser(data) {
return request.post('/admin/admin-users', data)
}
/** 编辑权限与备注(对目标账号立即生效,无需其重新登录) */
export function updateAdminUserPermissions(id, data) {
return request.put(`/admin/admin-users/${id}/permissions`, data)
}
/** 重置口令(不要求旧口令;成功后目标账号所有在线会话立即失效) */
export function resetAdminUserPassword(id, data) {
return request.put(`/admin/admin-users/${id}/password`, data)
}
/** 启用 / 禁用(status: 1 启用 / 0 禁用;禁用会立即踢下线) */
export function setAdminUserStatus(id, data) {
return request.put(`/admin/admin-users/${id}/status`, data)
}
/** 删除管理员(最高管理员与本人账号不可删,后端会拒绝) */
export function deleteAdminUser(id) {
return request.delete(`/admin/admin-users/${id}`)
}

View File

@@ -31,13 +31,14 @@ export function activate(data) {
}
/**
* 参赛者登录(身份证号 + 密码)
* @param {string} idNumber - 身份证号
* 参赛者登录(手机号 + 密码)
* @param {string} phone - 手机号
* @param {string} password - 密码
* @param {object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器逐次错误提示)
* @returns {Promise} { token, user }
*/
export function login(phone, password) {
return request.post('/auth/login', { phone, password })
export function login(phone, password, config) {
return request.post('/auth/login', { phone, password }, config)
}
/**

View File

@@ -22,15 +22,6 @@ export function getQuestions(group) {
return request.get(`/exam/${group}/questions`)
}
/**
* 获取 Word 答题模板(返回文件流,前端通过 window.open 直接下载)
* @param {string} group - 组别标识
* @returns {Promise} 文件流
*/
export function getWordTemplate(group) {
return request.get(`/exam/${group}/word-template`)
}
/**
* 提交单个答案
* @param {string} group - 组别标识
@@ -41,25 +32,41 @@ export function submitAnswers(group, answers) {
return request.post(`/exam/${group}/submit-answers`, { answers }, { silent: true })
}
// 注:原 getWordTemplate / uploadWord / deleteWord 三个封装已随「中职/高职取消 Word
// 上传题」一并移除(2026-09-21)。后端对应路由亦已对中职/高职封堵;本科/教师组的
// 文档上传走分片通道 @/api/upload,不经此处。
// ==================== 综合大题(中职/高职) ====================
/**
* 上传 Word 答题文件
* @param {string} group - 组别标识
* @param {FormData} formData - 包含 Word 文件的 FormData
* @returns {Promise}
* 获取综合大题(首次调用即抽题并落库快照,后续调用复用同一题)
*
* 服务端保证「一考生一行、一题一快照」:抽题幂等(reused=true 表示复用),
* 题干以抽题时刻的快照为准(管理员后续改题干不影响已抽考生,防纠纷)。
* 响应体不含任何分数字段(成绩发布门控)。
* @param {string} group - 组别标识(仅 zhongzhi / gaozhi)
* @returns {Promise} {
* question: { big_question_uid, title, description, sub_prompts, score_per_sub, total_score, snapshot_at },
* answers: { answer_1, answer_2, answer_3 },
* submitTime, maxChars, subCount, reused
* }
*/
export function uploadWord(group, formData) {
return request.post(`/exam/${group}/upload-word`, formData, {
timeout: 60000, // 上传超时 60 秒
})
export function getBigQuestion(group) {
// silent:本接口的失败(如 4004 该组暂无大题)由答题区自行展示 + 提供重试,
// 避免与页面其他提示重复弹窗。
return request.get(`/exam/${group}/big-question`, { silent: true })
}
/**
* 删除已上传的 Word 答题文件(中职/高职组)
* 保存综合大题三小问答案(整卷覆盖保存)
*
* 超长由前端 maxlength 与服务端双重拦截;服务端超限返回 400 且整批拒绝(不静默截断)。
* @param {string} group - 组别标识
* @returns {Promise}
* @param {{answer_1: string, answer_2: string, answer_3: string, forceSubmit?: boolean}} data
* @returns {Promise} { filled, subCount, maxChars, savedAt }
*/
export function deleteWord(group) {
return request.delete(`/exam/${group}/word`, { timeout: 60000 })
export function saveBigAnswer(group, data) {
return request.post(`/exam/${group}/big-answer`, data, { silent: true })
}
/**

View File

@@ -148,6 +148,21 @@ export function initProactiveTokenRefresh() {
}
}
// ==================== 错误提示口径(R64/R65 收敛 · 全站唯一权威) ====================
// 🔴 本拦截器已覆盖**全部**失败路径,且每种失败**只弹一次**:
// 业务码 ≠0/≠200 → `res.message`;403/404/500 → 固定文案;其它 HTTP 码 →
// `data.message`;超时(ECONNABORTED);无 response 的网络层错误。
// ⇒ **调用方 catch 内不要再 `ElMessage.error(...)`**,否则用户同时看到两条 = 同族双弹。
// ⇒ 已按此口径收敛:Admin.vue、Login.vue、Activate.vue、ForgotPassword.vue、
// Competition.vue、Dashboard.vue。
// 例外(**必须**由调用方负责提示,出现时须显式注明原因):
// ① 请求带 `silent: true` ⇒ 拦截器不说话,调用方是唯一提示方
// (登录 / 交卷 / 逐题保存 / 大题加载与保存 / 两个 Excel 导入 / resetTestData);
// ② 客户端校验失败(**根本没发请求**);
// ③ 非 axios 路径:`utils/download.js`(独立实例、不挂本拦截器)、原生 `fetch`、
// el-upload 的 on-success 手工判响应;
// ④ 401 由 `forceLogout()` 提示,**不受 `silent` 影响**(token 真失效必须让用户看到)。
// ==================== 响应拦截器 ====================
request.interceptors.response.use(
@@ -168,6 +183,13 @@ request.interceptors.response.use(
return Promise.reject(new Error(res.message || '请求失败'))
}
// 正常返回,只暴露 data 部分
// 例外:调用方显式传 config.returnMessage = true 时返回**整个业务体**(含 message)。
// 用途:需要原样透传后端提示文案的场景(如「重置测试数据」的结果指引含
// 「需先用默认口令完成短信激活」等关键信息,前端自拼文案会与后端契约漂移)。
// ⛔ 默认行为完全不变(不传该开关时仍只返回 data)。
if (response.config && response.config.returnMessage) {
return res
}
return res.data !== undefined ? res.data : res
}
@@ -175,6 +197,16 @@ request.interceptors.response.use(
return res
},
async (error) => {
// silent 模式(2026-09-21 新增):调用方可在请求 config 上传 silent: true
// 来抑制**逐次**错误提示。供自带走重试的调用方(登录 / 交卷 / 逐题保存)使用,
// 避免重试期间连弹多条相同报错、让考生误以为系统崩了。
// ⛔ 不传 silent 的既有调用方行为**完全不变**(silent 为 undefined → falsy)。
// 注:401 分支的提示由 forceLogout 负责(token 真失效必须让用户看到),不受此影响。
const silent = !!(error.config && error.config.silent)
const notifyError = (msg) => {
if (!silent) ElMessage.error(msg)
}
// 处理 HTTP 状态码错误
if (error.response) {
const { status } = error.response
@@ -240,26 +272,26 @@ request.interceptors.response.use(
}
case 403:
ElMessage.error('您没有权限执行此操作')
notifyError('您没有权限执行此操作')
break
case 404:
ElMessage.error('请求的资源不存在')
notifyError('请求的资源不存在')
break
case 500:
ElMessage.error('服务器内部错误,请稍后重试')
notifyError('服务器内部错误,请稍后重试')
break
default:
ElMessage.error(error.response.data?.message || `请求失败 (${status})`)
notifyError(error.response.data?.message || `请求失败 (${status})`)
}
} else if (error.code === 'ECONNABORTED') {
// 请求超时
ElMessage.error('请求超时,请检查网络连接')
notifyError('请求超时,请检查网络连接')
} else {
// 网络错误或其他错误
ElMessage.error('网络连接异常,请检查网络')
notifyError('网络连接异常,请检查网络')
}
return Promise.reject(error)

View File

@@ -2,58 +2,21 @@ import request from './request'
// ==================== 作品上传 API ====================
/**
* 上传作品分片
* @param {string} group - 组别标识
* @param {FormData} formData - 包含文件分片的 FormData
* @param {function} onProgress - 上传进度回调 (percent)
* @returns {Promise}
*/
export function uploadChunk(group, formData, onProgress) {
return request.post(`/upload/${group}/chunk`, formData, {
timeout: 120000,
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
onProgress(percent)
}
},
})
}
/**
* 合并分片并创建提交记录
* @param {string} group - 组别标识
* @param {object} data - { fileId, fileName, totalChunks, work_name }
* @returns {Promise}
*/
export function mergeChunks(group, data) {
return request.post(`/upload/${group}/merge`, data, {
timeout: 60000,
})
}
/**
* 查询分片上传状态
* @param {string} group - 组别标识
* @param {string} fileId - 文件唯一标识
* @returns {Promise} { received, chunks }
*/
export function getChunkStatus(group, fileId) {
return request.get(`/upload/${group}/chunk-status`, {
params: { fileId },
})
}
// 🔴 分片上传 API(uploadChunk / mergeChunks / getChunkStatus)已于 2026-09-29 · R43 删除:
// 全仓 0 调用点(唯一调用方 Upload.vue 在 B2 清理孤儿路由时一并删除)⇒ 死代码。
// 后端路由 /api/upload/:group/{chunk,merge,chunk-status} 与对应控制器方法**刻意保留**
// —— 本次只清前端死代码,不动服务端契约(避免影响外部调用方与后续复用)。
// ⚠️ 注意「在用的」作品上传走下方 uploadWork()(单次流式 POST /api/upload/:group),
// 不是分片通道 —— 别被"分片上传"这个更完善的名字误导。
/**
* 获取上传状态
* @param {string} group - 组别标识
* @returns {Promise} { submitted, deadline, canSubmit }
* @param {Object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器报错)
* @returns {Promise} { submitted, deadline, canSubmit, canUpload, teamLocked, teamLockMessage, teamFiles, ... }
*/
export function getUploadStatus(group) {
return request.get(`/upload/${group}/status`)
export function getUploadStatus(group, config = {}) {
return request.get(`/upload/${group}/status`, config)
}
/**

91
src/auto-imports.d.ts vendored
View File

@@ -1,91 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElMessage: typeof import('element-plus/es').ElMessage
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

21
src/components.d.ts vendored
View File

@@ -12,39 +12,52 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -20,7 +20,7 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useAuthStore } from '@/stores/auth'
@@ -29,6 +29,9 @@ const props = defineProps({
height: { type: String, default: '320px' },
placeholder: { type: String, default: '请输入内容…' },
mode: { type: String, default: 'default' }, // 'default' | 'simple'
// 是否开放表格菜单。综合大题题干需要富文本表格,故显式开启;
// 默认 false 保持资讯正文等既有调用方的工具栏不变(行为兼容)。
enableTable: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
@@ -49,14 +52,15 @@ watch(
}
)
const toolbarConfig = {
const toolbarConfig = computed(() => ({
excludeKeys: [
'group-video', // 轮播正文不需要视频
'insertTable',
'group-video', // 题干/正文不需要视频
// 未开启表格时隐藏插入表格入口;开启后除去该项,表格相关菜单恢复默认可见
...(props.enableTable ? [] : ['insertTable']),
'codeBlock',
'fullScreen',
],
}
}))
const editorConfig = {
placeholder: props.placeholder,

View File

@@ -1,13 +1,28 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
// Element Plus 按需引入(见 vite.config.js 的 Components 插件):
// 不再全量注册组件,也不再引入 element-plus/dist/index.css。
// 此处只保留「语言包」与「全局 locale 设置」两项职责。
import { provideGlobalConfig } from 'element-plus'
// Element Plus 中文语言包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// Element Plus 样式
import 'element-plus/dist/index.css'
// 🔴 函数式组件不经过模板编译,按需插件(静态扫描)无法为它们注入样式 ⇒ 必须显式引入。
// 这四类在项目中的调用次数:ElMessage 147 / ElMessageBox 23 / ElNotification 4 / ElLoading 2。
// 漏掉任一条的后果是「弹窗能弹出但无样式」(裸文本),构建不报错,只有真浏览器可见。
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/loading/style/css'
// 全局样式(含 Element Plus 主题色变量,防止 getThemeColors 解构 undefined 报错)
import './styles/global.css'
// SortableJS:新闻/轮播的拖拽排序依赖 window.Sortable(见 Admin.vue initNewsSortable/initCarouselSortable)。
// 原先由 index.html 从 cdn.jsdelivr.net 加载,内网/离线环境取不到时会静默失效
// (Admin.vue 内的 `if (!window.Sortable) return` 守卫不会报错也不提示)。
// 改为本地依赖并由 Vite 打包进产物,彻底消除对外网 CDN 的运行时依赖。
import Sortable from 'sortablejs'
window.Sortable = Sortable
import App from './App.vue'
import router from './router'
// 主动 token 续期(防活跃用户因 token 过期被强制踢回登录页)
@@ -27,10 +42,15 @@ app.use(router)
// 启动主动 token 续期:活跃用户不会因 token 过期被强制踢出登录
initProactiveTokenRefresh()
// 注册 Element Plus(使用中文语言包)
app.use(ElementPlus, {
locale: zhCn,
})
// 设置 Element Plus 全局配置(中文语言包)。
// 与原 app.use(ElementPlus, { locale: zhCn }) 语义等价:Element Plus 的 install
// 在注册完组件后做的正是 provideGlobalConfig(options, app, true)
// (见 node_modules/element-plus/es/make-installer.mjs)。
// 第三个参数 global=true 是关键 —— 它把 locale 写入 globalConfig,
// 使 ElMessageBox / ElPagination 等「脱离组件树的函数式 API」也能取到中文文案;
// 少了它,确认框按钮会退回英文 OK / Cancel。
// 组件注册职责已移交 vite 的按需插件,此处不再注册任何组件。
provideGlobalConfig({ locale: zhCn }, app, true)
// 全局指令 v-guard:为按钮/可点击元素提供「防重入 + 节流」,
// 防止狂点触发大量并发请求打爆连接池(2026-08-10 账号列表超时根因)。

View File

@@ -16,7 +16,6 @@ const Competition = () => import('@/views/Competition.vue')
const MockExam = () => import('@/views/MockExam.vue')
const NmeaParser = () => import('@/views/NmeaParser.vue')
const AtSimulator = () => import('@/views/AtSimulator.vue')
const Upload = () => import('@/views/Upload.vue')
const Materials = () => import('@/views/Materials.vue')
const Score = () => import('@/views/Score.vue')
@@ -106,14 +105,6 @@ const routes = [
meta: { requiresAuth: true },
},
// 作品上传页
{
path: '/upload',
name: 'Upload',
component: Upload,
meta: { requiresAuth: true },
},
// 资料下载页
{
path: '/materials',

View File

@@ -1,8 +1,153 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as authApi from '@/api/auth'
// ==================== 登录自动重试策略(2026-09-21 并发压测第二轮结论) ====================
//
// 背景:后端把 /api/auth/login 纳入并发闸门(middleware/concurrency_guard.js 的
// LOGIN_PATTERNS),过载时由「TCP 层静默 RST —— 客户端只拿到 WinError 10061,
// 考生完全不知道自己是"登不上"还是"要多点几次"」变为
// 「HTTP 429 + Retry-After —— 明确、可识别、可定时重试」。
// 客户端**必须**配套重试才能收敛,否则只是把「静默失败」换成了「明确失败」。
//
// 与交卷重试(stores/exam.js:66-68、504-529)**同源同策略**:
// 6 次尝试,退避 1s→2s→4s→8s→15s→15s(封顶),总计约 45s。
// 此处刻意**不**抽公共工具:exam.js 的交卷链路刚经 46 项专项验证 + 两轮压测复测
// (已稳定),为「不影响其他正常功能」而不触碰它。⚠️ 两处策略常量若有变动须同步。
//
// ==================== R8(2026-09-21):让 Retry-After 真正可达 ====================
//
// 问题:后端在 L118/L137/L157 一直发 `Retry-After: 2`,但**此前前端从不读它**
// ⇒ 该响应头在生产上是**纯装饰**(只在压测探针里生效,因为探针读它)。
// 结果:被拒后仍按 1s 的首次退避立刻回来 → 撞墙(实测登录侧请求 +14%、重试 +28%)。
//
// 修法:**服务端建议**与**指数退避**取较大者,再叠加抖动。三条决策依据:
// ① 为何取 max 而非直接用 Retry-After:
// 服务端只知道"当前多忙",不知道客户端已重试几次。若纯用它,重试后期
// (退避已到 8s/15s)反而会被**压回** 2s ⇒ 比改动前更频繁地撞墙;
// 纯用退避则忽略服务端的明确信号。取 max 兼顾两者。
// ② 为何要抖动(jitter):800 人几乎同刻被拒 ⇒ 若都等同一时长,会形成
// 「同步重试潮」在同一时刻再次撞墙(thundering herd)。±20% 抖动把重试打散。
// ③ 为何要给服务端值封顶:后端异常(如误配一个很大的值)不得把考生**锁死**在
// 等待里 —— 超过 LOGIN_RETRY_SERVER_CAP_MS 一律按上限处理。
//
// ⚠️ 已知的**非同源**(有意为之,非遗漏):`stores/exam.js` 的交卷重试与
// `file_io_guard` 的上传重试**仍不读** `Retry-After`。理由:交卷链路实测在
// 5 档 429 下均 100% 收敛(最终 HTTP 200),而它是考试关键路径、回归风险高于收益。
// ⛔ 若将来要统一,必须走独立排期 + 专项回归,不要"顺手"改。
//
// 重试安全性(为何不会把考生锁死):
// ① 登录请求无副作用(仅校验口令 + 签发 JWT),被拒/超时的请求从未到达业务逻辑;
// ② 后端闸门在 **loginRateLimit 之前**返回 429(全局中间件先于 router 级),
// 故 429 不会被计入「登录失败次数」→ 重试**不会**把考生自己打到 15 分钟账号锁;
// ③ 密码错误(3005)/未激活(3004)/手机号不在名单(3003) 等**业务判定**不在重试范围,
// 立即抛出(经拦截器转为无 response 字段的裸 Error,据此与网络层错误区分)。
const LOGIN_MAX_ATTEMPTS = 6
const LOGIN_RETRY_BASE_MS = 1000
const LOGIN_RETRY_CAP_MS = 15000
// 服务端 Retry-After 的采纳上限(防误配把考生锁死);见文件头 R8 第 ③ 条
const LOGIN_RETRY_SERVER_CAP_MS = 15000
// 抖动幅度(±20%):把"同时被拒"的考生重试时刻打散,避免同步重试潮
const LOGIN_RETRY_JITTER = 0.2
/**
* 判断登录失败是否可安全重试
* - 有 HTTP 响应:429(闸门背压,带 Retry-After)或 5xx(服务端瞬时故障)→ 可重试;
* 其余 4xx(语义已明确)→ 不重试。
* - 无 HTTP 响应:axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端,可重试。
* - 拦截器为「业务码非 0」生成的裸 Error(无 response/code/request/config)→ 不重试。
*/
function isRetriableLoginError(err) {
if (!err) return false
const status = err.response && err.response.status
if (status) return status === 429 || (status >= 500 && status < 600)
return !!(err.isAxiosError || err.code || err.request || err.config)
}
/**
* 解析响应头 `Retry-After` 为毫秒(无效/缺失返回 0)
*
* 只接受 **delay-seconds**(整数秒)形式 —— 后端 concurrency_guard.js 发的即此形式。
* HTTP 规范另允许 HTTP-date 形式,本系统不使用;无法解析的值一律返回 0
* (安全回退到纯指数退避:绝不因一个坏 header 让重试变快或卡死)。
*
* ⚠️ axios 的 headers 可能是普通对象或 AxiosHeaders 实例,两种形态都要兼容。
*/
function parseRetryAfterMs(err) {
const headers = err && err.response && err.response.headers
if (!headers) return 0
let raw
if (typeof headers.get === 'function') {
raw = headers.get('retry-after')
}
if (raw === undefined || raw === null || raw === '') {
raw = headers['retry-after']
}
if (raw === undefined || raw === null || raw === '') return 0
const sec = Number(raw)
if (!Number.isFinite(sec) || sec <= 0) return 0
return Math.min(sec * 1000, LOGIN_RETRY_SERVER_CAP_MS)
}
/**
* 计算第 attempt 次失败后的重试等待(毫秒)
*
* = max(指数退避, 服务端 Retry-After) × (1 ± LOGIN_RETRY_JITTER)
* 设计依据见文件头「R8」三条(为何取 max / 为何抖动 / 为何给服务端值封顶)。
*/
function computeLoginRetryDelayMs(err, attempt) {
const backoff = Math.min(
LOGIN_RETRY_CAP_MS,
LOGIN_RETRY_BASE_MS * Math.pow(2, attempt - 1)
)
const base = Math.max(backoff, parseRetryAfterMs(err))
const jittered =
base * (1 - LOGIN_RETRY_JITTER + Math.random() * LOGIN_RETRY_JITTER * 2)
return Math.round(jittered)
}
/**
* 把最终失败的错误包装成考生能看懂的文案(由 Login.vue 的统一 catch 弹出)。
* 业务错误(如「密码错误」「账号未激活」)原样返回,保留后端文案。
*/
function friendlyLoginError(err) {
const status = (err && err.response && err.response.status) || 0
if (status === 429) {
return new Error('当前登录人数过多,服务器繁忙,请稍后重试(若持续失败请举手联系监考老师)')
}
if (status >= 500 && status < 600) {
return new Error('服务器暂时不可用,请稍后重试')
}
if (!status && err && (err.isAxiosError || err.code || err.request)) {
return new Error('网络连接异常,请检查网络后重试')
}
return err // 业务错误:保留后端文案(密码错误 / 账号未激活 / 登录过于频繁…)
}
// 重试期间的常驻提示(持续显示,成功后关闭;避免连弹多条相同报错)
let _loginRetryTip = null
function showLoginRetryTip() {
if (_loginRetryTip) return
_loginRetryTip = ElMessage({
message: '服务器繁忙,正在自动重试登录,请勿刷新页面(最多约 45 秒)',
type: 'warning',
duration: 0,
showClose: true,
})
}
function closeLoginRetryTip() {
if (_loginRetryTip) {
try {
_loginRetryTip.close()
} catch (e) {
/* 忽略:提示已被用户关闭 */
}
_loginRetryTip = null
}
}
// ==================== 认证状态管理 ====================
export const useAuthStore = defineStore('auth', () => {
@@ -66,16 +211,41 @@ export const useAuthStore = defineStore('auth', () => {
// ==================== 操作方法(Actions) ====================
/**
* 参赛者登录(身份证号 + 密码)
* @param {string} idNumber - 身份证号
* 参赛者登录(手机号 + 密码)—— 带自动重试(见文件头「登录自动重试策略」)
* @param {string} phone - 手机号
* @param {string} password - 密码
* @returns {Promise<object>} 用户信息
*/
async function login(phone, password) {
const result = await authApi.login(phone, password)
// 后端应返回 { token, user }
persistAuth(result.token, result.user)
return result.user
let lastErr = null
for (let attempt = 1; attempt <= LOGIN_MAX_ATTEMPTS; attempt++) {
try {
// silent:抑制拦截器的逐次错误提示,改由本函数统一提示(避免连弹多条)
const result = await authApi.login(phone, password, { silent: true })
closeLoginRetryTip()
// 后端应返回 { token, user } —— 仅在真正拿到结果后才落盘
persistAuth(result.token, result.user)
return result.user
} catch (err) {
lastErr = err
if (!isRetriableLoginError(err) || attempt >= LOGIN_MAX_ATTEMPTS) {
closeLoginRetryTip()
throw friendlyLoginError(err)
}
showLoginRetryTip()
// max(指数退避, 服务端 Retry-After) + 抖动 —— 见文件头 R8
const delay = computeLoginRetryDelayMs(err, attempt)
const serverHint = parseRetryAfterMs(err)
console.warn(
`[auth] 登录第 ${attempt} 次失败(可重试),${delay}ms 后重试` +
`(服务端 Retry-After=${serverHint ? `${serverHint}ms` : '未提供'}):`,
err && err.message
)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
closeLoginRetryTip()
throw friendlyLoginError(lastErr)
}
/**

View File

@@ -9,8 +9,6 @@ const questions = ref([])
const answers = ref({})
const countdown = ref(0)
const examStarted = ref(false)
const wordTemplate = ref(null)
const wordUploaded = ref(false)
const totalQuestions = ref(0)
// 考试 session 标识(防止返回重新进入)
@@ -19,6 +17,172 @@ let timerInterval = null
let resyncInterval = null
let finalCheckInFlight = false
// ==================== 综合大题(中职/高职,替代原 Word 上传题) ====================
/** 启用综合大题的组别(与后端 service.bigQuestion.BIG_QUESTION_GROUPS 保持一致) */
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
/** 答题字数上限默认值(后端返回 maxChars 后以其为准) */
const DEFAULT_MAX_ANSWER_CHARS = 2000
/** 抽题快照(题干以抽题时刻为准,管理员后续改题干不影响已抽考生) */
const bigQuestion = ref(null)
/** 三小问答案 { answer_1, answer_2, answer_3 } */
const bigAnswers = ref({ answer_1: '', answer_2: '', answer_3: '' })
const bigSubmitTime = ref(null)
const bigMaxChars = ref(DEFAULT_MAX_ANSWER_CHARS)
const bigSubCount = ref(3)
const bigReused = ref(false)
const bigLoading = ref(false)
const bigLoaded = ref(false)
const bigError = ref('')
const bigSaving = ref(false)
let bigSaveTimer = null
// ==================== 答案本地草稿与落库重试(防丢答案) ====================
//
// 背景(2026-09-21 并发压测结论):
// 原 submitAnswer 在保存失败时只 console.warn(静默),答案仅存在于内存 answers 中。
// 一旦刷新页面 / 断电 / 浏览器崩溃,整场作答永久丢失;若交卷同时失败
// (400 并发下约 20% 考生的连接在 TCP accept 层即被拒,请求从未到达后端),
// 考生将无法恢复任何内容。这对竞赛是不可接受的。
//
// 本段提供两层兜底:
// ① 待重试队列(pendingSaves):逐题保存失败的答案记入队列,指数退避重试直到成功;
// ② 本地草稿:把作答写入 localStorage(按「组别 + 账号」隔离),重进考试时与
// 服务端答案合并,补齐「服务端缺失」的作答。
//
// 设计原则(重要):
// - 草稿**只补空位、绝不覆盖服务端已有值** —— 服务端是权威。最坏情况是给考生补回
// 一道他已答过的题(他可见并可修改),而不会用旧内容改写新作答;
// - 场次隔离用 status.startTime(管理员中途延长 endTime 属同一场次,不应使草稿作废);
// - 草稿仅在**交卷成功**后清除 —— 任何失败路径都必须保留,否则兜底形同虚设。
const DRAFT_VERSION = 1
const DRAFT_KEY_PREFIX = 'exam_draft::'
const DRAFT_WRITE_DEBOUNCE_MS = 200
const SAVE_RETRY_MAX = 6 // 逐题保存重试次数上限(退避 2s→4s→…→30s 封顶)
const SAVE_RETRY_BASE_MS = 2000
const SAVE_RETRY_CAP_MS = 30000
const SUBMIT_MAX_ATTEMPTS = 6 // 交卷自动重试次数上限(退避 1s→2s→…→15s 封顶)
const SUBMIT_RETRY_BASE_MS = 1000
const SUBMIT_RETRY_CAP_MS = 15000
/** 草稿键:按「组别 + 账号」隔离(机房共用电脑时不同考生互不覆盖) */
function draftKeyOf(group) {
let uid = 'anon'
try {
const u = JSON.parse(localStorage.getItem('user') || 'null')
uid = String((u && (u.id || u.uid || u.phone || u.username)) || 'anon')
} catch (e) { /* 解析失败退化为 anon,仍受组别隔离 */ }
return `${DRAFT_KEY_PREFIX}${group}::${uid}`
}
/** 逐题保存失败、待重试的作答 { questionUid: answer } */
const pendingSaves = ref({})
let draftTimer = null
let saveRetryTimer = null
let saveRetryAttempt = 0
/** 考试场次标识:status.startTime(延长 endTime 属同一场次,不应使草稿作废) */
let draftWindowStart = null
/** 立即落盘草稿(同步写 localStorage) */
function writeDraftNow() {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const g = currentGroup.value
if (!g) return
try {
localStorage.setItem(draftKeyOf(g), JSON.stringify({
v: DRAFT_VERSION,
group: g,
windowStart: draftWindowStart,
updatedAt: Date.now(),
answers: { ...answers.value },
big: BIG_QUESTION_GROUPS.includes(g) ? { ...bigAnswers.value } : null,
pending: Object.keys(pendingSaves.value),
}))
} catch (e) {
// 隐私模式 / 配额写满:草稿只是兜底能力,写入失败绝不能打断考生答题
console.warn('[exam] 本地草稿写入失败(不影响答题与交卷)', e)
}
}
/** 防抖落盘(逐字符输入时避免写放大) */
function scheduleDraftWrite() {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => { draftTimer = null; writeDraftNow() }, DRAFT_WRITE_DEBOUNCE_MS)
}
/** 读取草稿(不校验场次,由调用方决定是否采用) */
function readDraft(group) {
const g = group || currentGroup.value
if (!g) return null
try {
const raw = localStorage.getItem(draftKeyOf(g))
if (!raw) return null
const d = JSON.parse(raw)
if (!d || d.v !== DRAFT_VERSION) return null
return d
} catch (e) {
return null
}
}
/** 清除草稿(仅在交卷成功后调用) */
function clearDraft(group) {
try { localStorage.removeItem(draftKeyOf(group || currentGroup.value)) } catch (e) { /* 忽略 */ }
}
/** 是否有尚未同步到服务端的作答(供 UI 提示使用) */
function hasUnsynced() {
return Object.keys(pendingSaves.value).length > 0
}
/**
* 把待重试队列里的答案一次性推给服务端
* @returns {Promise<boolean>} 是否全部成功(队列为空视为成功)
*/
async function flushPendingSaves() {
const entries = Object.entries(pendingSaves.value)
if (!entries.length) return true
const batch = entries.map(([uid, ans]) => ({
question_uid: uid,
question_type: typeOfUid(uid),
answer_data: ans,
}))
try {
await examApi.submitAnswers(currentGroup.value, batch)
pendingSaves.value = {}
writeDraftNow()
return true
} catch (e) {
return false
}
}
/** 指数退避调度待重试队列(已有在途定时器时不重复排程) */
function scheduleSaveRetry() {
if (saveRetryTimer) return
if (saveRetryAttempt >= SAVE_RETRY_MAX) return
const delay = Math.min(SAVE_RETRY_CAP_MS, SAVE_RETRY_BASE_MS * Math.pow(2, saveRetryAttempt))
saveRetryAttempt++
saveRetryTimer = setTimeout(async () => {
saveRetryTimer = null
const ok = await flushPendingSaves()
if (ok) {
saveRetryAttempt = 0
} else {
scheduleSaveRetry()
}
}, delay)
}
/** 停止草稿与重试相关定时器(退出考试页时调用;不清草稿,草稿只在交卷成功后清) */
function stopDraftTimers() {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
if (saveRetryTimer) { clearTimeout(saveRetryTimer); saveRetryTimer = null }
saveRetryAttempt = 0
}
// ==================== 计算属性 ====================
const countdownDisplay = computed(() => {
@@ -41,6 +205,58 @@ const progress = computed(() => {
const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice'))
const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge'))
/** 当前组别是否启用综合大题(中职/高职启用,本科/教师不启用) */
const bigQuestionEnabled = computed(() => BIG_QUESTION_GROUPS.includes(currentGroup.value))
/** 三小问提示文案数组(从快照取,长度与 subCount 对齐) */
const bigSubPrompts = computed(() => {
const prompts = (bigQuestion.value && bigQuestion.value.sub_prompts) || []
const out = []
for (let i = 0; i < bigSubCount.value; i++) {
out.push(prompts[i] || '')
}
return out
})
/** 每小题分值(快照优先,回退 10 分) */
const bigScorePerSub = computed(() => {
const v = bigQuestion.value && bigQuestion.value.score_per_sub
return Number(v) > 0 ? Number(v) : 10
})
/** 大题总分(快照优先,回退「每小题分值 × 小问数」) */
const bigTotalScore = computed(() => {
const v = bigQuestion.value && bigQuestion.value.total_score
if (Number(v) > 0) return Number(v)
return bigScorePerSub.value * bigSubCount.value
})
/** 已作答(去空白后非空)的小问数量 */
const bigAnsweredCount = computed(() => {
let n = 0
for (let i = 1; i <= bigSubCount.value; i++) {
const v = bigAnswers.value[`answer_${i}`]
if (v != null && String(v).trim().length > 0) n++
}
return n
})
/** 三小问是否全部作答 */
const bigAllAnswered = computed(() => bigAnsweredCount.value >= bigSubCount.value)
/** 每个小问的字数统计 { answer_1: 12, ... }(用于右下角实时字数) */
const bigCharCounts = computed(() => {
const out = {}
for (let i = 1; i <= bigSubCount.value; i++) {
const v = bigAnswers.value[`answer_${i}`]
out[`answer_${i}`] = v == null ? 0 : String(v).length
}
return out
})
/** 综合大题完成状态(供进度/答题卡复用) */
const bigCompleted = computed(() => bigAllAnswered.value)
// ==================== 内部函数 ====================
function parseOptions(q) {
@@ -129,6 +345,7 @@ function stopTimer() {
async function startExam(group) {
stopExam()
draftWindowStart = null // 重置场次标识,避免把上一场的草稿归到本场
// 生成唯一的考试 session ID
examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
@@ -146,6 +363,8 @@ async function startExam(group) {
if (stateData.countdown !== undefined) {
countdown.value = stateData.countdown
}
// 记录本场考试标识(隔离不同场次的本地草稿;见「答案本地草稿」段)
draftWindowStart = stateData.startTime || null
}
if (questionList.status === 'fulfilled' && questionList.value) {
@@ -158,7 +377,10 @@ async function startExam(group) {
totalQuestions.value = responseData.count || rawQuestions.length
}
wordTemplate.value = { group }
// 中职/高职:进入考试即抽取综合大题(后端幂等,重进复用同一题与快照)
if (BIG_QUESTION_GROUPS.includes(group)) {
await loadBigQuestion(group)
}
examStarted.value = true
startTimer()
@@ -190,6 +412,7 @@ async function startUploadCountdown(group) {
function stopExam() {
stopTimer()
stopDraftTimers()
examStarted.value = false
sessionStorage.removeItem('exam_session')
}
@@ -208,8 +431,9 @@ function typeOfUid(questionUid) {
}
async function submitAnswer(questionId, answer) {
// 本地先保存答案
// 本地先保存答案,并立即写入本地草稿(先落本地、再谈服务端)
answers.value = { ...answers.value, [questionId]: answer }
scheduleDraftWrite()
// 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝:
// 「答案格式错误,需要合法的 question_type(choice/judge)」)
@@ -217,13 +441,53 @@ async function submitAnswer(questionId, answer) {
await examApi.submitAnswers(currentGroup.value, [
{ question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer },
])
// 落库成功:从待重试队列中移除该题
if (pendingSaves.value[questionId] !== undefined) {
const next = { ...pendingSaves.value }
delete next[questionId]
pendingSaves.value = next
writeDraftNow()
}
} catch (e) {
// 静默失败,不弹窗打扰用户答题
console.warn('提交答案失败', e)
// 🔴 不再静默丢弃(原为仅 console.warn):记入待重试队列并落本地草稿,
// 由指数退避重试直到成功。仍不弹窗打扰答题(失败多为瞬时网络抖动),
// 但交卷前会强制补推一次,且答题页可据 hasUnsynced 提示「未同步 N 题」。
pendingSaves.value = { ...pendingSaves.value, [questionId]: answer }
writeDraftNow()
scheduleSaveRetry()
console.warn('提交答案失败,已记入重试队列并保留本地草稿', e)
}
}
/**
* 判断交卷失败是否可安全重试
*
* 可重试(请求**未到达业务逻辑**,或服务端瞬时过载):
* - 无 HTTP 响应但属 axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端;
* - HTTP 429(并发闸门主动背压,响应带 Retry-After);HTTP 5xx(服务端瞬时故障)。
* 不可重试(业务已作出明确判定,重试无意义):
* - 拦截器为「业务码非 0」生成的裸 Error(4001 考试已结束 / 4004 未答满 /
* 4005 团队锁等)。它没有 axios 的 response、code、config 字段,
* 据此与网络层错误区分 —— 否则会把「未答满」误判为可重试并空转 6 次。
*/
function isRetriableSubmitError(err) {
if (!err) return false
const status = err.response && err.response.status
if (status) return status === 429 || (status >= 500 && status < 600)
return !!(err.isAxiosError || err.code || err.request || err.config)
}
async function submitAll(additionalData = {}) {
// 交卷前先把未确认落库的答案补推一次(不因失败而阻塞:submit-all 本身会带上
// 内存中的全部答案做一次全量落库,是更强的兜底)
try { await flushPendingSaves() } catch (e) { /* 忽略,交由后续全量提交 */ }
// 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间,
// 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。
if (bigQuestionEnabled.value) {
await flushBigAnswers({ force: additionalData.forceSubmit === true, silent: true })
}
// 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空
// 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。
const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({
@@ -236,19 +500,177 @@ async function submitAll(additionalData = {}) {
totalQuestions: totalQuestions.value,
...additionalData,
}
const result = await examApi.submitAll(currentGroup.value, data)
stopExam()
return result
// 🔴 交卷自动重试(2026-09-21 并发压测结论)
//
// 缺陷:400 并发同刻交卷时约 20% 考生的交卷请求在 TCP accept 层就被拒
// (Windows 在 accept 队列满时回 RST,客户端拿到 10061),请求**从未到达后端**;
// 原实现仅提示「请手动点击提交全部」,而手动提交不带 forceSubmit、
// 窗口关闭后必被 4001 拒 —— 这批考生会彻底交不上卷(答案在库里却 submit_time 为空)。
//
// 重试安全性论证:被拒请求从未到达业务逻辑(无副作用);后端落库为 upsert(幂等),
// submit_time 由 ensureExamSubmission 保证只写一次(findOrCreate + `!submit_time` 保护),
// 因此重试不会产生重复行、不会覆盖首次提交时间。
let lastErr = null
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
try {
const result = await examApi.submitAll(currentGroup.value, data)
stopExam()
clearDraft() // 仅在交卷成功后清除草稿;任何失败路径都必须保留兜底数据
return result
} catch (err) {
lastErr = err
if (!isRetriableSubmitError(err) || attempt >= SUBMIT_MAX_ATTEMPTS) throw err
const delay = Math.min(SUBMIT_RETRY_CAP_MS, SUBMIT_RETRY_BASE_MS * Math.pow(2, attempt - 1))
console.warn(`[exam] 交卷第 ${attempt} 次失败(可重试),${delay}ms 后重试:`, err && err.message)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastErr
}
// ==================== 综合大题方法 ====================
/**
* 拉取(首次即抽取并落库快照)当前考生的综合大题与已存答案
* @param {string} [group] - 组别,缺省用 currentGroup
* @returns {Promise<boolean>} 是否成功装载(失败时错误信息在 bigError)
*/
async function loadBigQuestion(group) {
const g = group || currentGroup.value
if (!g || !BIG_QUESTION_GROUPS.includes(g)) return false
bigLoading.value = true
bigError.value = ''
try {
const data = await examApi.getBigQuestion(g)
bigQuestion.value = data.question || null
bigAnswers.value = {
answer_1: (data.answers && data.answers.answer_1) || '',
answer_2: (data.answers && data.answers.answer_2) || '',
answer_3: (data.answers && data.answers.answer_3) || '',
}
bigSubmitTime.value = data.submitTime || null
if (Number(data.maxChars) > 0) bigMaxChars.value = Number(data.maxChars)
if (Number(data.subCount) > 0) bigSubCount.value = Number(data.subCount)
bigReused.value = data.reused === true
bigLoaded.value = !!bigQuestion.value
// 用本地草稿补齐服务端缺失的小问(只补空位,绝不覆盖服务端已有内容)
const d = readDraft(g)
if (bigLoaded.value && d && d.big) {
const mergedBig = { ...bigAnswers.value }
let filled = 0
for (const k of ['answer_1', 'answer_2', 'answer_3']) {
const local = d.big[k]
if ((!mergedBig[k] || !String(mergedBig[k]).trim()) && local && String(local).trim()) {
mergedBig[k] = local
filled++
}
}
if (filled > 0) bigAnswers.value = mergedBig
}
return bigLoaded.value
} catch (e) {
// 典型失败:4004 该组暂无大题 / 400 未启用 / 403 非本组考生
bigError.value = (e && e.message) || '综合大题加载失败'
bigQuestion.value = null
bigLoaded.value = false
return false
} finally {
bigLoading.value = false
}
}
/**
* 更新某小问答案(本地即时生效 + 防抖自动保存)
* @param {number} index - 小问序号 1~3
* @param {string} value - 文本
*/
function setBigAnswer(index, value) {
const key = `answer_${index}`
bigAnswers.value = { ...bigAnswers.value, [key]: value == null ? '' : String(value) }
scheduleDraftWrite() // 先落本地草稿:即便随后的防抖保存失败也不会丢
scheduleBigSave()
}
/** 防抖自动保存(800ms 静默合并,避免逐字符打接口) */
function scheduleBigSave() {
if (bigSaveTimer) clearTimeout(bigSaveTimer)
bigSaveTimer = setTimeout(() => {
bigSaveTimer = null
flushBigAnswers({ silent: true })
}, 800)
}
/**
* 立即把三小问答案落库
* @param {{force?: boolean, silent?: boolean}} [opts]
* force - 时间窗口已关闭时仍允许落库(仅自动交卷通道使用)
* silent - 失败不弹窗(由调用方决定提示方式)
* @returns {Promise<{ok: boolean, message?: string}>}
*/
async function flushBigAnswers(opts = {}) {
if (!bigQuestionEnabled.value) return { ok: true }
if (!bigLoaded.value) return { ok: true } // 未抽到题,无需保存
if (bigSaveTimer) {
clearTimeout(bigSaveTimer)
bigSaveTimer = null
}
const payload = {
answer_1: bigAnswers.value.answer_1 || '',
answer_2: bigAnswers.value.answer_2 || '',
answer_3: bigAnswers.value.answer_3 || '',
}
if (opts.force === true) payload.forceSubmit = true
bigSaving.value = true
try {
await examApi.saveBigAnswer(currentGroup.value, payload)
bigError.value = ''
return { ok: true }
} catch (e) {
// 超长(服务端 400)等失败:保留本地内容并回传错误,由页面提示,避免静默丢答案
const message = (e && e.message) || '综合大题答案保存失败'
bigError.value = message
if (!opts.silent) {
console.warn('综合大题答案保存失败:', message)
}
return { ok: false, message }
} finally {
bigSaving.value = false
}
}
/** 清空综合大题本地状态(含未落库的防抖定时器) */
function resetBigQuestion() {
if (bigSaveTimer) {
clearTimeout(bigSaveTimer)
bigSaveTimer = null
}
bigQuestion.value = null
bigAnswers.value = { answer_1: '', answer_2: '', answer_3: '' }
bigSubmitTime.value = null
bigMaxChars.value = DEFAULT_MAX_ANSWER_CHARS
bigSubCount.value = 3
bigReused.value = false
bigLoading.value = false
bigLoaded.value = false
bigError.value = ''
bigSaving.value = false
}
function resetExam() {
stopTimer()
stopDraftTimers()
resetBigQuestion()
questions.value = []
answers.value = {}
countdown.value = 0
examStarted.value = false
wordTemplate.value = null
wordUploaded.value = false
totalQuestions.value = 0
currentGroup.value = ''
examSessionId = ''
sessionStorage.removeItem('exam_session')
@@ -260,9 +682,54 @@ function isSessionValid() {
return saved === examSessionId && examSessionId !== ''
}
/** 恢复已有答案(考试重新进入时使用) */
/** 恢复已有答案(服务端为基线;考试重新进入时使用) */
function restoreAnswers(restoredAnswers) {
answers.value = { ...restoredAnswers }
answers.value = { ...(restoredAnswers || {}) }
}
/**
* 用本地草稿补齐服务端缺失的作答(考试重新进入时调用,且应在 restoreAnswers 之后)
*
* 合并规则(重要):**只补空位,绝不覆盖服务端已有值**。服务端是权威,
* 草稿仅承载「逐题保存失败因而未能上报」的作答。
* 场次隔离:草稿的 windowStart 与当前场次(startTime)不一致时判为上一场考试并丢弃。
*
* @returns {{merged:number, stale:boolean}} merged = 从草稿补回的题数
*/
function restoreDraft() {
const g = currentGroup.value
if (!g) return { merged: 0, stale: false }
const d = readDraft(g)
if (!d) return { merged: 0, stale: false }
// 不同场次(startTime 不同)→ 草稿属上一场考试,清除以免污染本场
if (draftWindowStart && d.windowStart && d.windowStart !== draftWindowStart) {
clearDraft(g)
return { merged: 0, stale: true }
}
const next = { ...answers.value }
let merged = 0
for (const [uid, ans] of Object.entries(d.answers || {})) {
if (ans === undefined || ans === null || String(ans).length === 0) continue
const cur = next[uid]
// 服务端已有作答 → 不覆盖(草稿可能是更早的内容)
if (cur !== undefined && cur !== null && String(cur).length > 0) continue
next[uid] = ans
merged++
}
answers.value = next
// 草稿里标记为「未成功落库」的题重新排入重试队列
const pending = { ...pendingSaves.value }
for (const uid of (d.pending || [])) {
if (next[uid] !== undefined) pending[uid] = next[uid]
}
if (Object.keys(pending).length) {
pendingSaves.value = pending
scheduleSaveRetry()
}
return { merged, stale: false }
}
export function useExamStore() {
@@ -273,15 +740,33 @@ export function useExamStore() {
answers,
countdown,
examStarted,
wordTemplate,
wordUploaded,
totalQuestions,
// 综合大题状态
bigQuestion,
bigAnswers,
bigSubmitTime,
bigMaxChars,
bigSubCount,
bigReused,
bigLoading,
bigLoaded,
bigError,
bigSaving,
// 计算属性
countdownDisplay,
isTimeUp,
progress,
choiceQuestions,
judgeQuestions,
// 综合大题计算属性
bigQuestionEnabled,
bigSubPrompts,
bigScorePerSub,
bigTotalScore,
bigAnsweredCount,
bigAllAnswered,
bigCharCounts,
bigCompleted,
// 方法
startExam,
startUploadCountdown,
@@ -291,5 +776,15 @@ export function useExamStore() {
resetExam,
isSessionValid,
restoreAnswers,
// 本地草稿与落库重试(防丢答案)
restoreDraft,
flushPendingSaves,
hasUnsynced,
pendingSaves,
// 综合大题方法
loadBigQuestion,
setBigAnswer,
flushBigAnswers,
resetBigQuestion,
})
}

View File

@@ -5,7 +5,18 @@
=========================================================== */
/* ============ CSS 变量定义 ============ */
:root {
/* 🔴 选择器必须是 html:root 而不是 :root(2026-10-21 R188 实测):
Element Plus 默认主题在同一 `:root` 上定义 `--el-color-primary:#409eff`,
两者特异性相同 ⇒ **谁后加载谁赢**。
· vite 5(rollup):EP 基础样式与 global.css 合并在同一个入口 CSS 内,
EP 在前、本文件在后 ⇒ 覆盖生效(生产旧包 index-CVAb-2kd.css 实测:
EP 规则 @27768 < 本文件覆盖 @57506)。
· vite 8(rolldown):EP 基础样式被拆成独立的 css-*.css 块,且
index.html 里排在入口 CSS **之后** ⇒ EP 默认值反过来赢,
主色退回 #409eff(登录按钮由深藏青变亮蓝)。
html:root = (0,0,1,1) 高于 :root = (0,0,1,0),与加载顺序无关。
⛔ 不要改回 :root —— 除非能证明 EP 样式一定排在前面。 */
html:root {
/* 主色调:深蓝色,用于导航栏、按钮等关键元素 */
--primary-color: #1a3a5c;
/* 辅助色:金色,用于强调、高亮、获奖标识等 */
@@ -371,7 +382,14 @@ input:-webkit-autofill {
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
新增页面自动继承此保障,无需逐页排查。
=========================================================== */
.el-button.is-text:not(.is-disabled):hover,
.el-button.is-text:not(.is-disabled):focus {
/* 🔴 选择器前缀 :root 是**必需**的(2026-10-21 R188 实测),不能省:
本规则与 EP 的 .el-button.is-text:not(.is-disabled):hover 同为 4 个类级,
旧构建靠「本文件在后」取胜;vite 8 把 EP 样式拆块后排在入口 CSS 之后,
同特异性下 EP 反过来赢 ⇒ hover 底变回不透明近白 #f5f7fa ⇒
「白底白字」缺陷复现(该缺陷此前已修复,见上方说明)。
前缀 :root 使特异性升到 5 个类级 ⇒ 与顺序无关,重新压过 EP。 */
:root .el-button.is-text:not(.is-disabled):hover,
:root .el-button.is-text:not(.is-disabled):focus {
background-color: rgba(120, 130, 145, 0.16);
}

View File

@@ -95,6 +95,32 @@ function getHeader(headers, name) {
return ''
}
/**
* 读取服务端声明的「真实字节数」哨兵(下载完整性校验用)。
*
* 🔴 R103(2026-10-12 生产实测):X-Accel-Redirect 分支下 nginx 会**丢弃上游自定义响应头**。
* 双通道实测同一文件:直连后端 ⇒ 带 `x-file-size: 19009`;经 nginx ⇒ 返回 19009 字节
* 但该头缺失(而 `Content-Disposition` 能穿透)。
* ⇒ 后端把同一字节数同时写入 RFC 6266 §4.1 的 disposition 扩展参数 `size=`,
* 这里按「x-file-size → CD 的 size=」顺序回落;两通道同源同值,任一缺失只降级不误伤。
*
* 判据可靠性:`filename*=` 的值是百分号编码的,其中不可能出现字面量 `size=`,
* 且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。
*
* @returns {number} 声明字节数;两通道都取不到时返回 NaN(调用方跳过校验,优雅降级)
*/
function readDeclaredSize(headers) {
const direct = parseInt(getHeader(headers, 'x-file-size'), 10)
if (Number.isFinite(direct) && direct > 0) return direct
const cd = getHeader(headers, 'content-disposition')
const m = /(?:^|;)\s*size\s*=\s*(\d+)\s*(?:;|$)/i.exec(cd || '')
if (m) {
const n = parseInt(m[1], 10)
if (Number.isFinite(n) && n > 0) return n
}
return NaN
}
// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
function normalizeUrl(url) {
if (url && url.startsWith('/api')) return url.slice(4)
@@ -155,8 +181,10 @@ function triggerDownload(blob, filename, originalUrl) {
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <html 开头;
// ② 大小哨兵:后端透出 X-File-Size(服务端 stat 的真实字节数),与 blob.size 比对,
// 不一致即说明内容被替换或传输被截断。该头缺失时跳过此校验(优雅降级,不误伤)。
// ② 大小哨兵:后端透出「服务端 stat 的真实字节数」,与 blob.size 比对,
// 不一致即说明内容被替换或传输被截断。取头顺序(见 readDeclaredSize):
// X-File-Size → Content-Disposition 的 `size=` 扩展参数 —— 两通道同源同值,
// 都取不到时跳过此校验(优雅降级,不误伤)。
async function assertRealFile(res) {
const blob = res.data
if (!blob) return '下载失败:响应内容为空'
@@ -176,7 +204,9 @@ async function assertRealFile(res) {
}
// ② 大小哨兵:与服务端 stat 的真实字节数比对
const declared = parseInt(getHeader(res.headers, 'x-file-size'), 10)
// 🔴 R103:头来源改为 readDeclaredSize —— 优先 x-file-size,缺失时回落 Content-Disposition
// 的 `size=` 扩展参数(X-Accel 分支下 nginx 会丢掉前者,见 readDeclaredSize 注释)。
const declared = readDeclaredSize(res.headers)
if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
}

View File

@@ -0,0 +1,36 @@
/**
* passwordField.js — 密码输入框「反浏览器自动填充」兜底
*
* 背景(2026-10-08 测试人员反馈):
* 激活页 / 忘记密码页进入「设置密码」步骤时,输入框内已存在内容(形如 123456),
* 而用户尚未进行任何输入。经全仓取证:前端不存在任何向密码框写值的赋值点
* (密码框初值为空、autocomplete="new-password"、无 :value 绑定、无历史版本预填),
* 故残留只能来自浏览器密码管理器 —— 测试账号的默认口令恰为 123456,
* 浏览器保存后会在同源同类表单上自动回填。
*
* 治法:在「进入设置密码步骤」时做一次确定性兜底清理。
*
* ⚠️ 安全前提:仅当表单模型本身为空时才清 DOM(isModelEmpty 为真),
* 因此绝不会覆盖用户已经真实输入的内容。
*/
/**
* 清除浏览器在密码框上留下的自动填充值。
*
* @param {string} scopeSelector 限定范围(默认 body 即整页)
* @param {boolean} isModelEmpty 表单模型是否为空;为 false 时整体跳过,不动 DOM
* @returns {number} 实际清除的输入框数量(便于验证/日志)
*/
export function purgeAutofilledPasswords(scopeSelector = 'body', isModelEmpty = true) {
if (!isModelEmpty || typeof document === 'undefined') return 0
let cleared = 0
document.querySelectorAll(`${scopeSelector} input[type="password"]`).forEach((el) => {
if (el.value) {
el.value = ''
// 同步通知 v-model:此时模型本为空,回调只是把状态与 DOM 拉回一致
el.dispatchEvent(new Event('input', { bubbles: true }))
cleared++
}
})
return cleared
}

228
src/utils/richtext.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* 富文本渲染安全工具(零依赖白名单消毒)
*
* 背景:综合大题题干由管理员通过 wangEditor 录入富文本(含表格),考生端以 v-html 渲染。
* v-html 直接插入未经处理的 HTML 等价于把渲染权交给内容作者,因此必须做纵深防御:
* 服务端已做基础危险片段拦截,前端在渲染前再做一次白名单收敛,保证即使内容被
* 绕过入口写入(历史数据 / 直接改库 / 其他写入通道),渲染侧依然无法执行脚本。
*
* 白名单策略(默认拒绝):
* - 标签:仅保留排版类标签(段落 / 标题 / 列表 / 表格 / 图片 / 链接 / 行内强调)
* - 属性:仅保留各标签的排版必需属性 + colspan/rowspan 等表格结构属性
* - 协议:href/src 仅允许 http / https / mailto / 站内相对路径 / data:image
* - style:保留(wangEditor 靠它输出对齐与颜色),但剔除 url()/expression() 等高危片段
*
* 未知标签走「解包」而非「整体删除」,避免把内容一起吃掉;危险标签整体丢弃并连同子树。
*/
// 整体丢弃(连同子树)——脚本/样式/嵌入对象/表单交互类
const DROP_TAGS = new Set([
'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
'link', 'meta', 'base', 'form', 'input', 'button', 'textarea', 'select', 'option',
'svg', 'math', 'template', 'noscript', 'audio', 'video', 'source', 'track',
'canvas', 'portal', 'dialog', 'marquee',
])
// 允许保留的标签(其余走解包)
const ALLOW_TAGS = new Set([
'p', 'br', 'hr', 'div', 'span', 'section', 'article',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col', 'tr', 'th', 'td',
'a', 'img', 'font',
])
// 允许保留的属性(全局)
const ALLOW_ATTRS_GLOBAL = new Set(['style', 'class', 'title', 'dir', 'lang'])
// 标签私有属性白名单
const ALLOW_ATTRS_BY_TAG = {
a: new Set(['href', 'target', 'rel']),
img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height']),
th: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'scope']),
col: new Set(['span', 'width']),
colgroup: new Set(['span', 'width']),
table: new Set(['border', 'cellpadding', 'cellspacing', 'width', 'align']),
ol: new Set(['start', 'type']),
font: new Set(['color', 'size', 'face']),
}
// style 属性内的危险片段(命中即整条 style 丢弃,宁可丢排版不丢安全)
const DANGEROUS_STYLE = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|\/\*/i
// 危险协议(含被空白/控制字符混淆的变体,如 "java\tscript:")
const DANGEROUS_PROTOCOL = /^(?:javascript|vbscript|file|blob)\s*:/i
/**
* 归一化协议文本:去掉 ASCII 控制字符与零宽字符后判协议
* (防止 "java&#9;script:" 之类的混淆绕过)
* @param {string} raw
* @returns {string}
*/
function normalizeUrl(raw) {
return String(raw || '')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0020\u007f\u200b-\u200f\ufeff]/g, '')
.toLowerCase()
}
/**
* 判断 URL 属性值是否安全
* @param {string} raw - 原始属性值
* @returns {boolean}
*/
function isSafeUrl(raw) {
const value = String(raw || '').trim()
if (!value) return false
const normalized = normalizeUrl(value)
if (DANGEROUS_PROTOCOL.test(normalized)) return false
if (normalized.startsWith('data:')) {
// 仅允许内联图片,杜绝 data:text/html(可执行脚本)
return /^data:image\/(png|jpe?g|gif|webp|bmp|svg\+xml);/i.test(normalized)
}
// 站内相对路径、锚点、以及 http/https/mailto/tel 放行
return /^(https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i.test(normalized)
}
/**
* 递归消毒 DOM 子树
* @param {Node} node
*/
function sanitizeNode(node) {
// 从后往前遍历,删除时不影响未处理节点的索引
const children = Array.from(node.childNodes)
for (const child of children) {
// 文本节点:保留
if (child.nodeType === Node.TEXT_NODE) continue
// 注释 / CDATA / 处理指令:整体丢弃
if (child.nodeType !== Node.ELEMENT_NODE) {
child.remove()
continue
}
const tag = child.tagName.toLowerCase()
if (DROP_TAGS.has(tag)) {
child.remove()
continue
}
if (!ALLOW_TAGS.has(tag)) {
// 未知标签:解包(把子节点提到父级),保留其内容但去掉标签语义
sanitizeNode(child)
while (child.firstChild) {
node.insertBefore(child.firstChild, child)
}
child.remove()
continue
}
sanitizeElementAttrs(child, tag)
sanitizeNode(child)
}
}
/**
* 收敛单个元素的属性
* @param {Element} el
* @param {string} tag - 小写标签名
*/
function sanitizeElementAttrs(el, tag) {
const tagAttrs = ALLOW_ATTRS_BY_TAG[tag] || new Set()
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase()
const value = attr.value
// 所有 on* 事件属性一律移除;data-* 也一律移除(无渲染需要)
if (name.startsWith('on') || name.startsWith('data-')) {
el.removeAttribute(attr.name)
continue
}
if (!ALLOW_ATTRS_GLOBAL.has(name) && !tagAttrs.has(name)) {
el.removeAttribute(attr.name)
continue
}
if (name === 'style') {
if (DANGEROUS_STYLE.test(value)) {
el.removeAttribute(attr.name)
}
continue
}
if (name === 'href' || name === 'src') {
if (!isSafeUrl(value)) {
el.removeAttribute(attr.name)
}
continue
}
if (name === 'target') {
// 只允许新窗口,并强制补 rel 防 window.opener 劫持
if (value !== '_blank') el.removeAttribute(attr.name)
continue
}
if (name === 'rel') {
continue
}
// 数值型结构属性:仅允许纯数字
if (['colspan', 'rowspan', 'span', 'start', 'width', 'height', 'border',
'cellpadding', 'cellspacing'].includes(name)) {
if (!/^\d+$/.test(String(value).trim())) {
el.removeAttribute(attr.name)
}
}
}
if (tag === 'a' && el.getAttribute('target') === '_blank') {
el.setAttribute('rel', 'noopener noreferrer nofollow')
}
}
/**
* 消毒富文本 HTML,返回可直接用于 v-html 的安全字符串
*
* @param {string} html - 原始富文本 HTML
* @returns {string} 消毒后的 HTML(非法输入返回空串)
*/
export function sanitizeHtml(html) {
const raw = String(html == null ? '' : html)
if (!raw.trim()) return ''
// 无 DOM 环境(如 SSR/单测)时退化为「剥标签」而不是原样返回,避免降级后反而放开注入面
if (typeof window === 'undefined' || typeof window.DOMParser === 'undefined') {
return raw.replace(/<[^>]*>/g, '')
}
try {
// 用 <template> 承载:内容落在 inert 的 DocumentFragment 中,不会执行脚本,
// 也不存在「被内容里的 </div> 提前闭合包装层导致越界/丢内容」的问题。
const tpl = document.createElement('template')
tpl.innerHTML = raw
sanitizeNode(tpl.content)
return tpl.innerHTML
} catch (e) {
// 解析失败一律降级为纯文本,绝不回退到原始 HTML
return raw.replace(/<[^>]*>/g, '')
}
}
/**
* 判断富文本内容是否为空(含只有空白/空标签/&nbsp; 的情况)
* @param {string} html
* @returns {boolean}
*/
export function isRichTextEmpty(html) {
const text = String(html == null ? '' : html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/gi, '')
.replace(/[\s\u3000]/g, '')
return text.length === 0
}

View File

@@ -135,6 +135,7 @@
:model="step3Form"
:rules="step3Rules"
label-position="top"
autocomplete="off"
>
<!-- 基本信息确认 -->
<div class="info-confirm">
@@ -149,16 +150,21 @@
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置登录密码(至少6位)"
autocomplete="new-password"
placeholder="请设置登录密码(6-20 位)"
size="large"
show-password
/>
<!-- 弱口令示例放在独立提示行,而不是 placeholder:
写在 placeholder 里会被用户误读成「默认密码就是 123456」(2026-10-08 测试人员反馈) -->
<div class="pwd-tip">不得使用 123456 等常见弱口令</div>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入密码"
size="large"
show-password
@@ -197,6 +203,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* Activate.vue - 账号激活页面(公开访问)
*
@@ -205,14 +215,21 @@
* 步骤2:发送短信验证码 -> 输入 6 位验证码 -> 验证
* 步骤3:确认基本信息 -> 设置密码 + 确认密码 -> 完成激活
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { purgeAutofilledPasswords } from '@/utils/passwordField'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
import * as authApi from '@/api/auth'
import { showTestSmsCode } from '@/utils/testSms'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
// 激活页语义 = 未登录。必须主动清除浏览器中可能残留的登录态:
// 否则激活成功/点「返回登录」跳 /login 时,router guard(router/index.js:203-222)
// 见到 isLoggedIn && role==='admin' 会把用户重定向进 /admin —— 公用电脑上
// 前一位管理员未退出时,下一位激活的考生会被直接送进管理后台且接口权限可用(R61)。
const authStore = useAuthStore()
// ==================== 状态 ====================
@@ -369,8 +386,8 @@ async function handleStep1() {
currentStep.value = 1
// 启动倒计时
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
// 刷新验证码
refreshCaptcha()
} finally {
@@ -386,7 +403,7 @@ async function handleStep2() {
// 验证短信码(此处通过获取用户信息间接验证)
step2Loading.value = true
// 短信码的验证由后端在激活时完成,此处仅进入下一步
currentStep.value = 2
enterStep3()
ElMessage.success('短信验证通过')
} catch (err) {
if (err?.message) ElMessage.error(err.message)
@@ -395,6 +412,22 @@ async function handleStep2() {
}
}
/**
* 进入「设置密码」步骤。
*
* 到达该步骤时输入框必须为空(2026-10-08 测试人员反馈:进入本步骤时密码框内已出现 123456)。
* 前端源码不存在任何回填密码的赋值点(初值为空 + autocomplete=new-password + 无历史预填版本),
* 残留只可能来自浏览器密码管理器自动填充(测试账号默认口令恰为 123456,会被浏览器保存并回填),
* 故此处做确定性兜底,保证「到达即空」;实现与前提见 utils/passwordField.js。
*/
function enterStep3() {
step3Form.password = ''
step3Form.confirmPassword = ''
currentStep.value = 2
// DOM 挂载后再兜底一次:仅当表单模型为空、DOM 却有值时清除,绝不覆盖用户真实输入
nextTick(() => purgeAutofilledPasswords('.step-card', !step3Form.password && !step3Form.confirmPassword))
}
/** 步骤3:完成激活 */
async function handleActivate() {
if (!step3FormRef.value) return
@@ -408,12 +441,15 @@ async function handleActivate() {
smsCode: step2Form.smsCode,
})
ElMessage.success('账号激活成功!请登录参赛')
// 兜底再清一次(R61):确保跳转瞬间 localStorage 内无残留登录态,
// 否则 /login 的守卫会把用户送进 /admin。
authStore.clearAuth()
// 跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally {
step3Loading.value = false
}
@@ -430,8 +466,8 @@ async function resendSms() {
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
refreshCaptcha()
}
}
@@ -452,6 +488,9 @@ function startSmsCountdown() {
// ==================== 生命周期 ====================
onMounted(() => {
// 进入激活页即按「未登录」处理(R61):清掉残留 token/user,
// 覆盖两条会跳 /login 的路径 —— ①本页「返回登录」按钮 ②激活成功后的跳转。
authStore.clearAuth()
refreshCaptcha()
})
@@ -632,6 +671,13 @@ onUnmounted(() => {
}
/* ==================== 按钮 ==================== */
.pwd-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.6;
color: #909399;
}
.step-btn {
width: 100%;
height: 46px;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,16 +18,20 @@
</div>
<div class="card-body">
<p class="exam-desc" v-if="contestStatus === 'ended'">考试已结束,无法再次进入</p>
<p class="exam-desc" v-else-if="(!contestTime || contestStatus === 'not_started') && !hasSubmitted">
<p class="exam-desc" v-else-if="hasSubmitted && !isUploadGroup">
您已提交试卷,本次考试已结束,无法再次进入。
</p>
<p class="exam-desc" v-else-if="hasSubmitted">您已提交作品,截止前可重新提交。</p>
<p class="exam-desc" v-else-if="!contestTime || contestStatus === 'not_started'">
{{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }}
</p>
<p class="exam-desc" v-else-if="!hasSubmitted">请关注比赛时间安排</p>
<p class="exam-desc" v-else>请关注比赛时间安排</p>
<el-button
type="primary"
size="large"
class="card-action-btn"
v-guard="() => enterContest()"
:disabled="contestStatus === 'ended'"
:disabled="enterDisabled"
>
{{ enterBtnText }}
</el-button>
@@ -112,6 +116,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* Dashboard.vue - 选手工作台
*
@@ -158,23 +166,43 @@ const isUploadGroup = computed(() => {
return g === 'benke' || g === 'jiaoshi'
})
const noScore = computed(() => !scoreData.value || scoreData.value.score === undefined || scoreData.value.score === null)
// 🔴 成绩发布门控(铁律15):**必须**同时满足 published === true 才渲染分数。
// 历史实现只判 score 是否为 null —— 这与后端「未发布则 score 置 null」的约定形成
// 隐式耦合:一旦后端任一通道在未发布状态下带上数值 score,本卡片即直接泄露。
// Score.vue 早已用 `v-if="scoreData && scoreData.published"` 同源判定,此处对齐,
// 形成「两条展示通道同一判据」的纵深防御。
const noScore = computed(() =>
!scoreData.value ||
scoreData.value.published !== true ||
scoreData.value.score === undefined ||
scoreData.value.score === null
)
const scoreMessage = computed(() => {
if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告'
return '暂无成绩,请耐心等待成绩发布后查看'
})
// 进入比赛按钮文案:截止后→「已结束」;已提交→ 提交组「重新提交」/考试组「继续考试」;
// 其余→「进入比赛」。考试组(中职/高职)已提交也可在截止前重进(应对意外退出)。
// 进入比赛按钮文案:
// 截止后 → 「已结束」(禁用)
// 考试组 + 已提交 → 「已提交」(禁用,不可再进入;测试反馈 #3)
// 提交组 + 已提交 → 「重新提交」(截止前可重传,用户既定需求)
// 其余 → 「进入比赛」
// ⚠️ 考试组(中职/高职)为一次性答题:正式交卷后不允许重进(防改答案再交卷)。
// 未交卷的意外退出(断网/断电)不受影响——submitted 仅在 submit_time 落库后为 true。
const enterBtnText = computed(() => {
if (contestStatus.value === 'ended') return '已结束'
if (hasSubmitted.value) {
return isUploadGroup.value ? '重新提交' : '继续考试'
return isUploadGroup.value ? '重新提交' : '已提交'
}
return '进入比赛'
})
// 考试组已交卷 ⇒ 禁止再次进入
const enterDisabled = computed(() =>
contestStatus.value === 'ended' || (hasSubmitted.value && !isUploadGroup.value)
)
// 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动
// 后端 getExamStatus 返回 status ∈ {not_set, not_started, in_progress, ended},
// 此前前端误判 === 'open' 导致活动期恒定显示「未开始」;现统一在此映射并纳入已提交态。
@@ -208,16 +236,24 @@ function formatDate(dateStr) {
async function enterContest() {
// 所有组别统一:仅考试窗口内(isActive)允许进入;截止后(ended)统一禁止。
// 考试组/提交组即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
// 提交组(本科/教师)即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
// 考试组(中职/高职)为一次性答题:**正式交卷后禁止再进入**(测试反馈 #3),
// 退出重进只对「未交卷」的情况开放。
try {
const group = authStore.userGroup || 'zhongzhi'
const isExam = group === 'zhongzhi' || group === 'gaozhi'
const data = await examApi.getExamStatus(group)
if (isExam && data.submitted) {
hasSubmitted.value = true
ElMessage.warning('您已提交试卷,无法再次进入考试')
return
}
if (!data.isActive) {
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
return
}
} catch (e) {
ElMessage.warning('获取考试状态失败,请稍后重试')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
return
}
router.push('/competition')

View File

@@ -134,21 +134,27 @@
:model="step3Form"
:rules="step3Rules"
label-position="top"
autocomplete="off"
>
<el-form-item label="新密码" prop="password">
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置新密码(至少6位)"
autocomplete="new-password"
placeholder="请设置新密码(6-20 位)"
size="large"
show-password
/>
<!-- 弱口令示例放在独立提示行,而不是 placeholder:
写在 placeholder 里会被用户误读成「默认密码就是 123456」(2026-10-08 测试人员反馈) -->
<div class="pwd-tip">不得使用 123456 等常见弱口令</div>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="step3Form.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入新密码"
size="large"
show-password
@@ -187,6 +193,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* ForgotPassword.vue - 忘记密码页面(公开访问)
*
@@ -195,7 +205,8 @@
* 步骤2:短信验证码验证
* 步骤3:设置新密码
*/
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { purgeAutofilledPasswords } from '@/utils/passwordField'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
@@ -343,8 +354,8 @@ async function handleStep1() {
ElMessage.success('短信验证码已发送')
currentStep.value = 1
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
refreshCaptcha()
} finally {
step1Loading.value = false
@@ -358,7 +369,7 @@ async function handleStep2() {
await step2FormRef.value.validate()
step2Loading.value = true
// 短信码由后端在重置密码时验证
currentStep.value = 2
enterStep3()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} finally {
@@ -366,6 +377,22 @@ async function handleStep2() {
}
}
/**
* 进入「重置密码」步骤。
*
* 到达该步骤时输入框必须为空(2026-10-08 测试人员反馈:进入本步骤时密码框内已出现 123456)。
* 前端源码不存在任何回填密码的赋值点(初值为空 + autocomplete=new-password),
* 残留只可能来自浏览器密码管理器自动填充,故此处做确定性兜底,保证「到达即空」;
* 实现与前提见 utils/passwordField.js。
*/
function enterStep3() {
step3Form.password = ''
step3Form.confirmPassword = ''
currentStep.value = 2
// DOM 挂载后再兜底一次:仅当表单模型为空、DOM 却有值时清除,绝不覆盖用户真实输入
nextTick(() => purgeAutofilledPasswords('.step-card', !step3Form.password && !step3Form.confirmPassword))
}
/** 步骤3:重置密码 */
async function handleReset() {
if (!step3FormRef.value) return
@@ -381,8 +408,8 @@ async function handleReset() {
setTimeout(() => {
router.push('/login')
}, 1500)
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally {
step3Loading.value = false
}
@@ -399,8 +426,8 @@ async function resendSms() {
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
refreshCaptcha()
}
}
@@ -582,6 +609,13 @@ onUnmounted(() => {
font-weight: 600;
}
.pwd-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.6;
color: #909399;
}
.step-btn {
width: 100%;
height: 46px;

View File

@@ -54,6 +54,7 @@
<el-input
v-model="contestantForm.password"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
@@ -103,6 +104,7 @@
<el-input
v-model="adminForm.password"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
@@ -137,6 +139,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* Login.vue - 登录页面(公开访问)
*
@@ -252,10 +258,8 @@ async function handleAdminLogin() {
ElMessage.success('管理员登录成功')
const redirect = route.query.redirect || '/admin'
router.push(redirect)
} catch (err) {
if (err?.message) {
ElMessage.error(err.message)
}
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally {
adminLoading.value = false
}

View File

@@ -72,14 +72,14 @@
</div>
</div>
<!-- Word 答题 -->
<!-- 最后一题:中职/高职 = 综合大题;本科/教师 = Word 答题(与正式考试一致) -->
<div class="nav-section">
<div class="nav-section-title">Word 答题</div>
<div class="nav-section-title">{{ isBigQuestionGroup ? '综合大题' : 'Word 答题' }}</div>
<div class="question-nav">
<div
class="nav-item"
:class="{
'nav-answered': wordCompleted,
'nav-answered': lastSectionCompleted,
'nav-current': currentIndex === wordIndex,
}"
@click="currentIndex = wordIndex"
@@ -166,8 +166,45 @@
</div>
</div>
<!-- Word 答题 -->
<div v-if="currentIndex === wordIndex" class="question-card word-card">
<!-- 综合大题(中职/高职组)—— 正式考试已取消 Word 上传题,改为在线作答 -->
<div v-if="currentIndex === wordIndex && isBigQuestionGroup" class="question-card word-card">
<div class="question-header">
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
<el-tag type="primary" size="small">综合大题</el-tag>
<span class="question-score">(30 分)</span>
</div>
<div class="question-body">
<p class="question-text">
模拟体验:正式考试中本题由系统从题库随机抽取(题干含文字描述与表格),
共 {{ mockBigSubPrompts.length }} 小问,每小问 10 分、作答上限 {{ MOCK_BIG_MAX_CHARS }} 字,
请直接在本页输入框中作答(模拟考试不会真实提交)。
</p>
<div class="big-sections">
<div v-for="(prompt, si) in mockBigSubPrompts" :key="'big-' + si" class="big-section">
<h4>第 {{ si + 1 }} 小问(10 分)</h4>
<p class="big-prompt">{{ prompt }}</p>
<el-input
v-model="bigAnswers[si]"
type="textarea"
:rows="5"
:maxlength="MOCK_BIG_MAX_CHARS"
show-word-limit
placeholder="请输入作答内容"
/>
</div>
</div>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- Word 答题(本科/教师组;模拟考试,文件不会真实上传) -->
<div v-if="currentIndex === wordIndex && !isBigQuestionGroup" class="question-card word-card">
<div class="question-header">
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合题</el-tag>
@@ -226,7 +263,7 @@
<div class="exam-bottom-bar">
<div class="exam-bottom-inner">
<span class="submit-hint">
请确保已检查所有题目和 Word 文件后提交
{{ isBigQuestionGroup ? '请确保已检查所有题目和综合大题后提交' : '请确保已检查所有题目和 Word 文件后提交' }}
</span>
<el-button
type="danger"
@@ -267,7 +304,8 @@ const optionLabels = ['A', 'B', 'C', 'D']
// ==================== 模拟题目数据 ====================
// 设计意图(与用户明确要求一致):
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道(+ 1 道 Word 综合题)。
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道
// (+ 1 道最后一题:中职/高职为「综合大题」在线作答,本科/教师为 Word 上传)。
// 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题),
// 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。
// 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。
@@ -316,6 +354,24 @@ const mockJudgeQuestions = Array.from({ length: MOCK_JUDGE_COUNT }, (_, i) => ({
// 合并所有题目(选择题在前、判断题在后,与在线考试一致)
const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions]
// ---------------------------------------------------------------------------
// 最后一题的形态随组别变化(与正式考试保持一致,2026-09-21 改造):
// · 中职组 / 高职组:正式考试已取消 Word 文件上传题,改为「综合大题」在线作答
// (题干含文字描述与表格,3 小问、每小问 10 分、作答上限 2000 字)。
// 模拟页必须同步呈现该形态,否则考生会在模拟里看到一道正式考试并不存在的题。
// · 本科组 / 教师组:正式考试仍为 Word 文档上传,模拟页保持原样。
// 注意:模拟页始终为**纯前端**演示,不对正式考试的题库/上传通道发起任何请求。
// ---------------------------------------------------------------------------
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
const MOCK_BIG_MAX_CHARS = 2000
// 综合大题样题(3 小问,仅用于模拟演示,与正式题库无关)
const mockBigSubPrompts = [
'请简述你对题干所给方案的总体思路。',
'请说明方案实施的关键步骤与所需条件。',
'请分析该方案可能存在的问题并给出改进建议。',
]
// ==================== 状态 ====================
const currentIndex = ref(0)
const answers = ref({})
@@ -324,13 +380,29 @@ const remainingSeconds = ref(3600) // 60分钟模拟
const wordCompleted = ref(false)
const wordFileName = ref('')
const wordFileList = ref([])
// 综合大题(中职/高职)模拟作答:与正式考试的 3 个输入框同口径
const bigAnswers = ref(mockBigSubPrompts.map(() => ''))
let timerInterval = null
// ==================== 计算属性 ====================
const totalCount = computed(() => allQuestions.length + 1) // +1 for Word
// 当前用户组是否走「综合大题」形态
const isBigQuestionGroup = computed(() => BIG_QUESTION_GROUPS.includes(authStore.userGroup))
const totalCount = computed(() => allQuestions.length + 1) // +1 为最后一题
const wordIndex = computed(() => allQuestions.length)
// 综合大题是否答满(3 小问均有非空内容,与后端 isCompleted 同口径)
const bigCompleted = computed(() =>
mockBigSubPrompts.length > 0 &&
bigAnswers.value.slice(0, mockBigSubPrompts.length).every(v => String(v || '').trim().length > 0)
)
// 最后一题是否已完成(按组别取对应口径,供导航着色与进度统计统一使用)
const lastSectionCompleted = computed(() =>
isBigQuestionGroup.value ? bigCompleted.value : wordCompleted.value
)
const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length)
const currentQuestion = computed(() => {
@@ -342,7 +414,7 @@ const currentQuestion = computed(() => {
const answeredCount = computed(() => {
let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length
if (wordCompleted.value) count++
if (lastSectionCompleted.value) count++
return count
})
@@ -739,15 +811,30 @@ onUnmounted(() => {
.options-group {
display: flex;
flex-direction: column;
/* 与 Competition.vue 同款修正(测试反馈 #1):覆盖 Element Plus
`.el-radio-group{align-items:center}`,否则各选项按自身内容宽度收缩居中、宽窄不一。 */
align-items: stretch;
gap: 12px;
/* 以最长选项的边框宽度为统一宽度,整块居中,文字左对齐 */
width: max-content;
max-width: 100%;
margin: 0 auto;
}
.option-item {
width: 100%;
box-sizing: border-box;
text-align: left;
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: background 0.2s;
}
.option-item:hover { border-color: #409eff; }
/* 超长选项不得撑破卡片:允许换行,避免 max-content 溢出 */
.options-group :deep(.el-radio__label) {
white-space: normal;
overflow-wrap: anywhere;
}
.option-label {
font-weight: 600;
color: #409eff;
@@ -789,6 +876,24 @@ onUnmounted(() => {
color: #303133;
}
/* 综合大题(中职/高职组) */
.big-sections {
display: flex;
flex-direction: column;
gap: 20px;
}
.big-section h4 {
font-size: 15px;
color: #1a3a5c;
margin-bottom: 8px;
}
.big-prompt {
font-size: 14px;
color: #606266;
line-height: 1.7;
margin-bottom: 10px;
}
/* ==================== 底部提交栏 ==================== */
.exam-bottom-bar {
background: #fff;

View File

@@ -49,6 +49,22 @@
<el-divider />
<!-- R176:附件下载(成绩公示表等「文件类公示」)
放在正文之前:附件是这类新闻的**主内容**,正文只是说明。
⛔ 链接直接用后端返回的 /public/... 相对路径(dev 由 vite 代理、
生产由 nginx 直出),不要拼 /api 前缀。 -->
<div v-if="news.attachment_url" class="article-attachment">
<el-icon class="article-attachment__icon"><Paperclip /></el-icon>
<span class="article-attachment__label">附件下载:</span>
<a
:href="news.attachment_url"
class="article-attachment__link"
target="_blank"
rel="noopener"
:download="news.attachment_name || ''"
>{{ news.attachment_name || '点击下载附件' }}</a>
</div>
<!-- 微信链接型新闻 -->
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
<el-alert type="info" :closable="false" show-icon>
@@ -251,6 +267,36 @@ onMounted(() => {
padding: 20px 0;
}
/* R176:附件下载块(成绩公示表等文件类公示) */
.article-attachment {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 12px 16px;
margin: 8px 0 16px;
background: #f0f6ff;
border: 1px solid #d6e4f5;
border-radius: 6px;
}
.article-attachment__icon {
color: #1a3a5c;
}
.article-attachment__label {
font-size: 14px;
color: #4b5563;
}
.article-attachment__link {
font-size: 14px;
font-weight: 600;
color: #1a3a5c;
text-decoration: underline;
word-break: break-all;
}
.article-attachment__link:hover {
color: #c9a84c;
}
.wx-link-btn {
text-decoration: none;
}

View File

@@ -1,735 +0,0 @@
<template>
<div class="upload-page">
<!-- 顶部导航 -->
<div class="upload-header">
<div class="upload-header-left">
<el-button text @click="$router.push('/')" class="back-btn">
<el-icon><HomeFilled /></el-icon><span>返回首页</span>
</el-button>
<el-button text @click="$router.push('/dashboard')" class="back-btn">
<el-icon><ArrowLeft /></el-icon><span>返回工作台</span>
</el-button>
</div>
<h2 class="upload-title">作品提交</h2>
<span class="group-tag">{{ groupLabel }}</span>
</div>
<div class="upload-content">
<!-- 截止时间 -->
<div class="deadline-card" v-if="deadline">
<div class="deadline-info">
<el-icon :size="20"><Clock /></el-icon>
<span>提交截止时间:</span>
<strong :class="{ 'deadline-near': isDeadlineNear }">{{ formatDeadline(deadline) }}</strong>
</div>
<div class="deadline-countdown" v-if="deadlineCountdown > 0">
<span>剩余时间:</span>
<strong>{{ formatCountdown(deadlineCountdown) }}</strong>
</div>
</div>
<!-- 教师组双上传区 -->
<template v-if="isTeacher">
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">教学方案</h3>
<p class="upload-card-desc">请上传教学方案文件(PDF、Word 格式)</p>
<file-upload-area
group="jiaoshi"
file-type="word"
:max-size="100 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">说课 PPT</h3>
<p class="upload-card-desc">请上传说课 PPT 文件</p>
<file-upload-area
group="jiaoshi"
file-type="ppt"
:max-size="100 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 本科组双上传区(Word + PPT) -->
<template v-else>
<el-row :gutter="24">
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(Word)</h3>
<p class="upload-card-desc">请上传参赛作品文档(PDF、Word 格式)</p>
<file-upload-area
:group="userGroup"
file-type="word"
:max-size="50 * 1024 * 1024"
accept=".pdf,.doc,.docx"
@upload-success="onUploadSuccess('word', $event)"
/>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="upload-card">
<h3 class="upload-card-title">参赛作品(PPT)</h3>
<p class="upload-card-desc">请上传参赛作品演示文稿(PPT 格式)</p>
<file-upload-area
:group="userGroup"
file-type="ppt"
:max-size="50 * 1024 * 1024"
accept=".ppt,.pptx"
@upload-success="onUploadSuccess('ppt', $event)"
/>
</div>
</el-col>
</el-row>
</template>
<!-- 上传历史 -->
<div class="history-card">
<h3 class="history-title">提交记录</h3>
<div v-loading="historyLoading">
<el-table :data="submissionHistory" stripe empty-text="暂无提交记录">
<el-table-column prop="fileName" label="文件名" min-width="180" show-overflow-tooltip />
<el-table-column prop="fileSize" label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" width="170">
<template #default="{ row }">{{ formatDate(row.uploadTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button
text
type="danger"
size="small"
:loading="deletingId === row.id"
v-guard="() => deleteSubmission(row)"
>删除</el-button>
<el-button text type="primary" size="small" @click="reupload">重新上传</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
<script setup>
/**
* Upload.vue - 作品上传页面(需要登录,benke/jiaoshi 组)
*
* 功能:
* 1. 截止时间倒计时
* 2. 拖拽上传区域
* 3. 文件类型/大小校验
* 4. 分片上传 + 进度条
* 5. MD5 哈希显示
* 6. 上传历史列表
* 7. 教师组双上传区(教学方案 + 说课PPT)
*/
import { ref, computed, onMounted, onUnmounted, h, resolveComponent } from 'vue'
import { ElMessage, ElMessageBox, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { ArrowLeft, HomeFilled, Clock, UploadFilled, Delete } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const authStore = useAuthStore()
// ==================== 计算属性 ====================
const userGroup = computed(() => authStore.userGroup)
const groupLabel = computed(() => {
const map = { benke: '本科组', jiaoshi: '教师组' }
return map[userGroup.value] || userGroup.value
})
const isTeacher = computed(() => userGroup.value === 'jiaoshi')
const isDeadlineNear = computed(() => {
if (!deadline.value) return false
const diff = new Date(deadline.value) - new Date()
return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000
})
// ==================== 状态 ====================
const deadline = ref('')
const deadlineCountdown = ref(0)
const submissionHistory = ref([])
const historyLoading = ref(false)
const deletingId = ref(null)
let deadlineTimer = null
// ==================== 文件上传子组件(内联实现) ====================
// 使用简单的内联上传逻辑替代子组件
const uploadStates = reactive({})
// ==================== 方法 ====================
function formatDate(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatDeadline(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatSize(bytes) {
return formatFileSize(bytes || 0)
}
function formatCountdown(seconds) {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}天 ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
/** 启动截止倒计时 */
function startDeadlineCountdown() {
clearInterval(deadlineTimer)
if (!deadline.value) return
deadlineTimer = setInterval(() => {
const diff = Math.floor((new Date(deadline.value) - new Date()) / 1000)
deadlineCountdown.value = Math.max(0, diff)
}, 1000)
}
/** 获取提交历史 */
async function fetchHistory() {
historyLoading.value = true
try {
const data = await uploadApi.getSubmissions(userGroup.value)
if (Array.isArray(data)) {
submissionHistory.value = data
} else if (data?.list) {
submissionHistory.value = data.list
}
} catch (err) {
console.warn('获取提交历史失败', err)
} finally {
historyLoading.value = false
}
}
/** 获取上传状态 */
async function fetchUploadStatus() {
try {
const data = await uploadApi.getUploadStatus(userGroup.value)
if (data?.deadline) {
deadline.value = data.deadline
startDeadlineCountdown()
}
} catch (err) {
console.warn('获取上传状态失败', err)
}
}
function onUploadSuccess(type, result) {
ElMessage.success('文件上传成功')
fetchHistory()
}
/** 删除已上传的作品文件(本科/教师组) */
async function deleteSubmission(row) {
try {
await ElMessageBox.confirm(
`确定要删除「${row.fileName}」吗?删除后可重新上传进行替换。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
deletingId.value = row.id
try {
await uploadApi.deleteWork(userGroup.value, row.id)
ElMessage.success('文件已删除')
fetchHistory()
} catch (err) {
ElMessage.error('删除失败,请重试')
} finally {
deletingId.value = null
}
}
/** 滚动到上传区以便重新上传 */
function reupload() {
const el = document.querySelector('.upload-card')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// ==================== 生命周期 ====================
onMounted(() => {
// 组别检查
if (!['benke', 'jiaoshi'].includes(authStore.userGroup)) {
ElMessage.warning('您所在的组别不需要提交作品')
// 不强制跳转,避免闪烁
}
fetchUploadStatus()
fetchHistory()
})
onUnmounted(() => {
if (deadlineTimer) clearInterval(deadlineTimer)
})
</script>
<!-- ==================== 文件上传子区域组件模板 ==================== -->
<script>
/**
* file-upload-area 是一个内联组件
* 为了保持 Vue SFC 的简洁性,我们用 defineComponent
*/
import { defineComponent, ref, reactive, h, resolveComponent, computed } from 'vue'
import { ElMessage, ElUpload, ElProgress, ElButton, ElTag, ElIcon } from 'element-plus'
import { UploadFilled, Delete } from '@element-plus/icons-vue'
import * as uploadApi from '@/api/upload'
import { chunkFile, calculateMd5, uploadWithRetry, formatFileSize } from '@/utils/upload'
const FileUploadArea = defineComponent({
name: 'FileUploadArea',
props: {
group: { type: String, required: true },
fileType: { type: String, default: 'work' },
maxSize: { type: Number, default: 50 * 1024 * 1024 },
accept: { type: String, default: '.pdf,.doc,.docx,.ppt,.pptx,.zip' },
},
emits: ['upload-success'],
setup(props, { emit }) {
const fileList = ref([])
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadedInfo = ref(null)
function validateFile(file) {
const raw = file.raw || file
if (raw.size > props.maxSize) {
const maxMB = props.maxSize / (1024 * 1024)
ElMessage.error(`文件大小不能超过 ${maxMB}MB`)
return false
}
return true
}
function handleFileChange(file) {
if (!validateFile(file)) {
fileList.value = []
return
}
fileList.value = [file]
uploadedInfo.value = null
// 选择文件后自动开始上传,提供即时反馈
startUpload()
}
function removeFile() {
if (uploading.value) {
ElMessage.warning('文件正在上传中,请等待完成后再操作')
return
}
fileList.value = []
uploadedInfo.value = null
uploadPercent.value = 0
}
async function startUpload() {
// 防重入:选择文件会自动触发一次上传,用户若再点「开始上传」会造成同一文件
// 双份分片流并发,既浪费连接池也可能让后端合并出错。
if (uploading.value) return
if (fileList.value.length === 0) {
ElMessage.warning('请先选择文件')
return
}
const rawFile = fileList.value[0]?.raw || fileList.value[0]
if (!rawFile) return
uploading.value = true
uploadPercent.value = 0
try {
// 计算 MD5 作为 fileId
let fileId = ''
try {
fileId = await calculateMd5(rawFile)
} catch (e) {
console.warn('MD5 计算失败', e)
fileId = rawFile.name + '_' + Date.now()
}
// 分片上传
const chunks = chunkFile(rawFile)
for (let i = 0; i < chunks.length; i++) {
const formData = new FormData()
// ⚠️ Egg stream 模式下 stream.fields 仅解析「出现在文件字段之前」的普通字段。
// 必须把文本字段放在 chunk(文件)之前,否则后端收不到 chunkIndex/fileId/fileName -> 400 缺参。
formData.append('chunkIndex', chunks[i].index)
formData.append('totalChunks', chunks[i].total)
formData.append('fileId', fileId)
formData.append('fileName', rawFile.name)
formData.append('chunk', chunks[i].chunk, rawFile.name)
await uploadWithRetry(
() => uploadApi.uploadChunk(props.group, formData),
3, 1000
)
uploadPercent.value = Math.round(((i + 1) / chunks.length) * 100)
}
// 所有分片上传完成,调用合并接口(透传 file_type:word/ppt)
await uploadApi.mergeChunks(props.group, {
fileId,
fileName: rawFile.name,
totalChunks: chunks.length,
work_name: rawFile.name,
file_type: props.fileType,
})
uploadedInfo.value = {
fileName: rawFile.name,
fileSize: rawFile.size,
fileId,
}
ElMessage.success('上传成功')
emit('upload-success', { type: props.fileType, info: uploadedInfo.value })
} catch (err) {
ElMessage.error('上传失败,请重试')
} finally {
uploading.value = false
}
}
return () => {
return h('div', { class: 'file-upload-wrapper' }, [
// 拖拽上传区
!uploadedInfo.value ? [
h('div', {
class: 'drag-area',
onClick: () => {
// 触发文件选择
},
}, [
h('input', {
type: 'file',
accept: props.accept,
style: { display: 'none' },
ref: 'fileInput',
onChange: (e) => {
const file = e.target.files?.[0]
if (file) {
handleFileChange({ raw: file, name: file.name, size: file.size })
}
e.target.value = ''
},
}),
h('div', {
class: 'drag-content',
onClick: (e) => {
// 点击时触发隐藏的 file input
e.currentTarget.previousElementSibling?.click()
},
}, [
h(resolveComponent('el-icon'), { size: 48 }, () => h(UploadFilled)),
h('div', { class: 'drag-text' }, '拖拽文件到此处或 '),
h('em', null, '点击上传'),
h('div', { class: 'drag-hint' },
`支持 ${props.accept.replace(/\./g, '')} 格式,最大 ${props.maxSize / (1024 * 1024)}MB`
),
]),
]),
// 已选文件
fileList.value.length > 0 ? h('div', { class: 'selected-file' }, [
h('span', { class: 'selected-name' }, fileList.value[0].name || fileList.value[0].fileName),
h('el-button', {
type: 'danger',
size: 'small',
icon: h(Delete),
circle: true,
onClick: removeFile,
}),
]) : null,
// 上传按钮
fileList.value.length > 0 ? h('div', { class: 'upload-actions' }, [
h('el-button', {
type: 'primary',
loading: uploading.value,
onClick: startUpload,
}, { default: () => '开始上传' }),
]) : null,
] : null,
// 上传进度
uploading.value ? h('div', { class: 'upload-progress' }, [
h(resolveComponent('el-progress'), {
percentage: uploadPercent.value,
status: uploadPercent.value >= 100 ? 'success' : undefined,
}),
]) : null,
// 已上传文件信息
uploadedInfo.value ? h('div', { class: 'uploaded-card' }, [
h(resolveComponent('el-tag'), { type: 'success', size: 'small' }, () => '上传成功'),
h('div', { class: 'uploaded-name' }, uploadedInfo.value.fileName),
h('div', { class: 'uploaded-meta' }, [
h('span', null, formatFileSize(uploadedInfo.value.fileSize)),
uploadedInfo.value.md5 ? h('span', null, `MD5: ${uploadedInfo.value.md5.slice(0, 16)}...`) : null,
].filter(Boolean)),
h('el-button', {
text: true,
type: 'danger',
size: 'small',
onClick: removeFile,
}, { default: () => '重新上传' }),
]) : null,
])
}
},
})
export default {
components: { FileUploadArea },
}
</script>
<style scoped>
.upload-page {
min-height: 100vh;
background: #f5f6f8;
padding-bottom: 40px;
}
/* ==================== 顶部导航 ==================== */
.upload-header {
background: #1a3a5c;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn { color: rgba(255,255,255,0.85); white-space: nowrap; }
.back-btn:hover { color: #c9a84c; }
.upload-title {
font-size: 18px;
font-weight: 600;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.upload-header-left {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.group-tag {
background: rgba(201,168,76,0.2);
color: #c9a84c;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
/* ==================== 内容 ==================== */
.upload-content {
max-width: 1000px;
margin: 24px auto 0;
padding: 0 20px;
}
/* ==================== 截止时间 ==================== */
.deadline-card {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.deadline-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
color: #6b7280;
}
.deadline-near { color: #f56c6c; }
.deadline-countdown {
font-size: 18px;
color: #1a3a5c;
}
/* ==================== 上传卡片 ==================== */
.upload-card {
background: #fff;
border-radius: 12px;
padding: 28px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.upload-card-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8px;
}
.upload-card-desc {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
}
/* ==================== 拖拽上传区 ==================== */
.drag-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.drag-area:hover {
border-color: #1a3a5c;
background: #f0f4f8;
}
.drag-content {
color: #6b7280;
}
.drag-text {
font-size: 14px;
margin-top: 12px;
}
.drag-hint {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
/* ==================== 已选文件 ==================== */
.selected-file {
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4f8;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
}
.selected-name {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.upload-actions {
margin-top: 12px;
text-align: center;
}
.upload-progress {
margin-top: 16px;
}
/* ==================== 上传成功信息 ==================== */
.uploaded-card {
border: 1px solid #bbf7d0;
background: #f0fdf4;
border-radius: 10px;
padding: 20px;
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.uploaded-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
flex: 1;
min-width: 200px;
word-break: break-all;
}
.uploaded-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #6b7280;
width: 100%;
}
/* ==================== 历史记录 ==================== */
.history-card {
background: #fff;
border-radius: 12px;
padding: 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.history-title {
font-size: 18px;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 16px;
}
/* ==================== Responsive ==================== */
@media (max-width: 480px) {
.upload-header { padding: 0 12px; }
.deadline-card { flex-direction: column; }
}
</style>

View File

@@ -1,11 +1,52 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, URL } from 'node:url'
// Element Plus 按需样式模块清单(供下方 optimizeDeps.include 使用)。
// 为什么不能用 glob:Vite 的 include 需要「运行时请求的 specifier 形式」(无扩展名,
// 形如 element-plus/es/components/table/style/css),而 glob 匹配的是文件系统条目 ——
// `style/css` 在磁盘上是目录而非文件,glob 会静默匹配 0 项(实测 '*/style/css' → 0 项);
// 写成 '*/style/css.mjs' 虽能匹配 118 项,但其 key 与运行时 specifier 不一致,
// Vite 仍会把每个组件判定为「新依赖」⇒ 等于没配。
// 因此改为扫描目录动态生成:既不硬编码 118 项,也不会随 element-plus 升级失效。
const EP_COMPONENTS_DIR = fileURLToPath(
new URL('./node_modules/element-plus/es/components', import.meta.url)
)
const EP_STYLE_INCLUDES = (() => {
try {
return fs
.readdirSync(EP_COMPONENTS_DIR)
.filter((name) => fs.existsSync(path.join(EP_COMPONENTS_DIR, name, 'style', 'css.mjs')))
.map((name) => `element-plus/es/components/${name}/style/css`)
} catch {
return [] // 依赖尚未安装时降级为空(vite build 不依赖此项)
}
})()
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
// Element Plus 按需引入(替代 main.js 中的 app.use(ElementPlus) 全量注册)。
// 构建期静态扫描模板,只把「实际用到的组件」及其样式打进产物:
// <el-table> → element-plus/es/components/table + table/style/css
// v-loading → ElLoadingDirective(resolver 的 directives 默认开启)
// 注意两处边界(改前已核对):
// ① 运行时字符串解析(h('el-button') / resolveComponent('el-icon'))不归它管
// —— 已改为显式组件引用,见 views/Upload.vue;
// ② 函数式 API(ElMessage/ElMessageBox/...)不会被注入样式,
// —— 已在 main.js 里显式引入这四个样式。
// dirs 置空 = 不额外扫描 src/components 目录,保持原有显式 import 行为不变,
// 只让本插件负责 Element Plus 一个职责(最小改动)。
Components({
dts: 'src/components.d.ts',
dirs: [],
resolvers: [ElementPlusResolver()],
}),
// 修复 Vite 内部 viteTransformMiddleware 调用 decodeURI 时遇到格式错误的 URL
// 导致 "URI malformed" 错误的问题。在 Vite 中间件之前拦截并安全处理。
{
@@ -30,6 +71,18 @@ export default defineConfig({
},
],
// 依赖预构建:显式声明「按需引入」注入的 Element Plus 样式模块。
// 必要性:unplugin-vue-components 是在 transform 阶段「运行时注入」style import 的,
// 而 Vite 启动期的依赖扫描基于 index.html,看不到这些被注入的模块 ⇒ 运行中才发现
// ⇒ 触发「二次预构建」(日志里的 new dependencies optimized + reloading)。
// 后果不只是「访问新页面时整页重载」;在带 safe-delete 钩子的受限沙箱里,
// 二次预构建清理临时目录会被钩子拦下,导致 dev server 进程直接退出。
// 显式声明后首次预构建即完整,dev 启动一次成型。
// ⚠️ 本项只作用于 dev(vite build 不做依赖预构建),不影响任何构建产物。
optimizeDeps: {
include: ['element-plus/es', ...EP_STYLE_INCLUDES],
},
// 开发服务器配置
server: {
host: '0.0.0.0',