From d4e085b6c57aaae258e5188e5f412dbd216df051 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 09:44:06 +0800 Subject: [PATCH] Implement application updates and refinements --- README.md | 15 +- app.js | 116 +++-- database.mjs | 68 ++- excel.mjs | 167 +++++++ package-lock.json | 1003 ++++++++++++++++++++++++++++++++++++++++- package.json | 6 + server.mjs | 300 +++++++++++- styles.css | 37 ++ tests/system.test.mjs | 63 ++- 9 files changed, 1704 insertions(+), 71 deletions(-) create mode 100644 excel.mjs diff --git a/README.md b/README.md index 935bce9..92707ba 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,18 @@ - 超级、校级、班级三级管理员,同一级支持多个账号 - 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 -- 校级管理员只管理本校考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 +- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 - 班级管理员只读查看本班考生、成绩和报名状态 - 考生信息修改、考试报名、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 - 当前处理人可将流程转交给同范围的同级管理员 - 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 - 报名号在创建考生账户时只生成一次,后续考试报名自动复用 - 超级管理员只维护号码规则;校级批量申请最终批准后,系统原子生成固定报名号、随机初始密码和待补录账户 -- 批次结果按班级返回校级管理员,并可导出 CSV 安全下发 -- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态 +- 批次结果按班级返回校级管理员,并可导出 Excel 安全下发 +- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位编排说明、类型和状态 - 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案 +- 班级、班级管理员、报名号班级配额、考生资料、考点考场和成绩均提供 Excel 模板、导入与当前数据导出;只读角色保留对应导出能力 +- Excel 导入逐行校验并返回具体行号;考生资料和考点考场的批量修改仍必须经过配置好的审批流程 - 考务指标与审计日志 - 考生资料审核、通过或退回修改 - 考试报名及科目审核 @@ -60,7 +62,7 @@ - 组织、学校、班级三级数据范围在服务端强制过滤 - 审批实例、当前责任人、转交和监督操作全程留痕 - 桌面端与移动端响应式布局 -- 零第三方运行时依赖 +- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小 ## 运行 @@ -73,7 +75,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v5,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、结构化考场、批量建号申请与审批结果结构。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v6,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、学校组织管理、结构化考场、座位编排说明、批量建号申请与审批结果结构。 ## 数据库配置 @@ -144,7 +146,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证和成绩完整流程。 ## 项目结构 @@ -154,6 +156,7 @@ styles.css 公共首页、考生端、管理端响应式样式 app.js 前端路由、状态和业务交互 server.mjs HTTP 服务、认证、权限与全部业务 API database.mjs 分表结构、SQLite / MySQL 适配与事务仓储 +excel.mjs Excel 模板、导入解析与导出工作簿 tests/system.test.mjs 端到端系统测试 data/exam.sqlite 本地运行后生成的 SQLite 数据库 .env.example 开发与生产环境变量模板 diff --git a/app.js b/app.js index a8ece20..29cf98c 100644 --- a/app.js +++ b/app.js @@ -57,11 +57,12 @@ function money(value) { } async function api(path, options = {}) { + const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData; const response = await fetch(path, { credentials: 'same-origin', - headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, ...options, - body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body + body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body }); const type = response.headers.get('content-type') || ''; const data = type.includes('application/json') ? await response.json() : await response.text(); @@ -150,7 +151,7 @@ function adminNavForUser() { const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; if (level === 'class') return core; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; - if (level === 'school') return [core[0], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]]; + if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]]; return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]]; } @@ -166,6 +167,7 @@ function portalHeadingAction(role, page) { if (role === 'admin' && page === 'exams') return ``; if (role === 'admin' && page === 'admins') return ``; if (role === 'admin' && page === 'centers') return ``; + if (role === 'admin' && page === 'organization') return ``; if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; return ''; } @@ -273,6 +275,7 @@ async function renderAdmin(page) { results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], + organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'], 'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'], flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], @@ -282,13 +285,13 @@ async function renderAdmin(page) { if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); try { - const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page; + const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page; const data = await api(`/api/admin/${endpoint}`); state.pageData = data; const content = { dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data), - admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), 'account-batches': () => adminAccountBatches(data), + admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); @@ -301,9 +304,19 @@ function adminDashboard(data) { return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; } +function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) { + return `
${h(label)} Excel使用系统模板可获得逐行校验
${template ? `` : ''}${importable ? `` : ''}
`; +} + +function adminSchoolOrganization(data) { + const classes = data.classes || []; + const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0); + return `
SCHOOL ORGANIZATION

${h(data.school?.name)}

班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。

