Compare commits

...

3 Commits

Author SHA1 Message Date
1a6707cac3 fix(upload): 上传上限统一 150MB + 超时放宽至 600s(R225)
背景:教师组作品压缩包上传失败,根因是后端 config.multipart.fileSize
为 '100mb',而前端一直承诺 zip 200MB / 其他 100MB —— 两套阈值互不知情,
100~200MB 的文件必然「前端放行、后端拒收」。

改动:
- utils/uploadCombo.js: 新增 MAX_MB=150 作为唯一数值源,ZIP_MAX /
  OTHER_MAX 均取 MAX_MB;validateSingleFile 的提示文案改为「150MB」。
  原先 zip 200MB 是**无法兑现的承诺**(后端从来只收 100MB),收紧到 150
  是让提示与事实一致,不是新增限制。
- views/Competition.vue: 删除本文件内另一份 ZIP_MAX=200MB/OTHER_MAX=100MB
  的副本(本来就没被引用,但重复常量正是漂移温床),改为从 uploadCombo
  导入 MAX_MB,三处文案同步为「单个文件 150MB」。
- api/upload.js: uploadWork 的 timeout 300s -> 600s。150MB 在服务器入口
  约 1.25MB/s 且为全体考生共享,单传约 120s、数人并发可达 400s+;不放宽
  超时的话,限额放开后失败方式只会从「文件太大」变成「超时」。
  nginx /api/ 的 proxy_send_timeout / proxy_read_timeout 已是 600s,对齐。

产物验证:新包 Competition-C3sA4cVv.js 含「超过大小限制(150MB)」,
旧文案「zip 200MB / 其他 100MB」0 命中;timeout 压缩形态 timeout:6e5
(=600000)已确认落在 Competition chunk,Admin chunk 的超时未被波及。
2026-10-23 11:19:18 +08:00
138592155f feat(admin): 成绩管理新增「导出排名表」按钮 R220
- 先请求 ?meta=1 预检再下载:未分小组人数 / 本组无数据 / 底色已降级
  三类情况必须摆到管理员面前才下载,避免下完才发现要重走一遍
- api 新增 getScoreRankMeta(silent 预检,不弹通用错误)/
  scoreRankExportUrl(下载交给 safeDownload 三重校验)
- 仅中职·高职显示(isExamScoreGroup),复用 scoreExportGroup 的组别选择
- 与「导出Excel」「下载公示表」三者分工写进注释:内部核查表 / 对外公示表 /
  排名速览表,不可互相替代
2026-10-22 17:25:27 +08:00
1f4cfc280e R194: 作品管理改「小组维度」列表 + 成绩/提交列表 includeIdle 透传
- src/views/Admin.vue
  · 作品管理一行 = 一个小组(小组编号/作品目录 + 提交状态 + 该组文件与评分 + 人数 + 提交人),
    替代原「按人平铺」(同组 6 人 6 行、5 行未提交)
  · 搜索口径改「小组编号」(姓名已不在列表展示);计数单位 人→组
  · PDF 预览常驻说明 + 「在新标签打开」出口(iframe 白屏时不误导「系统没加载」)
