176 lines
9.4 KiB
JavaScript
176 lines
9.4 KiB
JavaScript
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, '江苏海州'],
|
||
['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'],
|
||
['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'],
|
||
['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 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, '第一中学东区考点'],
|
||
['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'],
|
||
['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'],
|
||
['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'],
|
||
['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, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''], ['passScore', '单科合格线(只读参考)', 20, ''], ['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();
|
||
}
|