Compare commits

..

13 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
10 changed files with 3883 additions and 482 deletions

1474
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,8 @@
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^6.0.10",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^5.0.0" "vite": "^8.3.4"
} }
} }

View File

@@ -19,6 +19,15 @@ export function getAccounts(params) {
return request.get('/admin/accounts', { 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 批量创建账号 * 导入 Excel 批量创建账号
* @param {FormData} formData - 包含 Excel 文件的 FormData * @param {FormData} formData - 包含 Excel 文件的 FormData
@@ -44,8 +53,8 @@ export function importExcel(formData) {
* @param {string} group - 组别标识 * @param {string} group - 组别标识
* @returns {Promise} 作品列表 * @returns {Promise} 作品列表
*/ */
export function getSubmissions(group) { export function getSubmissions(group, params) {
return request.get(`/admin/submissions/${group}`) return request.get(`/admin/submissions/${group}`, { params })
} }
/** /**
@@ -59,9 +68,11 @@ export function getTimes() {
/** /**
* 设置比赛时间 * 设置比赛时间
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... } * @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
* @returns {Promise} * @returns {Promise}
*/ */
export function setTimes(data) { export function setTimes(data, expected) {
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }] // 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库 // 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
const times = Object.entries(data).map(([group, item]) => ({ const times = Object.entries(data).map(([group, item]) => ({
@@ -72,7 +83,21 @@ export function setTimes(data) {
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50, choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20, 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 })
} }
/** /**
@@ -231,8 +256,8 @@ export function deleteMaterial(group, fileName) {
* @param {string} group - 组别标识 * @param {string} group - 组别标识
* @returns {Promise} 成绩列表 * @returns {Promise} 成绩列表
*/ */
export function getScores(group) { export function getScores(group, params) {
return request.get(`/admin/scores/${group}`) return request.get(`/admin/scores/${group}`, { params })
} }
/** /**
@@ -257,6 +282,91 @@ export function getPublishStatus(group) {
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined) 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,
})
}
// ==================== 新闻管理 ==================== // ==================== 新闻管理 ====================
/** /**
@@ -304,6 +414,28 @@ export function updateNewsOrder(orders) {
return request.put('/admin/news/order', { 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,
})
}
// ==================== 轮播图管理 ==================== // ==================== 轮播图管理 ====================
/** /**
@@ -468,6 +600,61 @@ export function setBigAnswerScores(group, uid, scores) {
return request.put(`/admin/big-answers/${group}/${uid}/scores`, 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,
})
}
// ==================== 成绩复核 ==================== // ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。 // 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
@@ -503,4 +690,81 @@ export function getContestantScore(group, contestantId) {
return request.get(`/admin/review/score/${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

@@ -12,10 +12,11 @@ import request from './request'
/** /**
* 获取上传状态 * 获取上传状态
* @param {string} 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) { export function getUploadStatus(group, config = {}) {
return request.get(`/upload/${group}/status`) return request.get(`/upload/${group}/status`, config)
} }
/** /**

1
src/components.d.ts vendored
View File

@@ -19,6 +19,7 @@ declare module 'vue' {
ElCarousel: typeof import('element-plus/es')['ElCarousel'] ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']

View File

@@ -5,7 +5,18 @@
=========================================================== */ =========================================================== */
/* ============ CSS 变量定义 ============ */ /* ============ 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; --primary-color: #1a3a5c;
/* 辅助色:金色,用于强调、高亮、获奖标识等 */ /* 辅助色:金色,用于强调、高亮、获奖标识等 */
@@ -371,7 +382,14 @@ input:-webkit-autofill {
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。 本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
新增页面自动继承此保障,无需逐页排查。 新增页面自动继承此保障,无需逐页排查。
=========================================================== */ =========================================================== */
.el-button.is-text:not(.is-disabled):hover, /* 🔴 选择器前缀 :root 是**必需**的(2026-10-21 R188 实测),不能省:
.el-button.is-text:not(.is-disabled):focus { 本规则与 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); background-color: rgba(120, 130, 145, 0.16);
} }

View File

@@ -95,6 +95,32 @@ function getHeader(headers, name) {
return '' 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' 会重复前缀。 // 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
function normalizeUrl(url) { function normalizeUrl(url) {
if (url && url.startsWith('/api')) return url.slice(4) if (url && url.startsWith('/api')) return url.slice(4)
@@ -155,8 +181,10 @@ function triggerDownload(blob, filename, originalUrl) {
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。 // 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误): // 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <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) { async function assertRealFile(res) {
const blob = res.data const blob = res.data
if (!blob) return '下载失败:响应内容为空' if (!blob) return '下载失败:响应内容为空'
@@ -176,7 +204,9 @@ async function assertRealFile(res) {
} }
// ② 大小哨兵:与服务端 stat 的真实字节数比对 // ② 大小哨兵:与服务端 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) { if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试` return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
} }

File diff suppressed because it is too large Load Diff

View File

@@ -21,12 +21,46 @@
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading"> <div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
<el-card class="upload-card" shadow="never"> <el-card class="upload-card" shadow="never">
<template #header> <template #header>
<span class="upload-title">答案文件上传({{ groupLabel }})</span> <div class="upload-header">
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
<!-- R192:页面级常驻出口。本页无全局导航(App.vue 只有 router-view),
组锁 / 倒计时归零 / 只是想放弃提交 的用户若无此按钮就只能在浏览器后退,
移动端甚至退不出去(体验反馈:成员进入后被锁在提交页)。 -->
<el-button text type="primary" :icon="Back" @click="leaveCompetition">返回工作台</el-button>
</div>
</template> </template>
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px"> <el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template> <template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。 支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
</el-alert> </el-alert>
<!-- R190:小组作品状态(组内互斥)—— 组内已有人上传后,其他成员不能再传。
文案直接取后端 teamLockMessage,⛔ 前端不另拼一套口径(避免与后端判定漂移)。 -->
<div
v-if="teamPanelVisible"
class="team-status-panel"
:class="{ 'is-locked': teamLocked }"
>
<div class="team-status-head">
<span class="team-status-title">
<template v-if="teamLocked">本组作品已由其他成员上传,您不能再上传</template>
<template v-else-if="uploadStatus.teamSubmitted">本组作品已由您上传,截止前可随时替换</template>
<template v-else>本组尚无人上传作品</template>
</span>
<el-tag v-if="uploadStatus.teamNumber" size="small" type="info">小组编号 {{ uploadStatus.teamNumber }}</el-tag>
<el-tag v-if="uploadStatus.memberCount > 1" size="small" type="info">组内 {{ uploadStatus.memberCount }} 人</el-tag>
</div>
<div v-if="uploadStatus.teamLockMessage" class="team-status-msg">{{ uploadStatus.teamLockMessage }}</div>
<div v-if="teamSharedFiles.length" class="team-status-files">
<div class="pending-list-title">组内已上传文件:</div>
<div v-for="(f, i) in teamSharedFiles" :key="i" class="team-status-file">
<span class="uploaded-name">{{ f.fileName }}</span>
<el-tag size="small" :type="categoryTagType(categoryOf(f.fileName))">{{ categoryLabel(categoryOf(f.fileName)) }}</el-tag>
<span class="team-status-uploader">上传者:{{ f.uploaderName || '组内成员' }}{{ f.isMine ? '(我)' : '' }}</span>
</div>
</div>
</div>
<el-upload <el-upload
ref="uploadRef" ref="uploadRef"
:auto-upload="false" :auto-upload="false"
@@ -34,6 +68,7 @@
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'" :accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
:file-list="uploadFileList" :file-list="uploadFileList"
:on-change="onUploadFileChange" :on-change="onUploadFileChange"
:disabled="teamLocked"
multiple multiple
drag drag
class="upload-drag" class="upload-drag"
@@ -67,9 +102,25 @@
</div> </div>
<div class="upload-actions"> <div class="upload-actions">
<el-button type="primary" :loading="uploading" :disabled="pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button> <el-button type="primary" :loading="uploading" :disabled="teamLocked || pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button> <el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
</div> </div>
<!-- 被组锁时,明确告知「为什么按钮点不动」,避免用户以为是页面故障 -->
<el-alert
v-if="teamLocked"
type="warning"
:closable="false"
show-icon
style="margin-top:12px"
title="组内作品唯一:本组已有成员上传,您无法再上传"
description="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。您本人无需提交,可直接返回工作台。"
/>
<!-- 组锁的出口按钮必须**紧贴提示**:视线焦点在这里,且此处的「返回」不弹
「丢失提交进度」确认(团队作品已由队友提交,本机没有可丢的内容)。 -->
<div v-if="teamLocked" class="team-locked-actions">
<el-button type="primary" @click="leaveCompetition">返回工作台</el-button>
<span class="team-locked-hint">本组作品已由队友提交,您无需任何操作。</span>
</div>
<el-divider v-if="uploadedWorks.length" /> <el-divider v-if="uploadedWorks.length" />
<div v-if="uploadedWorks.length" class="uploaded-list"> <div v-if="uploadedWorks.length" class="uploaded-list">
@@ -366,7 +417,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { Timer, UploadFilled } from '@element-plus/icons-vue' import { Timer, UploadFilled, Back } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useExamStore } from '@/stores/exam' import { useExamStore } from '@/stores/exam'
import * as examApi from '@/api/exam' import * as examApi from '@/api/exam'
@@ -396,6 +447,31 @@ const uploadFileList = ref([]) // el-upload 绑定列表
const uploadedWorks = ref([]) const uploadedWorks = ref([])
const uploadRef = ref(null) const uploadRef = ref(null)
// ---------- R190:小组作品状态(组内互斥)----------
// 后端 `GET /api/upload/:group/status` 是组锁判定的**唯一权威**,前端只负责展示:
// teamLocked / teamLockMessage / teamFiles 等一律原样取用,⛔ 不在前端重算一套规则
//(重算必然与后端漂移,且学生端与下载端的「谁是归属人」会打架)。
const uploadStatus = ref({
submitted: false,
canUpload: true,
deadlineValid: true,
teamNumber: null,
folderName: '',
memberCount: 1,
teamSubmitted: false,
teamOwnerName: null,
isTeamOwner: false,
teamLocked: false,
teamLockMessage: null,
teamFiles: [],
})
const teamLocked = computed(() => !!uploadStatus.value.teamLocked)
// 有小组编号 or 组内多于一人时展示小组状态面板;独立参赛者保持原界面不变
const teamPanelVisible = computed(() => !!uploadStatus.value.teamNumber || uploadStatus.value.memberCount > 1)
// 被锁时才展示「组内已上传文件」——归属人(本人)在下方「已上传文件」里已有可删除列表,
// 两处同列会重复且按钮语义不同,容易误删。
const teamSharedFiles = computed(() => (teamLocked.value ? (uploadStatus.value.teamFiles || []) : []))
const ZIP_MAX = 200 * 1024 * 1024 const ZIP_MAX = 200 * 1024 * 1024
const OTHER_MAX = 100 * 1024 * 1024 const OTHER_MAX = 100 * 1024 * 1024
@@ -452,6 +528,36 @@ function clearUploadFiles() {
uploadRef.value?.clearFiles?.() uploadRef.value?.clearFiles?.()
} }
/**
* 返回工作台(R192)—— 上传模式的统一出口。
*
* 🔴 必须先 resetExam() 再 push,理由有三条:
* ① 不清 `examStarted` ⇒ `onBeforeRouteLeave` 会再弹一次「离开页面将丢失当前提交进度」。
* 上传模式下用户常常只是放弃提交(甚至被组锁、本机根本没有可提交内容),
* 这句文案既失真又构成二次打扰;
* ② 不清 timer ⇒ store 是全局单例,回到工作台后倒计时仍在跑,下次进入会看到陈旧剩余时间;
* ③ 用 resetExam(而非只 stopExam)与 `onBeforeRouteLeave` 里确认离开后的清理**同一口径**,
* ⛔ 不留「按钮退出」与「路由守卫退出」两套清理逻辑(漏项会以「下次进入状态诡异」的形式出现)。
*
* 只有「待上传列表里有已选文件」才算真有内容会丢,此时才确认一次。
* 组锁用户的上传控件是 disabled 的,选不出文件 ⇒ 不弹确认,直接离开。
*/
async function leaveCompetition() {
if (!teamLocked.value && uploadFileList.value.length > 0) {
try {
await ElMessageBox.confirm(
'已选择的文件尚未上传,离开后需要重新选择。确定返回工作台吗?',
'提示',
{ confirmButtonText: '返回工作台', cancelButtonText: '继续上传', type: 'warning' }
)
} catch {
return
}
}
examStore.resetExam()
router.push('/dashboard')
}
async function submitUploadFiles() { async function submitUploadFiles() {
const files = pendingUploadFiles.value const files = pendingUploadFiles.value
if (!files.length) return if (!files.length) return
@@ -483,7 +589,8 @@ async function submitUploadFiles() {
ElMessage.success('上传成功,作品已提交') ElMessage.success('上传成功,作品已提交')
uploadFileList.value = [] uploadFileList.value = []
uploadRef.value?.clearFiles?.() uploadRef.value?.clearFiles?.()
await fetchUploadedWorks() // 上传成功后必须同步组锁状态:归属人变成本人、teamSubmitted 变 true(R190)
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
examStore.stopExam() examStore.stopExam()
} catch { } catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹) // 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
@@ -510,7 +617,12 @@ async function deleteUploadedWork(w, silent = false) {
try { try {
await uploadApi.deleteWork(authStore.userGroup, w.id) await uploadApi.deleteWork(authStore.userGroup, w.id)
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id) uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
if (!silent) ElMessage.success('已删除') // 🔴 只在「用户手动删除」时刷新组锁状态:silent=true 的批量删除是 submitUploadFiles
// 的中间步骤,此时并发刷新可能在上传完成后才返回旧快照,把面板刷回陈旧结论。
if (!silent) {
ElMessage.success('已删除')
fetchUploadStatus()
}
} catch { } catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹) // 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally { } finally {
@@ -526,6 +638,31 @@ async function fetchUploadedWorks() {
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
/**
* 拉取小组提交状态(R190)。
* 🔴 失败**不阻断**上传面板:状态查询只是辅助展示,真正的上传校验在后端(6006)。
* 用 silent 抑制拦截器提示,避免辅助查询失败时给考生弹一条看不懂的报错。
*/
async function fetchUploadStatus() {
try {
const d = (await uploadApi.getUploadStatus(authStore.userGroup, { silent: true })) || {}
uploadStatus.value = {
submitted: !!d.submitted,
canUpload: d.canUpload !== false,
deadlineValid: d.deadlineValid !== false,
teamNumber: d.teamNumber || null,
folderName: d.folderName || '',
memberCount: Number(d.memberCount) || 1,
teamSubmitted: !!d.teamSubmitted,
teamOwnerName: d.teamOwnerName || null,
isTeamOwner: !!d.isTeamOwner,
teamLocked: !!d.teamLocked,
teamLockMessage: d.teamLockMessage || null,
teamFiles: Array.isArray(d.teamFiles) ? d.teamFiles : [],
}
} catch (e) { /* 辅助状态查询失败保持默认(不锁),真正拦截在后端 */ }
}
// ==================== 状态 ==================== // ==================== 状态 ====================
const loading = ref(true) const loading = ref(true)
@@ -936,7 +1073,13 @@ function preventKeyboard(e) {
// 路由离开警告 // 路由离开警告
onBeforeRouteLeave((to, from, next) => { onBeforeRouteLeave((to, from, next) => {
if (examStore.examStarted && !examStore.isTimeUp) { // 🔴 组锁用户不弹:上传控件 disabled、无交卷动作 ⇒ 本机没有任何可丢的内容。
// 对这类用户弹「离开将丢失提交进度」是假警告(体验反馈:被锁成员被困在提交页)。
// 判定写成「上传模式 && teamLocked」而不是只看 teamLocked:后者当前虽等价
// (客观题模式不拉 uploadStatus),但语义上把考试组也纳入了豁免面,将来一旦
// 考试组也拉 status,就会静默取消「交卷前离开」的确认 —— 这是不可接受的放松。
const lockedNoContent = isUploadMode.value && teamLocked.value
if (examStore.examStarted && !examStore.isTimeUp && !lockedNoContent) {
const verb = isUploadMode.value ? '提交' : '答题' const verb = isUploadMode.value ? '提交' : '答题'
ElMessageBox.confirm( ElMessageBox.confirm(
`离开页面将丢失当前${verb}进度,确定要离开吗?`, `离开页面将丢失当前${verb}进度,确定要离开吗?`,
@@ -1078,7 +1221,8 @@ onMounted(async () => {
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时 // 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
uploadPanelLoading.value = true uploadPanelLoading.value = true
try { try {
await fetchUploadedWorks() // 两个查询互不依赖,并行发起:作品列表 + 组锁状态(R190)
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
} finally { } finally {
uploadPanelLoading.value = false uploadPanelLoading.value = false
} }
@@ -1483,6 +1627,81 @@ onUnmounted(() => {
justify-content: space-between; justify-content: space-between;
} }
/* ==================== R192:上传页出口(组锁 / 主动放弃提交) ==================== */
/* 本页无全局导航(App.vue 只有 router-view)⇒ 出口按钮必须常驻,否则被组锁的
成员只能靠浏览器后退离开(移动端/内嵌浏览器常常退不出去)。 */
.upload-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.team-locked-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.75rem;
}
.team-locked-hint {
font-size: 0.85rem;
color: #909399;
}
/* ==================== R190:小组作品状态面板 ==================== */
.team-status-panel {
border: 1px solid #c6e2ff;
background: #f0f7ff;
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.team-status-panel.is-locked {
border-color: #f5dab1;
background: #fdf6ec;
}
.team-status-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.team-status-title {
font-size: 0.9rem;
font-weight: 600;
color: #303133;
}
.team-status-panel.is-locked .team-status-title {
color: #b88230;
}
.team-status-msg {
margin-top: 0.5rem;
font-size: 0.85rem;
line-height: 1.6;
color: #606266;
}
.team-status-files {
margin-top: 0.75rem;
}
.team-status-file {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: 4px;
background: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
}
.team-status-file + .team-status-file {
margin-top: 0.4rem;
}
.team-status-uploader {
color: #909399;
font-size: 0.8rem;
}
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */ /* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
.file-over-limit { .file-over-limit {
background: #fef0f0; background: #fef0f0;

View File

@@ -49,6 +49,22 @@
<el-divider /> <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"> <div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
<el-alert type="info" :closable="false" show-icon> <el-alert type="info" :closable="false" show-icon>
@@ -251,6 +267,36 @@ onMounted(() => {
padding: 20px 0; 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 { .wx-link-btn {
text-decoration: none; text-decoration: none;
} }