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 / 小组编号 / 下载本组压缩包 等新文案。
This commit is contained in:
2026-10-21 15:20:47 +08:00
parent ab1c67dbc5
commit 96d16129f7
3 changed files with 216 additions and 7 deletions

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

@@ -740,6 +740,24 @@
</div> </div>
<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" /> <el-table-column prop="name" label="选手姓名" width="120" />
<!-- R190:小组编号 / 作品目录。
展示值 = 后端落盘用的目录名(teamFolder),与「批量下载」压缩包内的子目录
以及「单组压缩包」文件名**完全同源**,管理人员据此对得上号。 -->
<el-table-column label="小组编号 / 作品目录" width="240">
<template #default="{ row }">
<div class="team-cell">
<span class="team-no" :title="row.teamFolder">{{ row.teamFolder }}</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="上传文件 / 评分" min-width="420"> <el-table-column label="上传文件 / 评分" min-width="420">
<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">
@@ -4193,6 +4211,11 @@ async function fetchSubmissions() {
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 })),
})) }))
} catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false } } catch (e) { console.warn('获取作品列表失败', e) } finally { submissionsLoading.value = false }
@@ -4257,6 +4280,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 {
@@ -5503,6 +5551,17 @@ onMounted(async () => {
.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;
}
/* ==================== 统计卡片 ==================== */ /* ==================== 统计卡片 ==================== */
.stats-row { margin-bottom: 20px; } .stats-row { margin-bottom: 20px; }

View File

@@ -27,6 +27,34 @@
<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 +62,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 +96,19 @@
</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="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。"
/>
<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">
@@ -396,6 +435,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
@@ -483,7 +547,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 +575,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 +596,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)
@@ -1078,7 +1173,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 +1579,59 @@ onUnmounted(() => {
justify-content: space-between; justify-content: space-between;
} }
/* ==================== 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;