Files
Exam-Information-System/excel.mjs
T

168 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}