- src/api/admin.js:getSubmissions/getScores 增加 params,供 includeIdle 口径透传
2026-10-22 09:41:57 +08:00
5 changed files with 320 additions and 27 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 })
} }
/** /**
@@ -322,6 +322,42 @@ export function publicityExportUrl(group) {
return `/api/admin/export-publicity/${group}` return `/api/admin/export-publicity/${group}`
} }
/**
* 成绩排名表导出前的统计预检(R220,⛔ 不下载文件,只回统计)
*
* 排名表是「一行 = 一个参赛小组」的速览表,前端在下载前需要知道三件事:
* ① 有没有人未归属小组(会各自单独占一行);② 本组是否根本没有可导出的数据;
* ③ 整行底色是否生成失败(后端会自动降级为无色版,文件名带「-无底色」)。
* ③ 必须提示,否则管理员会以为「导出错了」而不是「降级了」。
*
* silent: true —— 属「预检」而非用户动作,失败由调用方按业务语境提示。
* @param {string} group - 组别标识(仅 zhongzhi / gaozhi 有效)
* @returns {Promise<{fileName:string,rowCount:number,colored:boolean,degradeReason:string,
* stats:Object,topN:number,totalContestants:number,noTeamCount:number}>}
*/
export function getScoreRankMeta(group) {
return request.get(`/admin/export-score-rank/${group}`, {
params: { meta: 1 },
silent: true,
})
}
/**
* 下载成绩排名表(R220)
*
* 与另外两个导出产物的分工(⛔ 不可互相替代):
* · export-excel —— 内部核查表,全字段明细(含身份证号 / 手机号);
* · export-publicity —— 对外公示表,主办方模板、获奖等级列留空;
* · export-score-rank—— 排名速览表,一小组一行 + 区间底色 + 区间统计,无任何敏感字段。
*
* 下载动作由 `utils/download.js#safeDownload` 发起(三重校验),故只导出 URL。
* @param {string} group
* @returns {string} 下载 URL
*/
export function scoreRankExportUrl(group) {
return `/api/admin/export-score-rank/${group}`
}
/** /**
* 读取赛项信息(赛项名称 / 赛项编号) * 读取赛项信息(赛项名称 / 赛项编号)
* *

View File

@@ -39,8 +39,12 @@ export function uploadWork(group, formData, onProgress) {
// 注意:不要手动设置 Content-Type: multipart/form-data(不带 boundary 会导致 // 注意:不要手动设置 Content-Type: multipart/form-data(不带 boundary 会导致
// 后端 ctx.getFileStream() 报 Missing boundary 而 500)。交由 axios/浏览器自动 // 后端 ctx.getFileStream() 报 Missing boundary 而 500)。交由 axios/浏览器自动
// 补带 boundary 的 multipart 头。字段顺序:先文本字段(file_type/work_name)再 file。 // 补带 boundary 的 multipart 头。字段顺序:先文本字段(file_type/work_name)再 file。
// 🔴 R225:timeout 300s -> 600s。上传上限提至 150MB 后,服务器入口带宽约
// 1.25MB/s 且为全体考生共享,单传约 120s、数人并发时可达 400s+;
// 原 300s 会让「限额已放开」的文件改以超时失败。
// nginx 侧 /api/ 的 proxy_send_timeout/proxy_read_timeout 已是 600s,两者对齐。
return request.post(`/upload/${group}`, formData, { return request.post(`/upload/${group}`, formData, {
timeout: 300000, timeout: 600000,
onUploadProgress: (e) => { onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100)) if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
}, },

View File

@@ -13,8 +13,14 @@
*/ */
export const ALLOWED = ['.doc', '.docx', '.pdf', '.ppt', '.pptx', '.zip'] export const ALLOWED = ['.doc', '.docx', '.pdf', '.ppt', '.pptx', '.zip']
export const ZIP_MAX = 200 * 1024 * 1024 // 上传大小上限:统一 150MB
export const OTHER_MAX = 100 * 1024 * 1024 // 🔴 2026-10-23 R225:原 ZIP_MAX=200MB / OTHER_MAX=100MB,与后端
// config.multipart.fileSize(当时 '100mb')长期不一致 ⇒ 100~200MB 的 zip
// 前端放行、后端抛 MultipartFileTooLargeError,教师组因此报障。
// 现前后端统一为 150MB(后端已同步改为 '150mb'),保持「前端放行 == 后端接收」。
export const MAX_MB = 150
export const ZIP_MAX = MAX_MB * 1024 * 1024
export const OTHER_MAX = MAX_MB * 1024 * 1024
export function fileExt(name) { export function fileExt(name) {
return (String(name).split('.').pop() || '').toLowerCase() return (String(name).split('.').pop() || '').toLowerCase()
@@ -42,7 +48,7 @@ export function validateSingleFile(name, size) {
const e = '.' + fileExt(name) const e = '.' + fileExt(name)
if (!ALLOWED.includes(e)) return `不支持的格式:${name}` if (!ALLOWED.includes(e)) return `不支持的格式:${name}`
const max = e === '.zip' ? ZIP_MAX : OTHER_MAX const max = e === '.zip' ? ZIP_MAX : OTHER_MAX
if (size > max) return `${name} 超过大小限制(${e === '.zip' ? '200MB' : '100MB'})` if (size > max) return `${name} 超过大小限制(${MAX_MB}MB)`
return null return null
} }

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">
@@ -994,6 +1015,23 @@
</el-button> </el-button>
<!-- R29:由「导出CSV」改为直接导出真正的 Excel(.xlsx) --> <!-- R29:由「导出CSV」改为直接导出真正的 Excel(.xlsx) -->
<el-button type="primary" plain v-guard="() => exportExcel()" :disabled="!scoreExportGroup">导出Excel</el-button> <el-button type="primary" plain v-guard="() => exportExcel()" :disabled="!scoreExportGroup">导出Excel</el-button>
<!-- R220:成绩排名表(中职/高职专属)。
与「导出Excel」的区别:这个是**排名速览**——一行 = 一个参赛小组,
按小组客观题分降序**全量**导出(⛔ 不截断前 80,赞助小组也要在表里),
整行按客观题分区间上底色(≥60 绿 / 50–59.99 黄 / <50 红),
前 80 行用一个**红色外框整体圈住**(首行画上边、末行画下边,行间不画横线),
表尾给三区间小组数统计(全量 + 全量排名前 80)。
🔴 只出 6 列:名次/小组编号/学校/参赛选手/人数/客观题分(R220-C:不要大题分与总分)。
⛔ 不含身份证号 / 手机号,可直接给评审看。 -->
<el-button
v-if="isExamScoreGroup"
type="primary"
plain
:loading="scoreRankExporting"
v-guard="() => exportScoreRank()"
:disabled="!scoreExportGroup"
title="按小组客观题分降序导出全量排名表(整行按客观题分区间上底色,前80名用红框圈出,表尾含各区间小组数统计)"
>导出排名表</el-button>
<!-- R176:成绩公示表(半成品)。 <!-- R176:成绩公示表(半成品)。
与「导出Excel」的区别:导出Excel 是**内部工作用**的全字段明细; 与「导出Excel」的区别:导出Excel 是**内部工作用**的全字段明细;
公示表是**对外正式文书**,按主办方模板只留 7 列、按分数降序、 公示表是**对外正式文书**,按主办方模板只留 7 列、按分数降序、
@@ -1081,6 +1119,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 +1253,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 +4069,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 +4118,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 +4133,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 +4251,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 +4270,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 +4353,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 +4392,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 +4414,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 +4465,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 +4692,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 +4749,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 +4769,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,
@@ -4750,6 +4918,78 @@ async function exportPublicity() {
} finally { publicityExporting.value = false } } finally { publicityExporting.value = false }
} }
// ==================== 6.2 成绩排名表(R220) ====================
/**
* 排名表与另外两个导出产物的分工(⛔ 勿合并成同一个按钮):
* · 导出Excel —— **内部核查用**,全字段明细(含身份证号 / 手机号),不排序不裁剪;
* · 下载公示表 —— **对外公示用**,主办方模板 7 列、「获奖等级」列留空;
* · 导出排名表 —— **排名速览用**,一行 = 一个参赛小组、按小组客观题分降序**全量**导出、
* 整行按分数区间上底色、表尾含区间统计,无任何敏感字段。
* 用户明确要求「所有小组都导出」:赞助小组也在表里,⛔ 不截断前 80。
*/
const scoreRankExporting = ref(false)
/**
* 导出成绩排名表:先预检(?meta=1)再下载。
*
* 三件事必须在下载前摆到管理员面前,否则下载完才发现要重走一遍:
* ① 有人未归属小组(会各自单独占一行,行数会比预期多);
* ② 本组根本没有可导出的人(导出一张只有表头的空表);
* ③ 整行底色生成失败已降级为无色版(后端会自动降级,但**必须知情**,
* 否则管理员会以为「导出错了」)。
*/
async function exportScoreRank() {
const group = scoreExportGroup.value
if (!group) return
const label = groupMap[group] || group
let meta = null
try {
meta = await adminApi.getScoreRankMeta(group)
} catch (e) {
// 预检只是辅助信息,失败不阻断下载;也不再弹提示,避免与下载错误叠加
console.warn('成绩排名表预检失败', e)
}
const warns = []
if (meta) {
if (meta.noTeamCount > 0) warns.push(`有 ${meta.noTeamCount} 名选手未归属任何小组,将各自单独占一行`)
if (!meta.rowCount) warns.push('本组没有任何可导出的小组,导出的将是一张只有表头的空表')
if (meta.colored === false) {
warns.push(`整行底色生成失败(${meta.degradeReason || '未知原因'}),将导出无底色版本(文件名带「-无底色」)`)
}
}
if (warns.length) {
try {
await ElMessageBox.confirm(
h('div', null, [
h('p', { style: 'margin:0 0 8px' }, '导出前发现以下情况,请确认:'),
h('ul', { style: 'margin:0;padding-left:20px' }, warns.map(w => h('li', null, w))),
]),
`导出「${label}」成绩排名表`,
{
type: 'warning',
confirmButtonText: '仍然导出',
cancelButtonText: '取消',
distinguishCancelAndClose: true,
},
)
} catch {
return
}
}
scoreRankExporting.value = true
try {
await safeDownload(adminApi.scoreRankExportUrl(group), {
fallbackName: `成绩排名-${label}.xlsx`,
timeout: 120000,
})
} finally { scoreRankExporting.value = false }
}
// ==================== 7. 新闻管理 ==================== // ==================== 7. 新闻管理 ====================
const adminNews = ref([]) const adminNews = ref([])
const adminNewsLoading = ref(false) const adminNewsLoading = ref(false)
@@ -5548,6 +5788,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 +5804,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; }

