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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user