R194: 作品管理改「小组维度」列表 + 成绩/提交列表 includeIdle 透传

- src/views/Admin.vue
  · 作品管理一行 = 一个小组(小组编号/作品目录 + 提交状态 + 该组文件与评分 + 人数 + 提交人),
    替代原「按人平铺」(同组 6 人 6 行、5 行未提交)
  · 搜索口径改「小组编号」(姓名已不在列表展示);计数单位 人→组
  · PDF 预览常驻说明 + 「在新标签打开」出口(iframe 白屏时不误导「系统没加载」)
- src/api/admin.js:getSubmissions/getScores 增加 params,供 includeIdle 口径透传
This commit is contained in:
2026-10-22 09:41:57 +08:00
parent 4fd04f106a
commit 1f4cfc280e
2 changed files with 173 additions and 17 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 })
} }
/** /**
@@ -256,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

@@ -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,19 +736,24 @@
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:小组编号 / 作品目录。
<!-- R190:小组编号 / 作品目录。
展示值 = 后端落盘用的目录名(teamFolder),与「批量下载」压缩包内的子目录 展示值 = 后端落盘用的目录名(teamFolder),与「批量下载」压缩包内的子目录
以及「单组压缩包」文件名**完全同源**,管理人员据此对得上号。 --> 以及「单组压缩包」文件名**完全同源**,管理人员据此对得上号。 -->
<el-table-column label="小组编号 / 作品目录" width="240"> <el-table-column label="小组编号 / 作品目录" width="260">
<template #default="{ row }"> <template #default="{ row }">
<div class="team-cell"> <div class="team-cell">
<span class="team-no" :title="row.teamFolder">{{ row.teamFolder }}</span> <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 <el-button
text text
type="primary" type="primary"
@@ -758,7 +765,14 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="上传文件 / 评分" min-width="420"> <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>
@@ -794,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">
@@ -1081,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>
<!-- 成绩明细表 --> <!-- 成绩明细表 -->
@@ -1204,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">
@@ -4011,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)
@@ -4058,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)
@@ -4070,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),
@@ -4182,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),
// 只用白名单;否则点「清除」后会静默变成「只看未提交」。 // 只用白名单;否则点「清除」后会静默变成「只看未提交」。
@@ -4199,13 +4253,77 @@ 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,
@@ -4218,6 +4336,7 @@ async function fetchSubmissions() {
aggregationUid: s.aggregationUid === undefined ? null : s.aggregationUid, 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 }
} }
@@ -4256,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 {
@@ -4271,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
@@ -4312,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)
}) })
@@ -4536,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)
@@ -4590,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 {
@@ -4604,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,
@@ -5548,6 +5699,9 @@ 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; }
@@ -5561,6 +5715,8 @@ onMounted(async () => {
color: #303133; color: #303133;
font-weight: 500; font-weight: 500;
} }
/* R194:小组副信息(参赛人数),只报数量、不列成员姓名 */
.team-meta { color: #909399; font-size: 12px; }
/* ==================== 统计卡片 ==================== */ /* ==================== 统计卡片 ==================== */
.stats-row { margin-bottom: 20px; } .stats-row { margin-bottom: 20px; }