feat(admin): 成绩管理新增「导出排名表」按钮 R220

- 先请求 ?meta=1 预检再下载:未分小组人数 / 本组无数据 / 底色已降级
  三类情况必须摆到管理员面前才下载,避免下完才发现要重走一遍
- api 新增 getScoreRankMeta(silent 预检,不弹通用错误)/
  scoreRankExportUrl(下载交给 safeDownload 三重校验)
- 仅中职·高职显示(isExamScoreGroup),复用 scoreExportGroup 的组别选择
- 与「导出Excel」「下载公示表」三者分工写进注释:内部核查表 / 对外公示表 /
  排名速览表,不可互相替代
This commit is contained in:
2026-10-22 17:25:27 +08:00
parent 1f4cfc280e
commit 138592155f
2 changed files with 125 additions and 0 deletions

View File

@@ -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

@@ -1015,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 列、按分数降序、
@@ -4901,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)