diff --git a/src/api/admin.js b/src/api/admin.js index 186ec8e..a2dec3d 100644 --- a/src/api/admin.js +++ b/src/api/admin.js @@ -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 }) } /** diff --git a/src/views/Admin.vue b/src/views/Admin.vue index 29bbf32..e26a0c1 100644 --- a/src/views/Admin.vue +++ b/src/views/Admin.vue @@ -716,7 +716,9 @@ - + + - - - + {{ row.teamFolder }} + {{ row.memberCount }} 人参赛 + + 提交人:{{ row.submitterName }} - + + + + {{ isWorkSubmitted(row) ? '已提交' : '未提交' }} + + + + {{ f.fileName }} @@ -794,6 +808,13 @@ {{ previewFile.fileName }}({{ previewFile.kind === 'ppt' ? 'PPT' : previewFile.kind === 'answer' ? 'Word答案' : '文档' }}) + + + 在新标签打开 + 若下方空白,通常是浏览器未启用内置 PDF 阅读器,可点左侧按钮或下载后查看 + @@ -1081,6 +1102,17 @@ class="date-range" @change="onScoreFilterChange" /> + + + 显示未参赛 + @@ -1204,6 +1236,15 @@ class="date-range" @change="onExamSubsFilterChange" /> + + + 显示未参赛 + 刷新 @@ -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} people - getWorks 的选手级数据 + * @returns {Array} 小组级行 + */ +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; }
{{ previewFile.fileName }}({{ previewFile.kind === 'ppt' ? 'PPT' : previewFile.kind === 'answer' ? 'Word答案' : '文档' }})