R194: 作品管理改「小组维度」列表 + 成绩/提交列表 includeIdle 透传
- src/views/Admin.vue
· 作品管理一行 = 一个小组(小组编号/作品目录 + 提交状态 + 该组文件与评分 + 人数 + 提交人),
替代原「按人平铺」(同组 6 人 6 行、5 行未提交)
· 搜索口径改「小组编号」(姓名已不在列表展示);计数单位 人→组
· PDF 预览常驻说明 + 「在新标签打开」出口(iframe 白屏时不误导「系统没加载」)
- src/api/admin.js:getSubmissions/getScores 增加 params,供 includeIdle 口径透传
This commit is contained in:
@@ -53,8 +53,8 @@ export function importExcel(formData) {
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise} 作品列表
|
||||
*/
|
||||
export function getSubmissions(group) {
|
||||
return request.get(`/admin/submissions/${group}`)
|
||||
export function getSubmissions(group, params) {
|
||||
return request.get(`/admin/submissions/${group}`, { params })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,8 +256,8 @@ export function deleteMaterial(group, fileName) {
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise} 成绩列表
|
||||
*/
|
||||
export function getScores(group) {
|
||||
return request.get(`/admin/scores/${group}`)
|
||||
export function getScores(group, params) {
|
||||
return request.get(`/admin/scores/${group}`, { params })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -716,7 +716,9 @@
|
||||
<el-option label="本科组" value="benke" />
|
||||
<el-option label="教师组" value="jiaoshi" />
|
||||
</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);
|
||||
选项文案与「成绩管理 · 成绩总览」的提交状态下拉逐字对齐。 -->
|
||||
<!-- R178:value-on-clear="" 让「清除」回到与 ref 初值一致的空字符串
|
||||
@@ -734,19 +736,24 @@
|
||||
title="下载该组全部作品(不受上方筛选影响)"
|
||||
>批量下载作品</el-button>
|
||||
<span class="work-summary" v-if="submissions.length">
|
||||
共 {{ submissionStatusCount.total }} 人 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }}
|
||||
<template v-if="submissionStatusFilter === 'true' || submissionStatusFilter === 'false'">|当前筛选出 {{ filteredSubmissions.length }} 人</template>
|
||||
共 {{ submissionStatusCount.total }} 组 · 已提交 {{ submissionStatusCount.submitted }} · 未提交 {{ submissionStatusCount.unsubmitted }}
|
||||
<template v-if="submissionStatusFilter === 'true' || submissionStatusFilter === 'false'">|当前筛选出 {{ filteredSubmissions.length }} 组</template>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 🔴 R194:作品管理改「小组维度」(用户实测:比赛以小组为准,原表格按人平铺,
|
||||
同组 6 人 6 行、5 行是「未提交」,管理人员看不出小组整体是否已交)。
|
||||
一行 = 一个小组:小组编号/作品目录 + 提交状态 + 该组文件与评分。 -->
|
||||
<el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table">
|
||||
<el-table-column prop="name" label="选手姓名" width="120" />
|
||||
<!-- R190:小组编号 / 作品目录。
|
||||
<!-- R190/R194:小组编号 / 作品目录。
|
||||
展示值 = 后端落盘用的目录名(teamFolder),与「批量下载」压缩包内的子目录
|
||||
以及「单组压缩包」文件名**完全同源**,管理人员据此对得上号。 -->
|
||||
<el-table-column label="小组编号 / 作品目录" width="240">
|
||||
<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"
|
||||
@@ -758,7 +765,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</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 }">
|
||||
<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>
|
||||
@@ -794,6 +808,13 @@
|
||||
<el-dialog v-model="previewVisible" title="文件预览" width="80%" top="5vh" @closed="closePreview">
|
||||
<div v-if="previewFile">
|
||||
<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>
|
||||
<div v-else>
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom:12px">
|
||||
@@ -1081,6 +1102,17 @@
|
||||
class="date-range"
|
||||
@change="onScoreFilterChange"
|
||||
/>
|
||||
<!-- R195:默认只显示参赛过的人(有作答/有大题文件/已交卷)。中职/高职按小组参赛,
|
||||
一组报名多人只一人上场,其余组员全 0 分占行 ⇒ 勾选后才列出全部报名者。 -->
|
||||
<el-checkbox
|
||||
v-if="isExamScoreGroup"
|
||||
v-model="scoreIncludeIdle"
|
||||
size="small"
|
||||
class="idle-toggle"
|
||||
@change="onScoreIncludeIdleChange"
|
||||
>
|
||||
显示未参赛
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 成绩明细表 -->
|
||||
@@ -1204,6 +1236,15 @@
|
||||
class="date-range"
|
||||
@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>
|
||||
</div>
|
||||
<el-table :data="pagedExamSubmissions" stripe v-loading="examSubmissionsLoading" class="admin-table">
|
||||
@@ -4011,6 +4052,8 @@ async function downloadBigQuestionTpl() {
|
||||
|
||||
// 考生答题记录
|
||||
const examSubmissions = ref([])
|
||||
// R195:同「成绩总览」——默认隐藏从未参赛的报名者(includeIdle=1 才看全量)
|
||||
const examSubsIncludeIdle = ref(false)
|
||||
const examSubmissionsLoading = ref(false)
|
||||
const examSubmissionsPage = ref(1)
|
||||
const examSubmissionsPageSize = ref(10)
|
||||
@@ -4058,7 +4101,10 @@ async function fetchExamSubmissions() {
|
||||
// R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页
|
||||
examSubmissionsLoading.value = true
|
||||
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 || [])
|
||||
} catch (e) {
|
||||
console.warn('获取答题记录失败', e)
|
||||
@@ -4070,6 +4116,12 @@ function onExamSubsFilterChange() {
|
||||
examSubmissionsPage.value = 1
|
||||
}
|
||||
|
||||
// R195:服务端过滤(includeIdle),切换需重新请求
|
||||
async function onExamSubsIncludeIdleChange() {
|
||||
examSubmissionsPage.value = 1
|
||||
await fetchExamSubmissions()
|
||||
}
|
||||
|
||||
// R30:原「考生答题评分弹窗」的 state 与 openExamScoreDialog / saveExamScore 已整体移除。
|
||||
// 大题评分改由「考生大题作答查证」按小问给分(adminApi.setBigAnswerScores
|
||||
// → service.bigQuestion.setSubScores → service.admin.updateScores),
|
||||
@@ -4182,8 +4234,10 @@ const submissionStatusCount = computed(() => {
|
||||
const filteredSubmissions = computed(() => {
|
||||
let list = submissions.value
|
||||
if (submissionSearch.value) {
|
||||
// R194:行已按小组聚合 ⇒ 搜索口径改为「小组编号 / 作品目录」(姓名不再展示)
|
||||
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),
|
||||
// 只用白名单;否则点「清除」后会静默变成「只看未提交」。
|
||||
@@ -4199,13 +4253,77 @@ const pagedSubmissions = computed(() => {
|
||||
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() {
|
||||
if (!submissionGroup.value) return
|
||||
submissionsLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.getWorks(submissionGroup.value)
|
||||
const raw = Array.isArray(data) ? data : (data?.list || [])
|
||||
submissions.value = raw.map(s => ({
|
||||
const people = raw.map(s => ({
|
||||
contestantId: s.contestantId,
|
||||
name: s.name,
|
||||
idNumber: s.idNumber,
|
||||
@@ -4218,6 +4336,7 @@ async function fetchSubmissions() {
|
||||
aggregationUid: s.aggregationUid === undefined ? null : s.aggregationUid,
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -4256,7 +4375,14 @@ async function previewWork(row, f) {
|
||||
if (previewIsPdf.value) {
|
||||
const r = await fetchBlobGuarded(fileDownloadUrl(submissionGroup.value, f))
|
||||
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 {
|
||||
@@ -4271,6 +4397,16 @@ function closePreview() {
|
||||
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)
|
||||
async function batchDownloadWorks() {
|
||||
if (!submissionGroup.value) return
|
||||
@@ -4312,7 +4448,10 @@ async function batchSaveWorkScores() {
|
||||
submissions.value.forEach(s => {
|
||||
;(s.files || []).forEach(f => {
|
||||
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)
|
||||
else c.wordScore = Number(f._score)
|
||||
})
|
||||
@@ -4536,6 +4675,9 @@ watch(scoreExportGroup, () => {
|
||||
})
|
||||
|
||||
const scoreList = ref([])
|
||||
// R195:默认只看「参赛过的人」。中职/高职按小组参赛,一组报名多人只一人上场,
|
||||
// 未上场组员在成绩表里各占一行 0 分 ⇒ 默认隐藏,需核对全量时打开本开关。
|
||||
const scoreIncludeIdle = ref(false)
|
||||
const scoreListLoading = ref(false)
|
||||
const scoreListPage = ref(1)
|
||||
const scoreListPageSize = ref(10)
|
||||
@@ -4590,6 +4732,12 @@ function onScoreFilterChange() {
|
||||
scoreListPage.value = 1
|
||||
}
|
||||
|
||||
// R195:「显示未参赛」是**服务端过滤**(includeIdle 参数),切换必须重新拉数据,⛔ 不能只在本地过滤。
|
||||
async function onScoreIncludeIdleChange() {
|
||||
scoreListPage.value = 1
|
||||
await fetchScoreList()
|
||||
}
|
||||
|
||||
async function fetchPublishStatus() {
|
||||
if (!scoreExportGroup.value) return
|
||||
try {
|
||||
@@ -4604,7 +4752,10 @@ async function fetchScoreList() {
|
||||
if (!scoreExportGroup.value) return
|
||||
scoreListLoading.value = true
|
||||
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 => ({
|
||||
...item,
|
||||
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-input { width: 110px; }
|
||||
.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; }
|
||||
.work-summary { color: #606266; font-size: 13px; margin-left: 8px; }
|
||||
|
||||
@@ -5561,6 +5715,8 @@ onMounted(async () => {
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* R194:小组副信息(参赛人数),只报数量、不列成员姓名 */
|
||||
.team-meta { color: #909399; font-size: 12px; }
|
||||
|
||||
/* ==================== 统计卡片 ==================== */
|
||||
.stats-row { margin-bottom: 20px; }
|
||||
|
||||
Reference in New Issue
Block a user