import ExcelJS from 'exceljs'; const resourceSpecs = { classes: { title: '班级台账', sheet: '班级', columns: [ ['schoolCode', '学校代码*', 16, 'HZ01'], ['grade', '年级*', 14, '高三'], ['name', '班级名称*', 22, '高三(3)班'], ['status', '状态*', 12, '启用'] ], validations: { status: ['启用', '停用'] } }, class_admins: { title: '班级管理员台账', sheet: '班级管理员', columns: [ ['schoolCode', '学校代码*', 16, 'HZ01'], ['className', '班级名称*', 22, '高三(1)班'], ['displayName', '管理员姓名*', 18, '张老师'], ['username', '登录账号*', 20, 'hz01_g301'], ['initialPassword', '初始密码(新建必填)', 24, 'ChangeMe123!'], ['status', '状态*', 12, '启用'] ], validations: { status: ['启用', '停用'] } }, account_quotas: { title: '批量报名号申领配额', sheet: '班级配额', columns: [['className', '班级名称*', 24, '高三(1)班'], ['count', '申领数量*', 16, 30]], numberColumns: ['count'] }, account_results: { title: '报名号下发清单', sheet: '账号结果', columns: [ ['batchId', '批次编号', 30, ''], ['className', '班级', 22, ''], ['candidateNumber', '固定报名号', 26, ''], ['initialPassword', '初始密码', 22, ''] ] }, candidates: { title: '考生资料台账', sheet: '考生资料', columns: [ ['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['name', '姓名*', 16, '李明'], ['gender', '性别*', 10, '男'], ['idNumber', '证件号码*', 24, '320101200801011234'], ['phone', '手机号*', 18, '13800138000'], ['email', '邮箱', 24, 'student@example.com'], ['nativePlace', '籍贯', 18, '江苏海州'], ['address', '家庭住址', 32, '海州市清河区示例路 1 号'], ['className', '班级*', 22, '高三(1)班'], ['ethnicity', '民族', 12, '汉族'], ['birthDate', '出生日期', 16, '2008-01-01'], ['postalCode', '邮编', 14, '222000'], ['guardianName', '监护人', 16, '李家长'], ['guardianPhone', '监护人电话', 18, '13900139000'] ], validations: { gender: ['男', '女'] } }, centers: { title: '考点考场档案', sheet: '考点考场', columns: [ ['schoolCode', '学校代码*', 14, 'HZ01'], ['centerCode', '考点代码*', 18, 'HZ01-C02'], ['centerName', '考点名称*', 24, '第一中学东区考点'], ['address', '详细地址*', 30, '海州市示例路 8 号'], ['managerName', '负责人', 16, '王老师'], ['managerPhone', '负责人手机', 18, '13800138000'], ['contact', '值班电话', 18, '0518-86020000'], ['emergencyPhone', '应急电话', 18, '0518-120'], ['gateOpenTime', '开放时间', 14, '07:00'], ['transport', '交通提示', 30, '东门入场'], ['centerStatus', '考点状态*', 14, '启用'], ['centerNotes', '考点备注', 26, ''], ['roomCode', '考场代码*', 16, '001'], ['roomName', '考场名称*', 22, '第 001 考场'], ['building', '楼栋*', 18, '教学楼 A'], ['floor', '楼层', 12, '1 层'], ['capacity', '容量*', 12, 30], ['seatPlan', '座位编排说明', 28, '按教室现场座次表编排'], ['roomType', '考场类型*', 16, '标准考场'], ['roomStatus', '考场状态*', 14, '启用'], ['roomNotes', '考场备注', 26, ''] ], validations: { centerStatus: ['启用', '停用'], roomStatus: ['启用', '停用'], roomType: ['标准考场', '机考考场', '无障碍考场', '备用考场'] }, numberColumns: ['capacity'] }, results: { title: '考试成绩台账', sheet: '成绩', columns: [ ['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['subjectName', '科目*', 16, '语文'], ['score', '成绩*', 12, 120], ['grade', '等级', 12, 'A'], ['published', '发布状态*', 14, '发布'] ], validations: { published: ['发布', '不发布'] }, numberColumns: ['score'] } }; export function hasExcelResource(resource) { return Boolean(resourceSpecs[resource]); } function cellValue(cell) { const value = cell.value; if (value == null) return ''; if (value instanceof Date) return value.toISOString().slice(0, 10); if (typeof value === 'object') { if ('text' in value) return String(value.text || '').trim(); if ('result' in value) return String(value.result ?? '').trim(); } return typeof value === 'number' ? value : String(value).trim(); } export async function parseWorkbook(resource, buffer) { const spec = resourceSpecs[resource]; if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 }); const workbook = new ExcelJS.Workbook(); try { await workbook.xlsx.load(buffer); } catch { throw Object.assign(new Error('无法读取 Excel 文件,请使用系统下载的 .xlsx 模板'), { status: 400 }); } const sheet = workbook.getWorksheet(spec.sheet) || workbook.worksheets[0]; if (!sheet) throw Object.assign(new Error('Excel 文件中没有可读取的工作表'), { status: 400 }); const headerMap = new Map(); sheet.getRow(2).eachCell((cell, col) => headerMap.set(String(cell.value || '').replace(/\*/g, '').trim(), col)); const missing = spec.columns.filter(([, label]) => !headerMap.has(label.replace(/\*/g, '').trim())); if (missing.length) throw Object.assign(new Error(`模板列不完整:缺少 ${missing.map(([, label]) => label).join('、')}`), { status: 400 }); const rows = []; for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) { const row = sheet.getRow(rowNumber); const item = { __row: rowNumber }; let populated = false; for (const [key, label] of spec.columns) { const value = cellValue(row.getCell(headerMap.get(label.replace(/\*/g, '').trim()))); item[key] = value; if (value !== '') populated = true; } if (populated) rows.push(item); } if (!rows.length) throw Object.assign(new Error('Excel 中没有可导入的数据行'), { status: 400 }); return rows; } export async function buildWorkbook(resource, rows = [], { template = false, subtitle = '' } = {}) { const spec = resourceSpecs[resource]; if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 }); const workbook = new ExcelJS.Workbook(); workbook.creator = '衡准考试信息管理系统'; workbook.created = new Date(); const sheet = workbook.addWorksheet(spec.sheet, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] }); const lastColumn = spec.columns.length; sheet.columns = spec.columns.map(([key, , width]) => ({ key, width })); spec.columns.forEach(([key], index) => { sheet.getColumn(index + 1).numFmt = spec.numberColumns?.includes(key) ? '0' : '@'; }); sheet.mergeCells(1, 1, 1, lastColumn); const titleCell = sheet.getCell(1, 1); titleCell.value = subtitle ? `${spec.title}|${subtitle}` : spec.title; titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } }; titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } }; titleCell.alignment = { vertical: 'middle', horizontal: 'left' }; sheet.getRow(1).height = 34; const header = sheet.getRow(2); header.values = spec.columns.map(([, label]) => label); header.height = 25; header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } }; header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } }; header.alignment = { vertical: 'middle', horizontal: 'center' }; const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : []; for (const item of outputRows) { const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, item[key] ?? '']))); row.height = 23; row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } }; row.alignment = { vertical: 'middle' }; row.eachCell(cell => { cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } }; }); } sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } }; for (const [key, values] of Object.entries(spec.validations || {})) { const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1; for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) { sheet.getCell(row, col).dataValidation = { type: 'list', allowBlank: false, formulae: [`"${values.join(',')}"`] }; } } const requiredColumns = spec.columns.map(([, label], index) => label.includes('*') ? index + 1 : 0).filter(Boolean); for (const col of requiredColumns) header.getCell(col).font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFE7A3' } }; return workbook.xlsx.writeBuffer(); }