feat(admin): 成绩管理新增「导出排名表」按钮 R220
- 先请求 ?meta=1 预检再下载:未分小组人数 / 本组无数据 / 底色已降级 三类情况必须摆到管理员面前才下载,避免下完才发现要重走一遍 - api 新增 getScoreRankMeta(silent 预检,不弹通用错误)/ scoreRankExportUrl(下载交给 safeDownload 三重校验) - 仅中职·高职显示(isExamScoreGroup),复用 scoreExportGroup 的组别选择 - 与「导出Excel」「下载公示表」三者分工写进注释:内部核查表 / 对外公示表 / 排名速览表,不可互相替代
This commit is contained in:
@@ -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}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取赛项信息(赛项名称 / 赛项编号)
|
* 读取赛项信息(赛项名称 / 赛项编号)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user