View File

@@ -31,7 +31,7 @@
</template> </template>
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px"> <el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
<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」组合。单个文件上限 150MB。
</el-alert> </el-alert>
<!-- R190:小组作品状态(组内互斥)—— 组内已有人上传后,其他成员不能再传。 <!-- R190:小组作品状态(组内互斥)—— 组内已有人上传后,其他成员不能再传。
@@ -97,7 +97,7 @@
show-icon show-icon
style="margin-top:10px" style="margin-top:10px"
title="存在超出大小限制的文件" title="存在超出大小限制的文件"
description="标红的文件已超过上传上限(zip 200MB / 其他 100MB),请删除后重新选择符合要求的文件再提交。" description="标红的文件已超过上传上限(单个文件 150MB),请删除后重新选择符合要求的文件再提交。"
/> />
</div> </div>
@@ -432,6 +432,7 @@ import {
validateSingleFile, validateSingleFile,
finalComboCheck, finalComboCheck,
reconcileFiles, reconcileFiles,
MAX_MB,
} from '@/utils/uploadCombo' } from '@/utils/uploadCombo'
const router = useRouter() const router = useRouter()
@@ -472,8 +473,9 @@ const teamPanelVisible = computed(() => !!uploadStatus.value.teamNumber || uploa
// 两处同列会重复且按钮语义不同,容易误删。 // 两处同列会重复且按钮语义不同,容易误删。
const teamSharedFiles = computed(() => (teamLocked.value ? (uploadStatus.value.teamFiles || []) : [])) const teamSharedFiles = computed(() => (teamLocked.value ? (uploadStatus.value.teamFiles || []) : []))
const ZIP_MAX = 200 * 1024 * 1024 // 大小上限唯一来源 = uploadCombo.MAX_MB(与后端 config.multipart.fileSize 对齐)
const OTHER_MAX = 100 * 1024 * 1024 // 🔴 R225:原先此处另存一份 ZIP_MAX=200MB / OTHER_MAX=100MB 的副本,已删除
// —— 重复常量是「前端放行、后端拒收」漂移的温床,改为单点引用。
function fileTypeOf(name) { function fileTypeOf(name) {
const e = '.' + fileExt(name) const e = '.' + fileExt(name)
@@ -493,7 +495,7 @@ const uploadComboValid = computed(() => {
const pendingUploadFiles = computed(() => uploadFileList.value.filter(f => !f.uploaded && !isOverLimit(f))) const pendingUploadFiles = computed(() => uploadFileList.value.filter(f => !f.uploaded && !isOverLimit(f)))
const hasOverLimitFile = computed(() => uploadFileList.value.some(f => isOverLimit(f))) const hasOverLimitFile = computed(() => uploadFileList.value.some(f => isOverLimit(f)))
// 单文件是否超过上传大小限制(zip 200MB / 其他 100MB,来自 uploadCombo.validateSingleFile) // 单文件是否超过上传大小限制(150MB,来自 uploadCombo.validateSingleFile)
function isOverLimit(file) { function isOverLimit(file) {
const f = file.raw || file const f = file.raw || file
return validateSingleFile(f.name, f.size) !== null return validateSingleFile(f.name, f.size) !== null
@@ -511,7 +513,7 @@ function onUploadFileChange(file, fileList) {
const over = uploadFileList.value.filter(f => isOverLimit(f)) const over = uploadFileList.value.filter(f => isOverLimit(f))
if (over.length) { if (over.length) {
const names = over.map(f => (f.raw || f).name).join('、') const names = over.map(f => (f.raw || f).name).join('、')
ElMessage.error(`文件「${names}」已超过大小限制(zip 200MB / 其他 100MB),已标红且无法提交`) ElMessage.error(`文件「${names}」已超过大小限制(单个文件 ${MAX_MB}MB),已标红且无法提交`)
} else if (r.dropped.length) { } else if (r.dropped.length) {
ElMessage.info('已按规则自动覆盖冲突文件(zip 独占 / pdf 与 docx 互斥)') ElMessage.info('已按规则自动覆盖冲突文件(zip 独占 / pdf 与 docx 互斥)')
} }