diff --git a/app/controller/admin.js b/app/controller/admin.js index bada5ae..1b47bf3 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -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 diff --git a/app/router.js b/app/router.js index a7e1219..384daa8 100644 --- a/app/router.js +++ b/app/router.js @@ -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); diff --git a/app/service/admin.js b/app/service/admin.js index 4108ea3..c84adf5 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -626,62 +626,107 @@ class AdminService extends Service { }); } - // ==================== CSV 导出 ==================== + // ==================== 成绩导出(CSV / Excel,R29) ==================== /** - * 导出某组提交记录为 CSV 字符串 + * 成绩导出**单一数据源**:表头 + 数据行。 + * + * CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现 + * 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。 + * * @param {string} group - 组别 - * @returns {Promise} CSV 内容 + * @returns {Promise<{headers: string[], rows: Array>}>} */ - 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} 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} 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 ====================