班级
${classes.length}
班级管理员
${activeAdmins}
在册考生
${classes.reduce((sum, item) => sum + item.candidateCount, 0)}
${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}
${classes.map(item => `
${h(item.grade)}

${h(item.name)}

${badge(item.active ? 'approved' : 'closed')}
${item.candidateCount}在册考生${item.admins.length}管理员
班级管理员
${item.admins.map(admin => ``).join('') || '

尚未配置班级管理员

'}
`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}
`; +} + function adminCandidates(candidates) { const readOnly = state.user.adminLevel === 'class'; - return `
${candidates.map(item => ``).join('')}
报名号 / 考生证件号码学校 / 班级账户进度更新时间资料状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${item.profileCompleted ? badge(item.status) : '未完成'}
`; + return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}
${candidates.map(item => ``).join('')}
报名号 / 考生证件号码学校 / 班级账户进度更新时间资料状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${item.profileCompleted ? badge(item.status) : '未完成'}
`; } function adminRegistrations(registrations) { @@ -326,7 +339,7 @@ function adminAdmit(registrations) { function adminResults(data) { const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

保存后可立即发布
` : ''; - return `
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
`; + return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
`; } function adminUsers(data) { @@ -335,9 +348,9 @@ function adminUsers(data) { function adminCenters(data) { const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' }; - const cards = data.centers.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}
启用席位${center.totalCapacity}
开放时间${h(center.gateOpenTime || '未设')}
详细地址
${h(center.address)}
考点负责人
${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
应急电话
${h(center.emergencyPhone || '未填写')}
交通提示
${h(center.transport || '未填写')}
${center.rooms.map(room => ``).join('')}
考场位置类型容量座位号状态
${h(room.name)}${h(room.code)}${h(room.building)} · ${h(room.floor || '楼层未填')}${h(roomTypeNames[room.roomType] || room.roomType)}${h(room.capacity)} 席${h(room.seatStart)}—${h(room.seatEnd)}${badge(room.status === 'active' ? 'approved' : 'closed')}
${h(center.notes || '无补充说明')}
`).join(''); + const cards = data.centers.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}
启用席位${center.totalCapacity}
开放时间${h(center.gateOpenTime || '未设')}
详细地址
${h(center.address)}
考点负责人
${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
应急电话
${h(center.emergencyPhone || '未填写')}
交通提示
${h(center.transport || '未填写')}
${center.rooms.map(room => ``).join('')}
考场位置类型容量座位编排状态
${h(room.name)}${h(room.code)}${h(room.building)} · ${h(room.floor || '楼层未填')}${h(roomTypeNames[room.roomType] || room.roomType)}${h(room.capacity)} 席${h(room.seatPlan || '按现场座次表编排')}${badge(room.status === 'active' ? 'approved' : 'closed')}
${h(center.notes || '无补充说明')}
`).join(''); const requests = data.changeRequests || []; - return `
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; + return `${excelToolbar('centers', { label: '考点考场档案' })}
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; } const numberSegmentMeta = { @@ -350,10 +363,10 @@ function adminAccountBatches(data) { const batches = data.batches || []; const form = `
SCHOOL ACCOUNT REQUEST

按班级申领报名号

只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。

单批上限500个账户
${classes.map(item => ``).join('')}

审批通过后生成固定报名号、随机初始密码、待补录考生账户

`; const ledger = batches.map(batch => { - const resultRows = batch.status === 'approved' ? `
账号下发清单${batch.totalCount} 个账号 · 考生首次登录必须改密
${batch.items.map((item, index) => ``).join('')}
序号班级固定报名号 / 账户初始密码
${index + 1}${h(item.className)}${h(item.candidateNumber)}${h(item.initialPassword)}
` : ''; + const resultRows = batch.status === 'approved' ? `
账号下发清单${batch.totalCount} 个账号 · 考生首次登录必须改密
${batch.items.map((item, index) => ``).join('')}
序号班级固定报名号 / 账户初始密码
${index + 1}${h(item.className)}${h(item.candidateNumber)}${h(item.initialPassword)}
` : ''; return ``; }).join(''); - return `${form}`; + return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}`; } function adminNumberRules(data) { @@ -466,6 +479,17 @@ document.addEventListener('click', async event => { if (action === 'new-notice') return openNoticeForm(); if (action === 'new-exam') return openExamForm(); if (action === 'new-admin') return openAdminForm(); + if (action === 'new-school-class') return openSchoolClassForm(); + if (action === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id)); + if (action === 'toggle-school-class') { + await api(`/api/admin/classes/${target.dataset.id}`, { method: 'PATCH', body: { active: target.dataset.active === 'true' } }); + toast(target.dataset.active === 'true' ? '班级已启用' : '班级已停用', '班级管理员和历史数据仍会保留'); return renderRoute(); + } + if (action === 'new-class-admin') return openClassAdminForm(null, target.dataset.classId); + if (action === 'edit-class-admin') { + const schoolClass = state.pageData.classes.find(item => item.id === target.dataset.classId); + return openClassAdminForm(schoolClass?.admins.find(item => item.id === target.dataset.id), target.dataset.classId); + } if (action === 'new-center') return openCenterForm(); if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id)); if (action === 'add-center-room') { @@ -491,16 +515,16 @@ document.addEventListener('click', async event => { if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id)); if (action === 'review-candidate') return openCandidateReview(target.dataset.id); if (action === 'review-registration') return openRegistrationReview(target.dataset.id); - if (action === 'export-account-batch') { - const batch = state.pageData.batches.find(item => item.id === target.dataset.id); - if (!batch?.items?.length) throw new Error('该批次尚无可导出的账号结果'); - const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`; - const rows = [['序号', '班级', '固定报名号', '初始密码'], ...batch.items.map((item, index) => [index + 1, item.className, item.candidateNumber, item.initialPassword])]; - const blob = new Blob([`\uFEFF${rows.map(row => row.map(quote).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); link.href = url; link.download = `${batch.schoolName}-报名号批次-${batch.id}.csv`; link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - return toast('账号清单已导出', '请通过安全渠道向考生下发初始密码'); + if (action === 'excel-download') { + const query = new URLSearchParams(); + if (target.dataset.template === '1') query.set('template', '1'); + if (target.dataset.batchId) query.set('batchId', target.dataset.batchId); + window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`; + return; + } + if (action === 'excel-import') { + document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click(); + return; } if (action === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); @@ -541,6 +565,29 @@ document.addEventListener('input', event => { }); document.addEventListener('change', event => { + if (event.target.matches('[data-excel-file]')) { + const input = event.target; + const file = input.files?.[0]; + if (!file) return; + const resource = input.dataset.excelFile; + input.value = ''; + (async () => { + try { + toast('正在导入 Excel', `${file.name} · 逐行校验并写入,错误会标出具体行`); + const result = await api(`/api/admin/excel/${resource}`, { + method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() + }); + if (resource === 'account_quotas') { + document.querySelectorAll('[data-class-id]').forEach(field => { field.value = '0'; }); + result.quotas.forEach(item => { const field = document.querySelector(`[data-class-id="${item.classId}"]`); if (field) field.value = item.count; }); + toast('班级配额已填入', `已读取 ${result.quotas.length} 个班级,请核对后提交审批`); + } else { + toast('Excel 导入完成', `已处理 ${result.count} 条数据`); renderRoute(); + } + } catch (error) { toast('Excel 导入失败', error.message); } + })(); + return; + } if (event.target.matches('[data-action="school-select"]')) { const form = event.target.closest('form'); const classSelect = form?.querySelector('select[name="classId"]'); @@ -603,6 +650,15 @@ document.addEventListener('submit', async event => { const body = formObject(form); await api('/api/admin/admins', { method: 'POST', body }); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); + } else if (kind === 'school-class') { + const body = formObject(form); body.active = form.active.checked; + await api(body.id ? `/api/admin/classes/${body.id}` : '/api/admin/classes', { method: body.id ? 'PATCH' : 'POST', body }); + closeModal(); toast(body.id ? '班级已更新' : '班级已创建', `${body.grade} · ${body.name}`); renderRoute(); + } else if (kind === 'class-admin') { + const body = formObject(form); body.active = form.active.checked; + if (body.id) await api(`/api/admin/admins/${body.id}`, { method: 'PATCH', body }); + else await api('/api/admin/admins', { method: 'POST', body: { ...body, adminLevel: 'class', schoolId: state.user.schoolId } }); + closeModal(); toast(body.id ? '班级管理员已更新' : '班级管理员已创建', '权限范围已绑定到指定班级'); renderRoute(); } else if (kind === 'candidate-account-batch') { const quotas = [...form.querySelectorAll('[data-class-id]')].map(input => ({ classId: input.dataset.classId, count: Number(input.value || 0) })).filter(item => item.count > 0); const total = quotas.reduce((sum, item) => sum + item.count, 0); @@ -623,8 +679,7 @@ document.addEventListener('submit', async event => { building: row.querySelector('[name="roomBuilding"]').value, floor: row.querySelector('[name="roomFloor"]').value, capacity: Number(row.querySelector('[name="roomCapacity"]').value), - seatStart: Number(row.querySelector('[name="roomSeatStart"]').value), - seatEnd: Number(row.querySelector('[name="roomSeatEnd"]').value), + seatPlan: row.querySelector('[name="roomSeatPlan"]').value, roomType: row.querySelector('[name="roomType"]').value, status: row.querySelector('[name="roomStatus"]').value, notes: row.querySelector('[name="roomNotes"]').value @@ -688,14 +743,23 @@ function openAdminForm() { setModal(``); } +function openSchoolClassForm(schoolClass = null) { + setModal(``); +} + +function openClassAdminForm(admin = null, classId = '') { + const classes = state.pageData.classes || []; + setModal(``); +} + function centerRoomEditor(room = {}) { - return `
结构化考场
`; + return `
结构化考场
`; } function openCenterForm(center = null) { const schools = state.pageData.schools || []; const rooms = center?.rooms?.length ? center.rooms : [{}]; - setModal(``); + setModal(``); } function openFlowDetail(id) { @@ -710,7 +774,7 @@ function openFlowDetail(id) { const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : instance.candidateName; const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; const change = instance.centerChange; - const changeSnapshot = change ? `
申请快照

${h(change.name)} · ${h(change.code)}

${change.rooms.length} 个考场
地址
${h(change.address)}
负责人
${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}
开放时间
${h(change.gateOpenTime || '未填写')}
档案状态
${change.centerStatus === 'active' ? '启用' : '停用'}
${change.rooms.map(room => `${h(room.name)}${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatStart)}—${h(room.seatEnd)}`).join('')}
` : ''; + const changeSnapshot = change ? `
申请快照

${h(change.name)} · ${h(change.code)}

${change.rooms.length} 个考场
地址
${h(change.address)}
负责人
${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}
开放时间
${h(change.gateOpenTime || '未填写')}
档案状态
${change.centerStatus === 'active' ? '启用' : '停用'}
${change.rooms.map(room => `${h(room.name)}${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}`).join('')}
` : ''; const batch = instance.accountBatch; const batchSnapshot = batch ? `
班级配额

${batch.totalCount} 个待建账户

${batch.quotas.length} 个班级
${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}

最终批准时才生成固定报名号和随机初始密码。

` : ''; setModal(`${changeSnapshot}${batchSnapshot}
${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}

流程轨迹

${history || '

暂无操作

'}
${canProcess ? `` : '
当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`); diff --git a/database.mjs b/database.mjs index ba06599..d0b2949 100644 --- a/database.mjs +++ b/database.mjs @@ -219,6 +219,7 @@ const sqliteSchema = ` building TEXT NOT NULL, floor TEXT, capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_plan TEXT, seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0), seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start), room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), @@ -259,6 +260,7 @@ const sqliteSchema = ` building TEXT NOT NULL, floor TEXT, capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_plan TEXT, seat_start INTEGER NOT NULL DEFAULT 1, seat_end INTEGER NOT NULL, room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), @@ -597,6 +599,7 @@ const mysqlSchema = [ building VARCHAR(120) NOT NULL, floor VARCHAR(40) NULL, capacity INT UNSIGNED NOT NULL, + seat_plan VARCHAR(500) NULL, seat_start INT UNSIGNED NOT NULL DEFAULT 1, seat_end INT UNSIGNED NOT NULL, room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, @@ -643,6 +646,7 @@ const mysqlSchema = [ building VARCHAR(120) NOT NULL, floor VARCHAR(40) NULL, capacity INT UNSIGNED NOT NULL, + seat_plan VARCHAR(500) NULL, seat_start INT UNSIGNED NOT NULL DEFAULT 1, seat_end INT UNSIGNED NOT NULL, room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, @@ -788,7 +792,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 5, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 6, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, state.meta?.createdAt || new Date().toISOString() ); @@ -918,10 +922,10 @@ function buildSeedOperations(state) { for (const room of state.testRooms) { add( `INSERT INTO test_rooms ( - id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity), - Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, room.status || 'active', nullable(room.notes) + nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType, room.status || 'active', nullable(room.notes) ); } @@ -943,10 +947,10 @@ function buildSeedOperations(state) { for (const room of state.centerChangeRooms) { add( `INSERT INTO center_change_rooms ( - id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, room.requestId, nullable(room.roomId), room.code, room.name, room.building, nullable(room.floor), - Number(room.capacity), Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, + Number(room.capacity), nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType, room.status || 'active', nullable(room.notes) ); } @@ -1225,6 +1229,7 @@ function stateFromRows(rows) { building: row.building, floor: row.floor || '', capacity: Number(row.capacity), + seatPlan: row.seat_plan || '', seatStart: Number(row.seat_start), seatEnd: Number(row.seat_end), roomType: row.room_type, @@ -1262,6 +1267,7 @@ function stateFromRows(rows) { building: row.building, floor: row.floor || '', capacity: Number(row.capacity), + seatPlan: row.seat_plan || '', seatStart: Number(row.seat_start), seatEnd: Number(row.seat_end), roomType: row.room_type, @@ -1717,6 +1723,27 @@ function createRepository({ client, location, read, transaction, close }) { auditOperation(log) ]); }, + async saveSchoolClass(schoolClass, isNew, log) { + const change = isNew + ? operation( + 'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0 + ) + : operation( + 'UPDATE school_classes SET name = ?, grade = ?, active = ? WHERE id = ?', + schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0, schoolClass.id + ); + await transaction([change, auditOperation(log)]); + }, + async updateAdmin(user, passwordChanged, log) { + const sql = passwordChanged + ? 'UPDATE users SET display_name = ?, class_id = ?, active = ?, password_hash = ? WHERE id = ?' + : 'UPDATE users SET display_name = ?, class_id = ?, active = ? WHERE id = ?'; + const params = passwordChanged + ? [user.displayName, optional(user.classId), user.active ? 1 : 0, user.passwordHash, user.id] + : [user.displayName, optional(user.classId), user.active ? 1 : 0, user.id]; + await transaction([operation(sql, ...params), auditOperation(log)]); + }, async saveTestCenter(center, isNew, log) { const centerOperation = isNew ? operation( @@ -1750,10 +1777,10 @@ function createRepository({ client, location, read, transaction, close }) { ]; for (const room of rooms) operations.push(operation( `INSERT INTO center_change_rooms ( - id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, request.id, optional(room.roomId), room.code, room.name, room.building, optional(room.floor), - Number(room.capacity), Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, + Number(room.capacity), optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status, optional(room.notes) )); operations.push(auditOperation(log)); @@ -1798,10 +1825,10 @@ function createRepository({ client, location, read, transaction, close }) { operations.push(operation('DELETE FROM test_rooms WHERE center_id = ?', center.id)); for (const room of rooms) operations.push(operation( `INSERT INTO test_rooms ( - id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.id, center.id, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), - Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes) + optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status, optional(room.notes) )); } operations.push(auditOperation(log)); @@ -1933,6 +1960,8 @@ async function createSqliteStore({ path, seed }) { ['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], ['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'] ]); + ensureColumns('test_rooms', [['seat_plan', 'TEXT']]); + ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]); if (tableExists('workflow_definitions')) { const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || ''; if (!definitionSql.includes('candidate_account_batch')) { @@ -2134,13 +2163,17 @@ async function createSqliteStore({ path, seed }) { } } + if (existingSystem && Number(existingSystem.app_version || 1) < 6) { + connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run(); + } + if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { const initialState = seed(); connection.exec('BEGIN IMMEDIATE'); try { connection.prepare(` INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 5, ?, ?, ?) + VALUES (1, 6, ?, ?, ?) `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); @@ -2222,6 +2255,8 @@ async function createMysqlStore({ seed }) { 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL', "ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'", 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL', + 'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', + 'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', "ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL" ]; for (const statement of mysqlColumnMigrations) await pool.execute(statement); @@ -2395,6 +2430,9 @@ async function createMysqlStore({ seed }) { connection.release(); } } + if (Number(metadataRows[0]?.app_version || 1) < 6) { + await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1'); + } } if (!existing.length) { const initialState = seed(); @@ -2403,7 +2441,7 @@ async function createMysqlStore({ seed }) { await connection.beginTransaction(); const [insert] = await connection.execute(` INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 5, ?, ?, ?) + VALUES (1, 6, ?, ?, ?) `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); diff --git a/excel.mjs b/excel.mjs new file mode 100644 index 0000000..9dcd15a --- /dev/null +++ b/excel.mjs @@ -0,0 +1,167 @@ +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(); +} diff --git a/package-lock.json b/package-lock.json index 5463f63..a6a3d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,61 @@ { "name": "hengzhun-exam-system", - "version": "1.0.0", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hengzhun-exam-system", - "version": "1.0.0", + "version": "1.0.3", "dependencies": { + "exceljs": "^4.4.0", "mysql2": "^3.14.2" }, "engines": { "node": ">=22.5" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -24,6 +66,81 @@ "undici-types": "~8.3.0" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -33,6 +150,201 @@ "node": ">= 6.0.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -42,6 +354,115 @@ "node": ">=0.10" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -51,6 +472,33 @@ "is-property": "^1.0.2" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -67,12 +515,239 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -94,6 +769,39 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mysql2": { "version": "3.23.1", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.1.tgz", @@ -128,12 +836,146 @@ "node": ">=8.0.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sql-escaper": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", @@ -149,12 +991,169 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT", "peer": true + }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } } } } diff --git a/package.json b/package.json index 0855f62..791eee6 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,14 @@ "test": "node tests/system.test.mjs" }, "dependencies": { + "exceljs": "^4.4.0", "mysql2": "^3.14.2" }, + "overrides": { + "exceljs": { + "uuid": "^11.1.1" + } + }, "engines": { "node": ">=22.5" } diff --git a/server.mjs b/server.mjs index 23be2a1..e10baf6 100644 --- a/server.mjs +++ b/server.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { extname, join, normalize, resolve } from 'node:path'; import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; import { createDatabase } from './database.mjs'; +import { buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs'; const root = resolve(process.cwd()); const port = Number(process.env.PORT || 4173); @@ -45,7 +46,7 @@ function seedDatabase() { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 5, createdAt: nowIso() }, + meta: { version: 6, createdAt: nowIso() }, settings: { selfRegistrationEnabled: false }, organization: { name: '海州市教育考试中心', @@ -125,11 +126,11 @@ function seedDatabase() { { id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() } ], testRooms: [ - { id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }, - { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'standard', status: 'active', notes: '' }, - { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatStart: 1, seatEnd: 40, roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, - { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }, - { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' } + { id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, + { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' } ], centerChangeRequests: [], centerChangeRooms: [], @@ -195,6 +196,28 @@ async function readJson(request) { } } +async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 }); + chunks.push(chunk); + } + if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 }); + return Buffer.concat(chunks); +} + +function sendWorkbook(response, buffer, filename) { + response.writeHead(200, { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, + 'Content-Length': buffer.length, + 'Cache-Control': 'no-store' + }); + response.end(buffer); +} + function parseCookies(request) { return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { const index = part.indexOf('='); @@ -429,17 +452,14 @@ function parseCenterChange(db, body, schoolId, center = null) { const roomName = cleanText(room.name, 80); const building = cleanText(room.building, 80); const capacity = Number(room.capacity); - const seatStart = Number(room.seatStart); - const seatEnd = Number(room.seatEnd); - if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1 || !Number.isInteger(seatStart) || !Number.isInteger(seatEnd) || seatStart < 1 || seatEnd < seatStart) { - throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋、容量或座位号范围无效`), { status: 400 }); + if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) { + throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 }); } - if (seatEnd - seatStart + 1 > capacity) throw Object.assign(new Error(`第 ${index + 1} 个考场的座位号数量不能超过考场容量`), { status: 400 }); if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 }); roomCodes.add(roomCode); return { id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName, - building, floor: cleanText(room.floor, 30), capacity, seatStart, seatEnd, + building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity, roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard', status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300) }; @@ -485,6 +505,180 @@ function logAction(db, user, action, detail) { return log; } +const excelResourceNames = { + classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', + account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账' +}; + +function excelRowsForResource(db, user, resource, searchParams) { + const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId); + if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({ + schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用' + })); + if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({ + schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', + className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName, + username: item.username, initialPassword: '', status: item.active ? '启用' : '停用' + })); + if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 })); + if (resource === 'account_results') { + const batchId = cleanText(searchParams.get('batchId'), 64); + const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId)); + if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 }); + return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({ + batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword + })); + } + if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({ + candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender, + idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email, + nativePlace: profile.nativePlace, address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade, + ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone + })); + if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => { + const rooms = db.testRooms.filter(room => room.centerId === center.id); + return (rooms.length ? rooms : [{}]).map(room => ({ + schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name, + address: center.address, managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact, + emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport, + centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes, + roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '', + seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '', + roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || '' + })); + }); + if (resource === 'results') { + const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); + return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { + const registration = db.registrations.find(item => item.id === result.registrationId); + const exam = db.exams.find(item => item.id === registration?.examId); + return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: exam?.subjects.find(item => item.id === result.subjectId)?.name || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' }; + }); + } + return []; +} + +function excelImportError(row, message) { + return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 }); +} + +async function importExcelResource(db, user, resource, rows) { + if (resource === 'classes') { + if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 }); + for (const row of rows) { + const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); + if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内'); + const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60); + if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空'); + const existing = db.classes.find(item => item.schoolId === school.id && item.name === name); + const schoolClass = existing || { id: uid('class'), schoolId: school.id }; + Object.assign(schoolClass, { name, grade, active: row.status !== '停用' }); + await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`)); + if (!existing) db.classes.push(schoolClass); + } + return { count: rows.length }; + } + if (resource === 'class_admins') { + if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 }); + for (const row of rows) { + const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); + const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100)); + if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效'); + const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || ''); + if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空'); + const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase()); + if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用'); + if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位'); + if (existing) { + Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' }); + if (password) existing.passwordHash = hashPassword(password); + await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`)); + } else { + const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() }; + await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`)); + db.users.push(created); + } + } + return { count: rows.length }; + } + if (resource === 'account_quotas') { + if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 }); + const quotas = rows.filter(row => Number(row.count) > 0).map(row => { + const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active); + if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间'); + return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) }; + }); + if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 }); + return { count: quotas.length, quotas }; + } + if (resource === 'candidates') { + if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 }); + for (const row of rows) { + const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); + const profile = db.candidateProfiles.find(item => item.userId === account?.id); + if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内'); + if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程'); + const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100)); + if (!schoolClass) throw excelImportError(row, '班级名称无效'); + const required = ['name', 'gender', 'idNumber', 'phone']; + if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填'); + Object.assign(profile, { + name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30), + email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), address: cleanText(row.address, 200), classId: schoolClass.id, + grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20), + guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso() + }); + const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); + await database.updateCandidateProfile(profile, profile.name, instance, action); + } + return { count: rows.length }; + } + if (resource === 'centers') { + if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 }); + const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase()); + for (const [centerCode, centerRows] of groups) { + const first = centerRows[0]; + const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId)); + if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效'); + const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode); + if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校'); + if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更'); + const body = { + schoolId: school.id, code: centerCode, name: first.centerName, address: first.address, managerName: first.managerName, + managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime, + transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes, + rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan, + roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType, + status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes })) + }; + const parsed = parseCenterChange(db, body, school.id, existing || null); + const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; + const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id); + await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`)); + } + return { count: groups.size }; + } + if (resource === 'results') { + if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 }); + for (const row of rows) { + const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); + const exam = db.exams.find(item => item.code === cleanText(row.examCode, 60)); + const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved'); + const subject = exam?.subjects.find(item => item.name === cleanText(row.subjectName, 50)); + const score = Number(row.score); + if (!registration || !subject || !registration.subjectIds.includes(subject.id) || !Number.isFinite(score) || score < 0 || score > 150) throw excelImportError(row, '报名号、考试、科目或成绩无效'); + let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id); + const isNew = !result; + if (!result) result = { id: uid('result'), registrationId: registration.id, subjectId: subject.id }; + Object.assign(result, { score, grade: cleanText(row.grade, 10) || (score >= 120 ? 'A' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null }); + await database.saveResult(result, isNew, logAction(db, user, 'Excel 导入成绩', `${row.candidateNumber} · ${exam.name} · ${subject.name}`)); + if (isNew) db.results.push(result); + } + return { count: rows.length }; + } + throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 }); +} + function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } @@ -688,9 +882,66 @@ async function handleAdmin(request, response, pathname) { }); } + const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|centers|results)$/); + if (excelMatch && request.method === 'GET') { + const resource = excelMatch[1]; + if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在'); + if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据'); + if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场'); + if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料'); + if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩'); + const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); + const template = requestUrl.searchParams.get('template') === '1'; + const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams); + const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user); + const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle })); + return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`); + } + if (excelMatch && request.method === 'POST') { + const resource = excelMatch[1]; + if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出'); + const rows = await parseWorkbook(resource, await readBodyBuffer(request)); + const result = await importExcelResource(db, user, resource, rows); + return sendJson(response, 200, { ok: true, ...result }); + } + + if (pathname === '/api/admin/school-organization' && request.method === 'GET') { + if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织'); + const school = db.schools.find(item => item.id === user.schoolId); + const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({ + ...item, + candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length, + admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active })) + })); + return sendJson(response, 200, { ok: true, school, classes }); + } + if (pathname === '/api/admin/classes' && request.method === 'POST') { + if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级'); + const body = await readJson(request); + const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60); + if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空'); + if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级'); + const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false }; + await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`)); + return sendJson(response, 201, { ok: true, schoolClass }); + } + const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/); + if (classMatch && request.method === 'PATCH') { + if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级'); + const body = await readJson(request); + const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId); + if (!schoolClass) return sendError(response, 404, '班级不存在'); + const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60); + if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空'); + if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级'); + Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) }); + await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`)); + return sendJson(response, 200, { ok: true, schoolClass }); + } + if (pathname === '/api/admin/admins' && request.method === 'GET') { - if (!requirePermission(user, response, '*')) return true; - const admins = db.users.filter(item => item.role === 'admin').map(item => ({ + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员'); + const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).map(item => ({ ...safeUser(item), active: item.active, levelName: adminLevelNames[item.adminLevel], @@ -700,15 +951,15 @@ async function handleAdmin(request, response, pathname) { return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled }); } if (pathname === '/api/admin/admins' && request.method === 'POST') { - if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const username = cleanText(body.username, 50); const password = String(body.password || ''); const displayName = cleanText(body.displayName, 50); - const adminLevel = cleanText(body.adminLevel, 20); + const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20); + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员'); if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码'); if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在'); - const schoolId = adminLevel === 'super' ? null : cleanText(body.schoolId, 64); + const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64); const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null; if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校'); if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级'); @@ -717,6 +968,21 @@ async function handleAdmin(request, response, pathname) { await database.createAdmin(created, log); return sendJson(response, 201, { ok: true, admin: safeUser(created) }); } + const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/); + if (adminMatch && request.method === 'PATCH') { + if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员'); + const body = await readJson(request); + const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId); + if (!target) return sendError(response, 404, '班级管理员不存在'); + const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId); + if (!schoolClass) return sendError(response, 400, '请选择本校有效班级'); + const password = String(body.password || ''); + if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位'); + Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) }); + if (password) target.passwordHash = hashPassword(password); + await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`)); + return sendJson(response, 200, { ok: true, admin: safeUser(target) }); + } if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') { if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); diff --git a/styles.css b/styles.css index 3259e61..e8d5591 100644 --- a/styles.css +++ b/styles.css @@ -14,6 +14,40 @@ --radius: 16px; } +/* School organization and Excel operations */ +.excel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin-bottom: 12px; padding: 14px 18px; border: 1px solid #dbe5ea; border-left: 4px solid #2c7b82; border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgba(23,63,96,.05); } +.excel-toolbar > span strong, .excel-toolbar > span small { display: block; } +.excel-toolbar > span strong { color: #173f60; } +.excel-toolbar > span small { margin-top: 3px; color: #788b97; font-size: 11px; } +.excel-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; } +.school-org-banner { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 22px; padding: 28px 30px; color: #fff; background: linear-gradient(118deg, #142f4c, #1c526b 70%, #2a7c7c); border-radius: 12px; box-shadow: 0 18px 42px rgba(20,47,76,.16); } +.school-org-banner > div > span { color: #8ed8d4; font-size: 11px; font-weight: 800; letter-spacing: .16em; } +.school-org-banner h2 { margin: 6px 0; font-family: "STKaiti", "KaiTi", serif; font-size: 28px; } +.school-org-banner p { max-width: 620px; margin: 0; color: #c7dce4; line-height: 1.65; } +.school-org-banner dl { display: flex; gap: 26px; margin: 0; } +.school-org-banner dl div { min-width: 74px; padding-left: 18px; border-left: 1px solid rgba(255,255,255,.22); } +.school-org-banner dt { color: #bdd3dc; font-size: 11px; } +.school-org-banner dd { margin: 4px 0 0; font: 700 27px Consolas, monospace; } +.org-class-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 24px; } +.org-class-card { padding: 0; overflow: hidden; } +.org-class-card.inactive { opacity: .72; } +.org-class-card > header { display: flex; align-items: flex-start; justify-content: space-between; padding: 22px 24px 14px; } +.org-class-card > header span:first-child { color: #2c7b82; font-size: 11px; font-weight: 800; letter-spacing: .1em; } +.org-class-card h2 { margin: 4px 0 0; color: #173b55; font-family: "STKaiti", "KaiTi", serif; font-size: 23px; } +.org-class-metrics { display: flex; gap: 1px; background: #dfe7eb; } +.org-class-metrics span { flex: 1; padding: 13px 24px; background: #f7fafb; } +.org-class-metrics strong, .org-class-metrics small { display: block; } +.org-class-metrics strong { color: #1b5265; font-size: 20px; } +.org-class-metrics small { margin-top: 2px; color: #80909a; } +.org-class-card > section { padding: 18px 24px; } +.org-admin-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.org-admin-head > strong { color: #365266; font-size: 13px; } +.org-admin-row { width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 9px 0; border: 0; border-top: 1px solid #e7edef; background: transparent; text-align: left; } +.org-admin-row > span:nth-child(2) strong, .org-admin-row > span:nth-child(2) small { display: block; } +.org-admin-row > span:nth-child(2) small { margin-top: 2px; color: #81909a; font-size: 10px; } +.org-empty { margin: 10px 0 0; color: #8a98a1; font-size: 12px; } +.org-class-card > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 24px; border-top: 1px solid #e5ecef; background: #f8fafb; } + /* School account batch issuance */ .batch-quota-panel { overflow: hidden; padding: 0; } .batch-quota-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 28px; padding: 30px 32px 24px; color: #fff; background: linear-gradient(120deg, #102944, #173f60 72%, #276e82); } @@ -75,6 +109,9 @@ .flow-batch-snapshot > p { margin: 0; } @media (max-width: 760px) { + .excel-toolbar, .school-org-banner { align-items: stretch; flex-direction: column; } + .school-org-banner dl { justify-content: space-between; } + .org-class-grid { grid-template-columns: 1fr; } .batch-quota-head, .batch-submit-bar, .ledger-title { align-items: stretch; flex-direction: column; } .batch-quota-head > div:last-child { padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.2); border-left: 0; } .quota-grid { grid-template-columns: 1fr; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index e7bfbec..50e6aba 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -2,7 +2,9 @@ import { spawn } from 'node:child_process'; import { readFile, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import assert from 'node:assert/strict'; +import ExcelJS from 'exceljs'; import { relationalTables } from '../database.mjs'; +import { buildWorkbook } from '../excel.mjs'; const root = resolve(process.cwd()); const port = 4182; @@ -36,15 +38,16 @@ function createClient() { let cookie = ''; return { async request(path, options = {}) { + const binaryBody = Buffer.isBuffer(options.body) || options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body); const response = await fetch(`${baseUrl}${path}`, { ...options, - headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, - body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body + headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body }); const setCookie = response.headers.get('set-cookie'); if (setCookie) cookie = setCookie.split(';')[0]; const type = response.headers.get('content-type') || ''; - const data = type.includes('application/json') ? await response.json() : await response.text(); + const data = type.includes('application/json') ? await response.json() : type.includes('spreadsheetml') ? Buffer.from(await response.arrayBuffer()) : await response.text(); return { response, data }; } }; @@ -143,7 +146,7 @@ try { const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: { code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678', managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案', - rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }] + rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatPlan: '按现场座次表编排', roomType: 'standard', status: 'active', notes: '' }] } }); assert.equal(newCenter.response.status, 202, '新增考点应创建审批申请而不是直接落库'); assert.equal(newCenter.data.changeRequest.schoolId, 'school_hz1', '校级管理员新增考点必须自动归属本校'); @@ -158,7 +161,7 @@ try { const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: { ...createdCenter, managerName: '变更后负责人', rooms: [ ...createdCenter.rooms, - { code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatStart: 31, seatEnd: 55, roomType: 'accessible', status: 'active', notes: '无障碍通道' } + { code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatPlan: '无障碍座位现场标记', roomType: 'accessible', status: 'active', notes: '无障碍通道' } ] } }); assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批'); @@ -170,6 +173,55 @@ try { assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换'); assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点'); + const organization = await schoolAdmin.request('/api/admin/school-organization'); + assert.equal(organization.response.status, 200, '校级管理员应有本校组织管理入口'); + assert.ok(organization.data.classes.every(item => item.schoolId === 'school_hz1'), '组织页只能返回本校班级'); + const createClass = await schoolAdmin.request('/api/admin/classes', { method: 'POST', body: { grade: '高二', name: '高二(9)班', active: true } }); + assert.equal(createClass.response.status, 201, '校级管理员应能新增本校班级'); + const newClassId = createClass.data.schoolClass.id; + const createClassAdmin = await schoolAdmin.request('/api/admin/admins', { method: 'POST', body: { displayName: '批量测试班管', username: 'class_excel_test', password: 'ClassExcel123!', classId: newClassId } }); + assert.equal(createClassAdmin.response.status, 201, '校级管理员应能创建并绑定本校班级管理员'); + assert.equal(createClassAdmin.data.admin.adminLevel, 'class'); + assert.equal(createClassAdmin.data.admin.schoolId, 'school_hz1'); + assert.equal((await schoolAdmin.request(`/api/admin/admins/${createClassAdmin.data.admin.id}`, { method: 'PATCH', body: { classId: 'class_hz1_301', active: false } })).response.status, 200, '校级管理员应能调整或停用本校班级管理员'); + assert.equal((await admin.request('/api/admin/classes', { method: 'POST', body: { grade: '越权', name: '越权班' } })).response.status, 403, '超级管理员不应代替学校维护班级'); + + const classTemplate = await schoolAdmin.request('/api/admin/excel/classes?template=1'); + assert.equal(classTemplate.response.status, 200, '班级 Excel 模板应可下载'); + assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/); + const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data); + assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头'); + const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }])); + const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile }); + assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级'); + assert.equal(classImport.data.count, 1); + const quotaFile = Buffer.from(await buildWorkbook('account_quotas', [{ className: '高三(1)班', count: 12 }, { className: '高三(2)班', count: 8 }])); + const quotaImport = await schoolAdmin.request('/api/admin/excel/account_quotas', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: quotaFile }); + assert.deepEqual(quotaImport.data.quotas.map(item => item.count), [12, 8], '报名号配额 Excel 应解析并回填各班数量'); + const centerTemplate = await schoolAdmin.request('/api/admin/excel/centers?template=1'); + const centerWorkbook = new ExcelJS.Workbook(); await centerWorkbook.xlsx.load(centerTemplate.data); + const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String); + assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明'); + assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号'); + assert.equal(centerWorkbook.worksheets[0].getCell('M3').value, '001', '考场代码应保留前导零'); + assert.equal(centerWorkbook.worksheets[0].getCell('M3').numFmt, '@', '标识号列应固定为文本格式'); + const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }])); + const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile }); + assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入'); + const centerImportFile = Buffer.from(await buildWorkbook('centers', [{ + schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', address: '海州市表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试', + roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: '' + }])); + const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile }); + assert.equal(centerImport.response.status, 200, '考点考场 Excel 导入应创建审批申请'); + assert.equal(centerImport.data.count, 1); + const excelPendingCenter = (await schoolAdmin.request('/api/admin/centers')).data.changeRequests.find(item => item.code === 'HZ01-XLSX'); + assert.ok(excelPendingCenter && excelPendingCenter.status === 'pending', 'Excel 导入的考点必须进入审批而非直接生效'); + assert.equal(excelPendingCenter.rooms[0].seatPlan, '按现场桌贴编排'); + assert.equal((await schoolAdmin.request('/api/admin/excel/candidates')).response.status, 200, '考生资料应支持按学校范围导出 Excel'); + assert.equal((await schoolAdmin.request('/api/admin/excel/results')).response.status, 200, '成绩台账应支持按学校范围导出 Excel'); + assert.equal((await classAdmin.request('/api/admin/excel/centers')).response.status, 403, '班级管理员不得通过 Excel 接口越权导出考场'); + const numberRules = await admin.request('/api/admin/number-rules'); assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则'); const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: { @@ -359,6 +411,7 @@ try { console.log('✓ 审批流程设计、同级转交与超级管理员监督退回'); console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); + console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); console.log('✓ 成绩录入、发布与考生查询'); } finally { server.kill('SIGTERM');