Compare commits

...

4 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
4 changed files with 534 additions and 36 deletions

View File

@@ -53,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 })
} }
/** /**
@@ -68,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]) => ({
@@ -81,7 +83,10 @@ 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)
} }
/** /**
@@ -251,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 })
} }
/** /**

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)
} }
/** /**

View File

@@ -716,7 +716,9 @@
<el-option label="本科组" value="benke" /> <el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" /> <el-option label="教师组" value="jiaoshi" />
</el-select> </el-select>
<el-input v-model="submissionSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onSubmissionFilterChange" /> <!-- R194:列表已按小组聚合(一行 = 一个小组),搜索口径同步改为小组编号/目录名,
⛔ 不再按选手姓名过滤——姓名已不在列表展示,命中也无法解释。 -->
<el-input v-model="submissionSearch" placeholder="搜索小组编号" clearable size="small" class="search-input" @input="onSubmissionFilterChange" />
<!-- R87:提交状态筛选。判据与表格内「未提交」文案同源(row.files.length === 0); <!-- R87:提交状态筛选。判据与表格内「未提交」文案同源(row.files.length === 0);
选项文案与「成绩管理 · 成绩总览」的提交状态下拉逐字对齐。 --> 选项文案与「成绩管理 · 成绩总览」的提交状态下拉逐字对齐。 -->
<!-- R178:value-on-clear="" 让「清除」回到与 ref 初值一致的空字符串 <!-- R178:value-on-clear="" 让「清除」回到与 ref 初值一致的空字符串
@@ -734,13 +736,43 @@
title="下载该组全部作品(不受上方筛选影响)" title="下载该组全部作品(不受上方筛选影响)"
>批量下载作品</el-button> >批量下载作品</el-button>
<span class="work-summary" v-if="submissions.length"> <span class="work-summary" v-if="submissions.length">
共 {{ submissionStatusCount.total }} 人 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }} 共 {{ submissionStatusCount.total }} 组 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }}
<template v-if="submissionStatusFilter === 'true' || submissionStatusFilter === 'false'">|当前筛选出 {{ filteredSubmissions.length }} 人</template> <template v-if="submissionStatusFilter === 'true' || submissionStatusFilter === 'false'">|当前筛选出 {{ filteredSubmissions.length }} 组</template>
</span> </span>
</div> </div>
<!-- 🔴 R194:作品管理改「小组维度」(用户实测:比赛以小组为准,原表格按人平铺,
同组 6 人 6 行、5 行是「未提交」,管理人员看不出小组整体是否已交)。
一行 = 一个小组:小组编号/作品目录 + 提交状态 + 该组文件与评分。 -->
<el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table"> <el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table">
<el-table-column prop="name" label="选手姓名" width="120" /> <!-- R190/R194:小组编号 / 作品目录。
<el-table-column label="上传文件 / 评分" min-width="420"> 展示值 = 后端落盘用的目录名(teamFolder),与「批量下载」压缩包内的子目录
以及「单组压缩包」文件名**完全同源**,管理人员据此对得上号。 -->
<el-table-column label="小组编号 / 作品目录" width="260">
<template #default="{ row }">
<div class="team-cell">
<span class="team-no" :title="row.teamFolder">{{ row.teamFolder }}</span>
<span v-if="row.memberCount > 1" class="team-meta">{{ row.memberCount }} 人参赛</span>
<!-- R194:提交人(= 组锁口径下的归属人,最早提交者);未提交则不显示 -->
<span v-if="row.submitterName" class="team-meta">提交人:{{ row.submitterName }}</span>
<el-button
text
type="primary"
size="small"
:loading="row._zipLoading"
:disabled="!row.files || row.files.length === 0"
v-guard="() => downloadTeamZip(row)"
>下载本组压缩包</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="提交状态" width="110" align="center">
<template #default="{ row }">
<el-tag size="small" :type="isWorkSubmitted(row) ? 'success' : 'info'">
{{ isWorkSubmitted(row) ? '已提交' : '未提交' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上传文件 / 评分" min-width="460">
<template #default="{ row }"> <template #default="{ row }">
<div v-for="f in row.files" :key="f.id" class="work-file-row"> <div v-for="f in row.files" :key="f.id" class="work-file-row">
<span class="work-file-name" :title="f.fileName">{{ f.fileName }}</span> <span class="work-file-name" :title="f.fileName">{{ f.fileName }}</span>
@@ -776,6 +808,13 @@
<el-dialog v-model="previewVisible" title="文件预览" width="80%" top="5vh" @closed="closePreview"> <el-dialog v-model="previewVisible" title="文件预览" width="80%" top="5vh" @closed="closePreview">
<div v-if="previewFile"> <div v-if="previewFile">
<p class="preview-file-name">{{ previewFile.fileName }}({{ previewFile.kind === 'ppt' ? 'PPT' : previewFile.kind === 'answer' ? 'Word答案' : '文档' }})</p> <p class="preview-file-name">{{ previewFile.fileName }}({{ previewFile.kind === 'ppt' ? 'PPT' : previewFile.kind === 'answer' ? 'Word答案' : '文档' }})</p>
<!-- R194:空白兜底。iframe 渲染 PDF 依赖浏览器内置阅读器(Chrome/Edge 内置,
被策略禁用/精简版系统移除时 iframe 会显示纯白且**不报错**),故常驻一条说明 +
「在新标签打开」出口,避免管理人员误判「系统没加载」。 -->
<div v-if="previewIsPdf && previewBlobUrl" class="preview-bar">
<el-button text type="primary" size="small" v-guard="() => openPreviewInNewTab()">在新标签打开</el-button>
<span class="preview-hint">若下方空白,通常是浏览器未启用内置 PDF 阅读器,可点左侧按钮或下载后查看</span>
</div>
<iframe v-if="previewIsPdf && previewBlobUrl" :src="previewBlobUrl" class="preview-frame"></iframe> <iframe v-if="previewIsPdf && previewBlobUrl" :src="previewBlobUrl" class="preview-frame"></iframe>
<div v-else> <div v-else>
<el-alert type="info" :closable="false" show-icon style="margin-bottom:12px"> <el-alert type="info" :closable="false" show-icon style="margin-bottom:12px">
@@ -1063,6 +1102,17 @@
class="date-range" class="date-range"
@change="onScoreFilterChange" @change="onScoreFilterChange"
/> />
<!-- R195:默认只显示参赛过的人(有作答/有大题文件/已交卷)。中职/高职按小组参赛,
一组报名多人只一人上场,其余组员全 0 分占行 ⇒ 勾选后才列出全部报名者。 -->
<el-checkbox
v-if="isExamScoreGroup"
v-model="scoreIncludeIdle"
size="small"
class="idle-toggle"
@change="onScoreIncludeIdleChange"
>
显示未参赛
</el-checkbox>
</div> </div>
<!-- 成绩明细表 --> <!-- 成绩明细表 -->
@@ -1186,6 +1236,15 @@
class="date-range" class="date-range"
@change="onExamSubsFilterChange" @change="onExamSubsFilterChange"
/> />
<!-- R195:默认只列出参赛过的人;勾选后显示全部报名者(含从未进考场的组员)。 -->
<el-checkbox
v-model="examSubsIncludeIdle"
size="small"
class="idle-toggle"
@change="onExamSubsIncludeIdleChange"
>
显示未参赛
</el-checkbox>
<el-button text type="primary" v-guard="() => fetchExamSubmissions()" :loading="examSubmissionsLoading">刷新</el-button> <el-button text type="primary" v-guard="() => fetchExamSubmissions()" :loading="examSubmissionsLoading">刷新</el-button>
</div> </div>
<el-table :data="pagedExamSubmissions" stripe v-loading="examSubmissionsLoading" class="admin-table"> <el-table :data="pagedExamSubmissions" stripe v-loading="examSubmissionsLoading" class="admin-table">
@@ -1950,9 +2009,15 @@
<h2 class="section-heading">轮播管理</h2> <h2 class="section-heading">轮播管理</h2>
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button> <el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
<el-button type="primary" plain v-guard="() => saveCarouselOrder()" :disabled="adminCarousels.length < 2">保存排序</el-button> <el-button
:type="carouselOrderDirty ? 'warning' : 'primary'"
plain
v-guard="() => saveCarouselOrder()"
:disabled="adminCarousels.length < 2"
>{{ carouselOrderDirty ? '保存排序(有未保存改动)' : '保存排序' }}</el-button>
<el-tag v-if="carouselOrderDirty" type="warning" effect="light" size="small">排序未保存</el-tag>
<span class="drag-hint" v-if="adminCarousels.length > 1"> <span class="drag-hint" v-if="adminCarousels.length > 1">
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序 <el-icon><Rank /></el-icon> 拖拽卡片可调整排序;拖完须点「保存排序」才写入服务器
</span> </span>
</div> </div>
<div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef"> <div v-loading="carouselLoading" class="carousel-card-list" ref="carouselCardListRef">
@@ -2434,7 +2499,31 @@ function firstAllowedMenu() {
return MENU_ORDER.find(m => can(MENU_PERM[m])) || null return MENU_ORDER.find(m => can(MENU_PERM[m])) || null
} }
function onMenuSelect(index) { // ---- 「改权限需刷新」的收敛点(R189)----
// 原状:权限只在 onMounted 拉一次。最高管理员改了你的权限后,你必须**手动刷新页面**;
// 若停在旧页面上,菜单还是旧的,每个请求都撞 403 且没有任何提示。
// 现法:切菜单时静默重拉 /api/admin/me(8s 节流,避免连点菜单刷请求)。
// ⛔ 不用 location.reload() —— 那会丢掉未提交的表单输入。
let lastPermCalibAt = 0
const PERM_CALIB_MIN_INTERVAL = 8000
async function calibratePermissions() {
const now = Date.now()
if (now - lastPermCalibAt < PERM_CALIB_MIN_INTERVAL) return
lastPermCalibAt = now
const prev = authStore.currentUser || {}
const prevKey = `${prev.isSuper === true}|${[...(prev.permissions || [])].sort().join(',')}`
await loadPermissionCatalog()
const cur = authStore.currentUser || {}
const curKey = `${cur.isSuper === true}|${[...(cur.permissions || [])].sort().join(',')}`
if (prevKey === curKey) return
ElMessage.warning('你的权限已被更新,菜单与可用操作已同步')
}
async function onMenuSelect(index) {
// R189:先静默校准权限(节流 8s)。否则这里拦的仍是登录时的旧快照 ——
// 「改权限后需刷新」的根因就在这一步缺失,而不是拦少了。
await calibratePermissions()
// R178:菜单本身已按权限隐藏,这里再拦一层 —— // R178:菜单本身已按权限隐藏,这里再拦一层 ——
// 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。 // 权限被最高管理员收回后页面不会自动刷新,若仍停在旧菜单上,每个请求都会撞 403。
const need = MENU_PERM[index] const need = MENU_PERM[index]
@@ -3963,6 +4052,8 @@ async function downloadBigQuestionTpl() {
// 考生答题记录 // 考生答题记录
const examSubmissions = ref([]) const examSubmissions = ref([])
// R195:同「成绩总览」——默认隐藏从未参赛的报名者(includeIdle=1 才看全量)
const examSubsIncludeIdle = ref(false)
const examSubmissionsLoading = ref(false) const examSubmissionsLoading = ref(false)
const examSubmissionsPage = ref(1) const examSubmissionsPage = ref(1)
const examSubmissionsPageSize = ref(10) const examSubmissionsPageSize = ref(10)
@@ -4010,7 +4101,10 @@ async function fetchExamSubmissions() {
// R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页 // R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页
examSubmissionsLoading.value = true examSubmissionsLoading.value = true
try { try {
const data = await adminApi.getSubmissions(scoreExportGroup.value) const data = await adminApi.getSubmissions(
scoreExportGroup.value,
examSubsIncludeIdle.value ? { includeIdle: 1 } : undefined
)
examSubmissions.value = Array.isArray(data) ? data : (data?.list || []) examSubmissions.value = Array.isArray(data) ? data : (data?.list || [])
} catch (e) { } catch (e) {
console.warn('获取答题记录失败', e) console.warn('获取答题记录失败', e)
@@ -4022,6 +4116,12 @@ function onExamSubsFilterChange() {
examSubmissionsPage.value = 1 examSubmissionsPage.value = 1
} }
// R195:服务端过滤(includeIdle),切换需重新请求
async function onExamSubsIncludeIdleChange() {
examSubmissionsPage.value = 1
await fetchExamSubmissions()
}
// R30:原「考生答题评分弹窗」的 state 与 openExamScoreDialog / saveExamScore 已整体移除。 // R30:原「考生答题评分弹窗」的 state 与 openExamScoreDialog / saveExamScore 已整体移除。
// 大题评分改由「考生大题作答查证」按小问给分(adminApi.setBigAnswerScores // 大题评分改由「考生大题作答查证」按小问给分(adminApi.setBigAnswerScores
// → service.bigQuestion.setSubScores → service.admin.updateScores), // → service.bigQuestion.setSubScores → service.admin.updateScores),
@@ -4134,8 +4234,10 @@ const submissionStatusCount = computed(() => {
const filteredSubmissions = computed(() => { const filteredSubmissions = computed(() => {
let list = submissions.value let list = submissions.value
if (submissionSearch.value) { if (submissionSearch.value) {
// R194:行已按小组聚合 ⇒ 搜索口径改为「小组编号 / 作品目录」(姓名不再展示)
const s = submissionSearch.value.trim().toLowerCase() const s = submissionSearch.value.trim().toLowerCase()
list = list.filter(item => (item.name || '').toLowerCase().includes(s)) list = list.filter(item => String(item.teamFolder || '').toLowerCase().includes(s)
|| String(item.teamNumber || '').toLowerCase().includes(s))
} }
// 🔴 R178 同「成绩总览」:⛔ 不用 `!== ''` 判断是否启用筛选(清空值可能是 undefined), // 🔴 R178 同「成绩总览」:⛔ 不用 `!== ''` 判断是否启用筛选(清空值可能是 undefined),
// 只用白名单;否则点「清除」后会静默变成「只看未提交」。 // 只用白名单;否则点「清除」后会静默变成「只看未提交」。
@@ -4151,20 +4253,90 @@ const pagedSubmissions = computed(() => {
return filteredSubmissions.value.slice(start, start + submissionsPageSize.value) return filteredSubmissions.value.slice(start, start + submissionsPageSize.value)
}) })
/**
* 把后端 getWorks()「按人返回」的结果折叠成「按小组一行」(R194)。
*
* 【业务来源】用户实测:比赛以小组为准,但管理端把同组 6 个人平铺成 6 行,
* 其中 5 行是「未提交」,管理人员无法一眼判断「这个小组到底交没交」。
* 管理侧只关心:小组编号 → 是否已提交 → 文件与评分。
*
* 【分组键】与 R190 组锁 / 落盘口径**保持一致**,不另立一套:
* teamUid(有队伍号)→ aggregationUid(无队伍号的单人)→ contestantId(兜底,
* 保证任何一行都不会被吞掉——漏行比多行更难被发现)。
* ⚠️ teamUid 仅**组内**唯一且可重复,但本函数只在单一 group 的列表内聚合
* (submissionGroup 单选),故不存在跨组串号。
*
* @param {Array<Object>} people - getWorks 的选手级数据
* @returns {Array<Object>} 小组级行
*/
function groupSubmissionsByTeam(people) {
const buckets = new Map()
people.forEach(p => {
const key = (p.teamUid !== null && p.teamUid !== undefined)
? `t:${p.teamUid}`
: (p.aggregationUid !== null && p.aggregationUid !== undefined)
? `a:${p.aggregationUid}`
: `c:${p.contestantId}`
let g = buckets.get(key)
if (!g) {
g = {
teamKey: key,
teamUid: p.teamUid,
teamNumber: p.teamNumber,
teamFolder: p.teamFolder,
aggregationUid: p.aggregationUid,
memberCount: 0,
files: [],
_zipLoading: false,
_order: p.contestantId,
}
buckets.set(key, g)
}
g.memberCount += 1
// contestantId 必须跟着文件走:批量保存评分要按「人」回写 wordScore / pptScore
// _uploaderName 同理:聚合后要靠它回答「这组是谁提交的」
;(p.files || []).forEach(f => g.files.push({ ...f, contestantId: p.contestantId, _uploaderName: p.name }))
})
const list = Array.from(buckets.values())
list.forEach(g => {
// 跨成员合并后重排一次:后端按 upload_time DESC,合并会打乱顺序
g.files.sort((a, b) => String(b.uploadedAt || '').localeCompare(String(a.uploadedAt || '')))
// 🔴 提交人 = 归属人,口径与 R190 组锁一致(**最早提交者**)。
// 组锁下正常只有一人能提交,这里取最早者是为了兼容历史越权上传留下的多份文件。
const owners = g.files.filter(f => f._uploaderName)
if (owners.length) {
const ts = f => (f.uploadedAt ? String(f.uploadedAt) : '9999-12-31 23:59:59')
owners.sort((a, b) => ts(a).localeCompare(ts(b)))
g.submitterName = owners[0]._uploaderName
} else {
g.submitterName = ''
}
})
list.sort((a, b) => a._order - b._order)
return list
}
async function fetchSubmissions() { async function fetchSubmissions() {
if (!submissionGroup.value) return if (!submissionGroup.value) return
submissionsLoading.value = true submissionsLoading.value = true
try { try {
const data = await adminApi.getWorks(submissionGroup.value) const data = await adminApi.getWorks(submissionGroup.value)
const raw = Array.isArray(data) ? data : (data?.list || []) const raw = Array.isArray(data) ? data : (data?.list || [])
submissions.value = raw.map(s => ({ const people = raw.map(s => ({
contestantId: s.contestantId, contestantId: s.contestantId,
name: s.name, name: s.name,
idNumber: s.idNumber, idNumber: s.idNumber,
phone: s.phone, phone: s.phone,
group: s.group, group: s.group,
// R190:小组维度字段(后端 getWorks 已按 (group, teamUid) 解析,前端只展示)
teamUid: s.teamUid === undefined ? null : s.teamUid,
teamNumber: s.teamNumber || null,
teamFolder: s.teamFolder || '未编组',
aggregationUid: s.aggregationUid === undefined ? null : s.aggregationUid,
files: (s.files || []).map(f => ({ ...f, _score: f.score != null ? Number(f.score) : null })), files: (s.files || []).map(f => ({ ...f, _score: f.score != null ? Number(f.score) : null })),
})) }))
submissions.value = groupSubmissionsByTeam(people)
} catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false } } catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false }
} }
@@ -4203,7 +4375,14 @@ async function previewWork(row, f) {
if (previewIsPdf.value) { if (previewIsPdf.value) {
const r = await fetchBlobGuarded(fileDownloadUrl(submissionGroup.value, f)) const r = await fetchBlobGuarded(fileDownloadUrl(submissionGroup.value, f))
if (r.ok) { if (r.ok) {
previewBlobUrl.value = URL.createObjectURL(r.blob) // 🔴 R194(用户实测:预览 PDF 时浏览器反复下载「无扩展名文件」):
// 下载端点为了让「点下载」必然落盘,统一声明 Content-Type: application/octet-stream
// (见后端 admin.js#downloadSubmissionFile)。axios 的 blob 会**继承该响应头**,
// 于是 blob: URL 的 MIME 也是 octet-stream ⇒ iframe 无法渲染、Chrome 直接把它当
// 附件下载,弹窗内就是一片空白。
// 修法:预览通道把同一份字节重新包成 application/pdf(只改本地 Blob 的 type,
// 不碰后端契约,下载按钮行为不变)。字节完整性已由 fetchBlobGuarded 校验过。
previewBlobUrl.value = URL.createObjectURL(new Blob([r.blob], { type: 'application/pdf' }))
} else if (r.ignored) { } else if (r.ignored) {
// 同一文件正在预览加载,被去重忽略——静默跳过,不报错 // 同一文件正在预览加载,被去重忽略——静默跳过,不报错
} else { } else {
@@ -4218,6 +4397,16 @@ function closePreview() {
previewRow.value = null previewRow.value = null
} }
/**
* R194:把当前预览的 blob 交给新标签页打开(iframe 空白时的确定出口)。
* ⚠️ 不要在这里 revoke:新标签可能仍在加载,revoke 会让它拿到空文档。
*/
function openPreviewInNewTab() {
if (!previewBlobUrl.value) return
const w = window.open(previewBlobUrl.value, '_blank')
if (!w) ElMessage.warning('浏览器拦截了新标签,请允许本站弹窗后重试')
}
// 批量下载作品(ZIP) // 批量下载作品(ZIP)
async function batchDownloadWorks() { async function batchDownloadWorks() {
if (!submissionGroup.value) return if (!submissionGroup.value) return
@@ -4227,6 +4416,31 @@ async function batchDownloadWorks() {
}) })
} }
/**
* 下载「单个小组」作品压缩包(R190)
*
* 【业务来源】评分时两个文件分开下载容易混乱 ⇒ 一个小组一个压缩包,包内平铺该组文件。
* 定位键优先级:teamUid(组内全部成员)→ aggregationUid(无队伍号时退化为单人)。
* 🔴 与后端 `teamZip()` 的定位口径保持一致,⛔ 不在此处按姓名/编号自行匹配。
*/
async function downloadTeamZip(row) {
const group = submissionGroup.value
if (!group || row._zipLoading) return
const q = new URLSearchParams()
if (row.teamUid !== null && row.teamUid !== undefined) q.set('teamUid', row.teamUid)
else if (row.aggregationUid !== null && row.aggregationUid !== undefined) q.set('aggregationUid', row.aggregationUid)
const suffix = q.toString() ? `?${q.toString()}` : ''
row._zipLoading = true
try {
await safeDownload(`/api/admin/team-zip/${group}${suffix}`, {
fallbackName: `${row.teamFolder || '作品'}_作品.zip`,
timeout: 300000,
})
} finally {
row._zipLoading = false
}
}
// 批量保存评分(按文件聚合到选手的 wordScore / pptScore) // 批量保存评分(按文件聚合到选手的 wordScore / pptScore)
async function batchSaveWorkScores() { async function batchSaveWorkScores() {
try { try {
@@ -4234,7 +4448,10 @@ async function batchSaveWorkScores() {
submissions.value.forEach(s => { submissions.value.forEach(s => {
;(s.files || []).forEach(f => { ;(s.files || []).forEach(f => {
if (f._score == null || f._score === '') return if (f._score == null || f._score === '') return
const c = (perContestant[s.contestantId] = perContestant[s.contestantId] || {}) // R194:行已按小组聚合 ⇒ 回写主体不是「行」而是「文件所属选手」,
// contestantId 由 groupSubmissionsByTeam 逐文件注入(⛔ 不要读 s.contestantId)
if (f.contestantId == null) return
const c = (perContestant[f.contestantId] = perContestant[f.contestantId] || {})
if (f.scoreField === 'ppt') c.pptScore = Number(f._score) if (f.scoreField === 'ppt') c.pptScore = Number(f._score)
else c.wordScore = Number(f._score) else c.wordScore = Number(f._score)
}) })
@@ -4259,6 +4476,10 @@ const timesForm = reactive({
}) })
const timesSaving = ref(false) const timesSaving = ref(false)
const timesLoading = ref(false) const timesLoading = ref(false)
// B② 乐观锁基线:`fetchTimes` 载入时的「服务端旧值」。
// 保存时原样回传,后端比对不一致即判并发冲突 ⇒ 后保存者不会静默覆盖先保存者。
// ⛔ 不要在这里放表单当前值:那样永远等于提交值,校验恒真(假保护)。
const timesBaseline = ref({})
const timeGroups = [ const timeGroups = [
{ key: 'zhongzhi', name: '中职组', isExam: true }, { key: 'zhongzhi', name: '中职组', isExam: true },
@@ -4275,6 +4496,8 @@ async function fetchTimes() {
const list = (res && res.times) || [] const list = (res && res.times) || []
const map = {} const map = {}
for (const t of list) map[t.group] = t for (const t of list) map[t.group] = t
// 重新载入即视为「新的服务端基线」:冲突后刷新表单走的就是这里
const baseline = {}
for (const g of timeGroups) { for (const g of timeGroups) {
const t = map[g.key] const t = map[g.key]
if (!t) continue if (!t) continue
@@ -4285,7 +4508,14 @@ async function fetchTimes() {
timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20 timesForm[g.key + 'JudgeCount'] = t.judgeCount != null ? t.judgeCount : 20
timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0 timesForm[g.key + 'AvailChoice'] = t.availableChoice || 0
timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0 timesForm[g.key + 'AvailJudge'] = t.availableJudge || 0
// 只记「会被乐观锁比对」的三项时间字段(choiceCount/judgeCount 不参与)
baseline[g.key] = {
start: t.startTime ? t.startTime : '',
end: t.endTime ? t.endTime : '',
deadline: t.deadline ? t.deadline : '',
}
} }
timesBaseline.value = baseline
} catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false } } catch (e) { console.warn('获取时间配置失败', e) } finally { timesLoading.value = false }
} }
@@ -4297,11 +4527,18 @@ async function saveTimes() {
gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount }, gaozhi: { start: timesForm.gaozhiStart, end: timesForm.gaozhiEnd, deadline: timesForm.gaozhiDeadline, choiceCount: timesForm.gaozhiChoiceCount, judgeCount: timesForm.gaozhiJudgeCount },
benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount }, benke: { start: timesForm.benkeStart, end: timesForm.benkeEnd, deadline: timesForm.benkeDeadline, choiceCount: timesForm.benkeChoiceCount, judgeCount: timesForm.benkeJudgeCount },
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount }, jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
}) }, timesBaseline.value)
ElMessage.success('时间设置保存成功') ElMessage.success('时间设置保存成功')
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见 // 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
fetchNodeBackupStatus() fetchNodeBackupStatus()
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false } } catch (e) {
// 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹)。
// 🔴 仅「并发冲突」时自动回读:此刻表单里的值已过期,拿它重试只会再冲突;
// 其它失败(网络 / 参数校验)保留用户输入,不擅自清空。
if (e && typeof e.message === 'string' && e.message.includes('已被他人修改')) {
await fetchTimes()
}
} finally { timesSaving.value = false }
} }
// ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)---- // ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)----
@@ -4438,6 +4675,9 @@ watch(scoreExportGroup, () => {
}) })
const scoreList = ref([]) const scoreList = ref([])
// R195:默认只看「参赛过的人」。中职/高职按小组参赛,一组报名多人只一人上场,
// 未上场组员在成绩表里各占一行 0 分 ⇒ 默认隐藏,需核对全量时打开本开关。
const scoreIncludeIdle = ref(false)
const scoreListLoading = ref(false) const scoreListLoading = ref(false)
const scoreListPage = ref(1) const scoreListPage = ref(1)
const scoreListPageSize = ref(10) const scoreListPageSize = ref(10)
@@ -4492,6 +4732,12 @@ function onScoreFilterChange() {
scoreListPage.value = 1 scoreListPage.value = 1
} }
// R195:「显示未参赛」是**服务端过滤**(includeIdle 参数),切换必须重新拉数据,⛔ 不能只在本地过滤。
async function onScoreIncludeIdleChange() {
scoreListPage.value = 1
await fetchScoreList()
}
async function fetchPublishStatus() { async function fetchPublishStatus() {
if (!scoreExportGroup.value) return if (!scoreExportGroup.value) return
try { try {
@@ -4506,7 +4752,10 @@ async function fetchScoreList() {
if (!scoreExportGroup.value) return if (!scoreExportGroup.value) return
scoreListLoading.value = true scoreListLoading.value = true
try { try {
const data = await adminApi.getScores(scoreExportGroup.value) const data = await adminApi.getScores(
scoreExportGroup.value,
scoreIncludeIdle.value ? { includeIdle: 1 } : undefined
)
scoreList.value = (Array.isArray(data) ? data : (data?.list || [])).map(item => ({ scoreList.value = (Array.isArray(data) ? data : (data?.list || [])).map(item => ({
...item, ...item,
idNumber: item.idNumber ?? item.id_number, idNumber: item.idNumber ?? item.id_number,
@@ -4955,6 +5204,10 @@ const carouselForm = reactive({
}) })
const carouselCardListRef = ref(null) const carouselCardListRef = ref(null)
let carouselSortable = null let carouselSortable = null
// ㊳ 排序脏标记:拖拽只改前端顺序、**不落库**(需点「保存排序」)。
// 此前卡片上「排序: N」用的是渲染下标 idx+1 ⇒ 拖完立刻变成新序号,看起来已生效,
// 刷新却回退到旧顺序。加脏标记后,未保存状态在界面上是可见的。
const carouselOrderDirty = ref(false)
const uploadUrl = '/api/admin/upload/image' const uploadUrl = '/api/admin/upload/image'
const uploadHeaders = computed(() => { const uploadHeaders = computed(() => {
@@ -5005,6 +5258,8 @@ async function fetchCarousels() {
sort_order: item.sort_order ?? 0, sort_order: item.sort_order ?? 0,
enabled: item.enabled !== false, enabled: item.enabled !== false,
})) }))
// 重新载入 = 服务端顺序,脏状态归零
carouselOrderDirty.value = false
initCarouselSortable() initCarouselSortable()
} catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false } } catch (e) { console.warn('获取轮播失败', e) } finally { carouselLoading.value = false }
} }
@@ -5084,6 +5339,7 @@ async function saveCarouselOrder() {
})) }))
try { try {
await adminApi.updateCarouselOrder(orders) await adminApi.updateCarouselOrder(orders)
carouselOrderDirty.value = false
ElMessage.success('排序保存成功') ElMessage.success('排序保存成功')
} catch (e) { } catch (e) {
console.error('轮播排序保存失败:', e) console.error('轮播排序保存失败:', e)
@@ -5121,6 +5377,8 @@ function initCarouselSortable() {
}) })
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
adminCarousels.value = reordered adminCarousels.value = reordered
// 拖拽只改前端顺序,**未落库** ⇒ 必须点亮脏标记,否则「排序: N」看着已生效
carouselOrderDirty.value = true
}, },
}) })
}) })
@@ -5274,11 +5532,10 @@ async function downloadReviewWork(row) {
// ==================== 初始化 ==================== // ==================== 初始化 ====================
onMounted(async () => { onMounted(async () => {
// R178:先向服务端校准权限(多管理员下,权限可能在登录后被改动)。 // R178/R189:权限校准统一由 onMenuSelect → calibratePermissions 负责(带节流),
// 此处不再单独请求 /api/admin/me,避免首屏连发两次。
// 失败不阻断 —— can() 会退回登录时落盘的那份快照。 // 失败不阻断 —— can() 会退回登录时落盘的那份快照。
await loadPermissionCatalog() await onMenuSelect(activeMenu.value)
// 首次加载时根据当前活动菜单获取对应数据
onMenuSelect(activeMenu.value)
}) })
</script> </script>
@@ -5442,9 +5699,25 @@ onMounted(async () => {
.score-inline { display: inline-flex; align-items: center; gap: 4px; color: #606266; font-size: 13px; } .score-inline { display: inline-flex; align-items: center; gap: 4px; color: #606266; font-size: 13px; }
.score-input { width: 110px; } .score-input { width: 110px; }
.preview-frame { width: 100%; height: 70vh; border: 0; background: #fff; } .preview-frame { width: 100%; height: 70vh; border: 0; background: #fff; }
/* R194:预览操作条(新标签打开 + 空白兜底说明) */
.preview-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.preview-hint { color: #909399; font-size: 12px; line-height: 1.5; }
.preview-file-name { color: #303133; font-weight: 600; margin-bottom: 8px; } .preview-file-name { color: #303133; font-weight: 600; margin-bottom: 8px; }
.work-summary { color: #606266; font-size: 13px; margin-left: 8px; } .work-summary { color: #606266; font-size: 13px; margin-left: 8px; }
/* R190:小组编号 / 作品目录列 */
.team-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.team-no {
max-width: 210px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #303133;
font-weight: 500;
}
/* R194:小组副信息(参赛人数),只报数量、不列成员姓名 */
.team-meta { color: #909399; font-size: 12px; }
/* ==================== 统计卡片 ==================== */ /* ==================== 统计卡片 ==================== */
.stats-row { margin-bottom: 20px; } .stats-row { margin-bottom: 20px; }

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;