feat(admin): 成绩导出改为 Excel + 板块迁移配套接口(R29)
- service/admin.js: 抽出 buildScoreExportTable(group) 作单一数据源(CSV/XLSX 共用,消除表头漂移) + 新增 exportToXlsx()(SheetJS aoa_to_sheet + write buffer + !cols 列宽) - controller/admin.js: 新增 exportExcel()(xlsx MIME + ctx.attachment 中文文件名编码) - router.js: 新增 GET /api/admin/export-excel/:group(adminAuth) - export-csv 路由刻意保留:verify_full_e2e.py B10/D10、verify_upload_download.py B2 依赖其非空断言
This commit is contained in:
@@ -261,6 +261,34 @@ class AdminController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出提交记录为 Excel(.xlsx)(R29)
|
||||
* GET /api/admin/export-excel/:group
|
||||
*
|
||||
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时中文易乱码、
|
||||
* 身份证号 / 手机号会被转成科学计数法。此处直接下发真正的 xlsx。
|
||||
* ⚠️ 与 exportCsv 共用 service.admin.buildScoreExportTable(表头/口径单一数据源)。
|
||||
*/
|
||||
async exportExcel() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group, true)) return;
|
||||
|
||||
const buf = await service.admin.exportToXlsx(group);
|
||||
|
||||
const fileName = `${group}_scores_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
ctx.attachment(fileName);
|
||||
ctx.body = buf;
|
||||
} catch (err) {
|
||||
ctx.logger.error('导出 Excel 失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '导出 Excel 失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载某组所有提交文件为 ZIP
|
||||
* GET /api/admin/batch-download/:group
|
||||
|
||||
@@ -121,6 +121,8 @@ module.exports = app => {
|
||||
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
|
||||
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
|
||||
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
|
||||
// R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable)
|
||||
router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel);
|
||||
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
|
||||
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
||||
|
||||
|
||||
@@ -626,62 +626,107 @@ class AdminService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== CSV 导出 ====================
|
||||
// ==================== 成绩导出(CSV / Excel,R29) ====================
|
||||
|
||||
/**
|
||||
* 导出某组提交记录为 CSV 字符串
|
||||
* 成绩导出**单一数据源**:表头 + 数据行。
|
||||
*
|
||||
* CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现
|
||||
* 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<string>} CSV 内容
|
||||
* @returns {Promise<{headers: string[], rows: Array<Array<string|number>>}>}
|
||||
*/
|
||||
async exportToCsv(group) {
|
||||
async buildScoreExportTable(group) {
|
||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||
const submissions = isExam
|
||||
? await this.getExamSubmissions(group)
|
||||
: await this.getSubmissions(group);
|
||||
|
||||
let csvContent = '';
|
||||
|
||||
if (isExam) {
|
||||
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
|
||||
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
|
||||
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
|
||||
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,客观题分,大题分,总分\n';
|
||||
for (const item of submissions) {
|
||||
csvContent += [
|
||||
const submissions = await this.getExamSubmissions(group);
|
||||
return {
|
||||
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
|
||||
rows: submissions.map(item => [
|
||||
item.contestantId,
|
||||
`"${item.idNumber || ''}"`,
|
||||
`"${item.userName || ''}"`,
|
||||
`"${item.phone || ''}"`,
|
||||
item.idNumber || '',
|
||||
item.userName || '',
|
||||
item.phone || '',
|
||||
item.answeredCount > 0 ? '是' : '否',
|
||||
item.examScore ?? 0,
|
||||
item.wordScore ?? 0,
|
||||
item.totalScore ?? 0,
|
||||
].join(',') + '\n';
|
||||
}
|
||||
} else {
|
||||
// 本科/教师组:Word(人工) + PPT(人工)
|
||||
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,文档文件名,文档大小,文档分,PPT文件名,PPT大小,PPT分\n';
|
||||
for (const item of submissions) {
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// 本科/教师组:Word(人工) + PPT(人工)
|
||||
const submissions = await this.getSubmissions(group);
|
||||
return {
|
||||
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '文档文件名', '文档大小', '文档分', 'PPT文件名', 'PPT大小', 'PPT分'],
|
||||
rows: submissions.map(item => {
|
||||
const account = item.account || {};
|
||||
const wordSub = item.wordSubmission;
|
||||
const pptSub = item.pptSubmission;
|
||||
csvContent += [
|
||||
return [
|
||||
item.contestantId,
|
||||
`"${account.id_number || ''}"`,
|
||||
`"${account.name_cn || ''}"`,
|
||||
`"${account.phone || ''}"`,
|
||||
account.id_number || '',
|
||||
account.name_cn || '',
|
||||
account.phone || '',
|
||||
item.submitted ? '是' : '否',
|
||||
`"${wordSub ? wordSub.file_name : ''}"`,
|
||||
wordSub ? wordSub.file_name : '',
|
||||
wordSub ? wordSub.file_size : '',
|
||||
wordSub && wordSub.score != null ? wordSub.score : '',
|
||||
`"${pptSub ? pptSub.file_name : ''}"`,
|
||||
pptSub ? pptSub.file_name : '',
|
||||
pptSub ? pptSub.file_size : '',
|
||||
pptSub && pptSub.score != null ? pptSub.score : '',
|
||||
].join(',') + '\n';
|
||||
}
|
||||
}
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return csvContent;
|
||||
/**
|
||||
* 导出某组提交记录为 CSV 字符串
|
||||
*
|
||||
* ⚠️ 该出口**保留**:`scripts/verify_full_e2e.py`(B10 / D10)与
|
||||
* `scripts/verify_upload_download.py`(B2)都以它做「导出链路非空」断言,
|
||||
* 删掉会让门禁变红。管理端界面已改走 Excel(见 exportToXlsx)。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<string>} CSV 内容
|
||||
*/
|
||||
async exportToCsv(group) {
|
||||
const { headers, rows } = await this.buildScoreExportTable(group);
|
||||
// 字符串一律加引号(原文对身份证 / 姓名 / 手机号 / 文件名即如此),数值保持裸值
|
||||
const cell = v => {
|
||||
if (v == null) return '';
|
||||
return typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v);
|
||||
};
|
||||
return [headers, ...rows].map(r => r.map(cell).join(',')).join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出某组成绩为真正的 Excel(.xlsx)(R29 需求)。
|
||||
*
|
||||
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时,
|
||||
* 中文易乱码、18 位身份证号 / 11 位手机号会被转成科学计数法,需手工「分列」。
|
||||
* 直接用已装依赖 xlsx(SheetJS) 生成 xlsx,打开即为规整表格。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<Buffer>} xlsx 二进制
|
||||
*/
|
||||
async exportToXlsx(group) {
|
||||
const { headers, rows } = await this.buildScoreExportTable(group);
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
// 列宽:文件名较长、身份证号 18 位,给足宽度让导出即可读
|
||||
ws['!cols'] = headers.map(h => {
|
||||
if (/文件名/.test(h)) return { wch: 32 };
|
||||
if (/身份证号/.test(h)) return { wch: 20 };
|
||||
return { wch: 12 };
|
||||
});
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '成绩单');
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
// ==================== 批量下载 ZIP ====================
|
||||
|
||||
Reference in New Issue
Block a user