From 4fa001cecef77f4350d97b8183c64aa46d8bbf0c Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 09:02:30 +0800 Subject: [PATCH] Implement batch candidate account requests and approval-based number eal --- LICENSE.txt => LICENSE | 0 README.md | 14 ++- app.js | 86 ++++++++----- database.mjs | 272 +++++++++++++++++++++++++++++++++++------ server.mjs | 209 +++++++++++++++++++------------ styles.css | 68 +++++++++++ tests/system.test.mjs | 64 +++++++--- 7 files changed, 549 insertions(+), 164 deletions(-) rename LICENSE.txt => LICENSE (100%) diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/README.md b/README.md index 4041459..935bce9 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ### 考生中心 - 报名号即考生账户,同一考生参加不同考试始终使用同一个号码 -- 学校管理员创建考生账户并下发报名号、初始密码 +- 学校管理员按一个或多个班级填写人数并提交批量申领(单人也按 1 人批次走审批) - 首次登录强制修改初始密码,完成后才能补全个人信息 - 完整维护姓名、性别、证件号码、籍贯、出生日期、民族、家庭住址、手机号、邮箱、学校、班级、监护人和紧急联系人 - 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号 @@ -29,13 +29,14 @@ - 超级、校级、班级三级管理员,同一级支持多个账号 - 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 -- 校级管理员只管理本校考生和报名流程,并提交本校考点、考场档案变更 +- 校级管理员只管理本校考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 - 班级管理员只读查看本班考生、成绩和报名状态 -- 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程 +- 考生信息修改、考试报名、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 - 当前处理人可将流程转交给同范围的同级管理员 - 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 - 报名号在创建考生账户时只生成一次,后续考试报名自动复用 -- 按考试、学校筛选,为旧数据批量同步考生账户的固定报名号 +- 超级管理员只维护号码规则;校级批量申请最终批准后,系统原子生成固定报名号、随机初始密码和待补录账户 +- 批次结果按班级返回校级管理员,并可导出 CSV 安全下发 - 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态 - 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案 - 考务指标与审计日志 @@ -72,7 +73,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v4,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、结构化考场和变更审批结构。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v5,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、结构化考场、批量建号申请与审批结果结构。 ## 数据库配置 @@ -106,6 +107,7 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex - `results`、`notices`、`audit_logs` - `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms` - `number_rules`、`number_rule_segments` +- `candidate_account_batches`、`candidate_account_batch_items` - `workflow_definitions`、`workflow_steps`、`workflow_instances`、`workflow_actions` - `organization`、`schema_metadata` @@ -142,7 +144,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、报名号规则与旧记录批量同步、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。 ## 项目结构 diff --git a/app.js b/app.js index f0a796f..a8ece20 100644 --- a/app.js +++ b/app.js @@ -150,7 +150,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], core[1], core[2], ...operations, core[3]]; + if (level === 'school') return [core[0], ['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]]; } @@ -165,7 +165,6 @@ function portalHeadingAction(role, page) { if (role === 'admin' && page === 'notices') return ``; if (role === 'admin' && page === 'exams') return ``; if (role === 'admin' && page === 'admins') return ``; - if (role === 'admin' && page === 'candidates' && state.user.adminLevel !== 'class') return ``; if (role === 'admin' && page === 'centers') return ``; if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; return ''; @@ -274,21 +273,22 @@ async function renderAdmin(page) { results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], + 'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'], flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], - 'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'], - 'number-rules': ['报名号规则', '设计新账户号码组成,并为旧考试记录批量同步固定报名号。'] + 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], + 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'] }; const allowedPages = adminNavForUser().map(item => item[0]); 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; + const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : 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), + admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), 'account-batches': () => adminAccountBatches(data), 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); @@ -345,16 +345,26 @@ const numberSegmentMeta = { sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] }; +function adminAccountBatches(data) { + const classes = data.classes || []; + 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)}
` : ''; + return ``; + }).join(''); + return `${form}`; +} + function adminNumberRules(data) { const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] }; const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); const types = Object.keys(numberSegmentMeta); - const candidates = data.batchCandidates || []; - return `

账户报名号组成

规则用于新建考生账户;流水号为必选字段。

当前规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
LEGACY SYNCHRONIZATION

批量同步账户报名号

用于旧数据或导入记录:缺号的考试报名会复制考生账户的固定报名号,不会为每场考试另建号码。

待同步${candidates.length}
${candidates.slice(0, 8).map(item => `${h(item.candidateName)}${h(item.schoolName)} · ${h(item.examName)}`).join('') || '

当前所有考试报名都已关联固定账户报名号。

'}${candidates.length > 8 ? `另有 ${candidates.length - 8} 条` : ''}
`; + return `

账户报名号组成

规则用于最终审批后的批量建号;流水号为必选字段。

当前规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
`; } function adminFlowDesign(workflows) { - const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE' }; + const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH' }; return `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

${workflow.steps.map(step => workflowStepEditor(step)).join('')}
`).join('')}
`; } @@ -364,8 +374,14 @@ function workflowStepEditor(step = {}) { function adminFlows(data) { const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' }; - const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更' }; - return `
${data.instances.map(instance => { const title = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName; const sub = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}
`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名或考点档案提交后,流程会显示在这里。')}
`; + const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领' }; + return `
${data.instances.map(instance => { + const isCenter = instance.businessType === 'center_change'; + const isBatch = instance.businessType === 'candidate_account_batch'; + const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName; + const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; + return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}
`; + }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`; } function emptyState(title, description, route, action) { @@ -450,7 +466,6 @@ 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-candidate-account') return openCandidateAccountForm(); 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') { @@ -476,9 +491,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 === 'generate-registration-number') { - await api(`/api/admin/registrations/${target.dataset.id}/registration-number`, { method: 'POST' }); - toast('账户报名号已同步', '考试记录已关联考生的固定号码'); return renderRoute(); + 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 === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); @@ -581,9 +603,12 @@ document.addEventListener('submit', async event => { const body = formObject(form); await api('/api/admin/admins', { method: 'POST', body }); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); - } else if (kind === 'candidate-account') { - const data = await api('/api/admin/candidate-accounts', { method: 'POST', body: formObject(form) }); - closeModal(); toast('考生账户已创建', `报名号:${data.candidate.candidateNumber}`); 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); + if (!total) throw new Error('请至少为一个班级填写申领数量'); + await api('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas } }); + toast('批量申领已提交', `${total} 个账户将在最终批准后统一生成`); renderRoute(); } else if (kind === 'self-registration-setting') { const enabled = form.enabled.value === 'true'; await api('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled } }); @@ -614,9 +639,6 @@ document.addEventListener('submit', async event => { })).sort((a, b) => a.position - b.position); await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } }); toast('报名号规则已启用', '后续创建的考生账户将按此规则生成固定号码'); renderRoute(); - } else if (kind === 'batch-registration-numbers') { - const result = await api('/api/admin/registration-numbers/batch', { method: 'POST', body: formObject(form) }); - toast('报名号同步完成', `已为 ${result.count} 条考试报名关联固定账户号码`); renderRoute(); } else if (kind === 'workflow-design') { const names = [...form.querySelectorAll('[name="stepName"]')]; const levels = [...form.querySelectorAll('[name="stepLevel"]')]; @@ -629,7 +651,9 @@ document.addEventListener('submit', async event => { ? `/api/admin/candidates/${body.businessId}` : body.businessType === 'registration_review' ? `/api/admin/registrations/${body.businessId}` - : `/api/admin/center-change-requests/${body.businessId}`; + : body.businessType === 'center_change' + ? `/api/admin/center-change-requests/${body.businessId}` + : `/api/admin/candidate-account-batches/${body.businessId}`; await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote } }); closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute(); } else if (kind === 'flow-transfer') { @@ -664,11 +688,6 @@ function openAdminForm() { setModal(``); } -function openCandidateAccountForm() { - const { schools = [], classes = [] } = state.pageData; - setModal(``); -} - function centerRoomEditor(room = {}) { return `
结构化考场
`; } @@ -685,11 +704,16 @@ function openFlowDetail(id) { const available = state.pageData.availableAdmins.filter(item => item.adminLevel === currentLevel && (currentLevel === 'super' || item.schoolId === instance.assignee?.schoolId)); const canProcess = instance.status === 'pending' && (state.user.adminLevel === 'super' || instance.assignee?.id === state.user.id); const history = instance.actions.map(action => `
${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}
`).join(''); - const subject = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName; - const subjectDetail = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; + const isCenter = instance.businessType === 'center_change'; + const isBatch = instance.businessType === 'candidate_account_batch'; + const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length; + 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('')}
` : ''; - setModal(`${changeSnapshot}
${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}

流程轨迹

${history || '

暂无操作

'}
${canProcess ? `` : '
当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`); + 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 ? `` : ''}`); } function openCandidateReview(id) { @@ -701,7 +725,7 @@ function openCandidateReview(id) { function openRegistrationReview(id) { const reg = state.pageData.registrations.find(item => item.id === id); const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending'; - setModal(`
报考科目

${reg.subjects.map(subject => `${h(subject.name)}`).join('')}

账户报名号
${h(reg.registrationNumber || '待同步账户号码')}
当前步骤
${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
${canReview ? `` : ``}`); + setModal(`
报考科目

${reg.subjects.map(subject => `${h(subject.name)}`).join('')}

账户报名号
${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}
当前步骤
${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
${canReview ? `` : ''}`); } function openNoticeForm() { diff --git a/database.mjs b/database.mjs index 4bba987..ba06599 100644 --- a/database.mjs +++ b/database.mjs @@ -21,6 +21,8 @@ export const relationalTables = [ 'center_change_rooms', 'number_rules', 'number_rule_segments', + 'candidate_account_batches', + 'candidate_account_batch_items', 'workflow_definitions', 'workflow_steps', 'workflow_instances', @@ -284,9 +286,31 @@ const sqliteSchema = ` UNIQUE (rule_id, position) ) STRICT; + CREATE TABLE IF NOT EXISTS candidate_account_batches ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE, + class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + candidate_number TEXT UNIQUE, + initial_password TEXT, + user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT, + UNIQUE (batch_id, position) + ) STRICT; + CREATE TABLE IF NOT EXISTS workflow_definitions ( id TEXT PRIMARY KEY, - business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')), + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, @@ -345,6 +369,8 @@ const sqliteSchema = ` CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code); CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code); CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at); + CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at); + CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position); CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); `; @@ -647,9 +673,40 @@ const mysqlSchema = [ UNIQUE KEY uq_rule_segments_position (rule_id, position), CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_account_batches ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + requested_by VARCHAR(64) NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_account_batches_school (school_id, status, created_at), + CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, + CONSTRAINT fk_account_batches_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( + id VARCHAR(64) NOT NULL, + batch_id VARCHAR(64) NOT NULL, + class_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + candidate_number VARCHAR(120) NULL, + initial_password VARCHAR(120) NULL, + user_id VARCHAR(64) NULL, + created_at VARCHAR(35) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_account_batch_position (batch_id, position), + UNIQUE KEY uq_account_batch_number (candidate_number), + UNIQUE KEY uq_account_batch_user (user_id), + KEY idx_account_batch_items (batch_id, class_id, position), + CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE, + CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id), + CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS workflow_definitions ( id VARCHAR(64) NOT NULL, - business_type ENUM('profile_change', 'registration_review', 'center_change') NOT NULL, + business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL, name VARCHAR(120) NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, updated_by VARCHAR(64) NULL, @@ -715,7 +772,8 @@ function validateState(state, source = '数据库') { const collections = [ 'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', - 'numberRules', 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' + 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', + 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' ]; if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { throw new Error(`${source}中的应用数据格式无效`); @@ -730,7 +788,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 4, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 5, 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() ); @@ -904,6 +962,26 @@ function buildSeedOperations(state) { )); } + for (const batch of state.candidateAccountBatches) { + add( + `INSERT INTO candidate_account_batches ( + id, school_id, requested_by, status, review_note, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + batch.id, batch.schoolId, nullable(batch.requestedBy), batch.status, nullable(batch.reviewNote), + batch.createdAt, nullable(batch.reviewedAt) + ); + } + + for (const item of state.candidateAccountBatchItems) { + add( + `INSERT INTO candidate_account_batch_items ( + id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + item.id, item.batchId, item.classId, Number(item.position), nullable(item.candidateNumber), + nullable(item.initialPassword), nullable(item.userId), nullable(item.createdAt) + ); + } + for (const workflow of state.workflows) { add( `INSERT INTO workflow_definitions ( @@ -1199,6 +1277,25 @@ function stateFromRows(rows) { updatedAt: row.updated_at, segments: ruleSegments.get(row.id) || [] })), + candidateAccountBatches: rows.candidateAccountBatches.map(row => ({ + id: row.id, + schoolId: row.school_id, + requestedBy: row.requested_by, + status: row.status, + reviewNote: row.review_note || '', + createdAt: row.created_at, + reviewedAt: row.reviewed_at + })), + candidateAccountBatchItems: rows.candidateAccountBatchItems.map(row => ({ + id: row.id, + batchId: row.batch_id, + classId: row.class_id, + position: Number(row.position), + candidateNumber: row.candidate_number || '', + initialPassword: row.initial_password || '', + userId: row.user_id, + createdAt: row.created_at + })), workflows: rows.workflows.map(row => ({ id: row.id, businessType: row.business_type, @@ -1261,6 +1358,8 @@ function readSqliteRows(connection) { centerChangeRooms: connection.prepare('SELECT * FROM center_change_rooms ORDER BY request_id, code, id').all(), numberRules: connection.prepare('SELECT * FROM number_rules ORDER BY updated_at DESC, id').all(), numberRuleSegments: connection.prepare('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id').all(), + candidateAccountBatches: connection.prepare('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id').all(), + candidateAccountBatchItems: connection.prepare('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id').all(), workflows: connection.prepare('SELECT * FROM workflow_definitions ORDER BY business_type, id').all(), workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(), workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(), @@ -1292,6 +1391,8 @@ async function readMysqlRows(connection) { centerChangeRooms: await query('SELECT * FROM center_change_rooms ORDER BY request_id, code, id'), numberRules: await query('SELECT * FROM number_rules ORDER BY updated_at DESC, id'), numberRuleSegments: await query('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id'), + candidateAccountBatches: await query('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id'), + candidateAccountBatchItems: await query('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id'), workflows: await query('SELECT * FROM workflow_definitions ORDER BY business_type, id'), workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'), workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'), @@ -1374,6 +1475,26 @@ function createRepository({ client, location, read, transaction, close }) { if (log) operations.push(auditOperation(log)); await transaction(operations); }, + async createCandidateAccountBatch(batch, items, instance, action, log) { + const operations = [ + operation( + `INSERT INTO candidate_account_batches ( + id, school_id, requested_by, status, review_note, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + batch.id, batch.schoolId, optional(batch.requestedBy), batch.status, optional(batch.reviewNote), + batch.createdAt, optional(batch.reviewedAt) + ) + ]; + for (const item of items) operations.push(operation( + `INSERT INTO candidate_account_batch_items ( + id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + item.id, item.batchId, item.classId, Number(item.position), optional(item.candidateNumber), + optional(item.initialPassword), optional(item.userId), optional(item.createdAt) + )); + operations.push(...workflowCreateOperations(instance, action), auditOperation(log)); + await transaction(operations); + }, async updateCandidateProfile(profile, displayName, instance, action) { const operations = [ operation( @@ -1472,16 +1593,71 @@ function createRepository({ client, location, read, transaction, close }) { business.status, business.paymentStatus, optional(business.reviewedAt), optional(business.reviewNote), optional(business.registrationNumber), optional(business.numberRuleId), business.id )); - } else { + } else if (instance.businessType === 'center_change') { operations.push(operation( `UPDATE center_change_requests SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id )); + } else if (instance.businessType === 'candidate_account_batch') { + operations.push(operation( + `UPDATE candidate_account_batches SET + status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id + )); } if (log) operations.push(auditOperation(log)); await transaction(operations); }, + async completeCandidateAccountBatch(batch, items, users, profiles, instance, action, log) { + const operations = [ + operation( + `UPDATE workflow_instances SET + status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, + instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id + ), + workflowActionOperation(action), + operation( + `UPDATE candidate_account_batches SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + batch.status, optional(batch.reviewNote), optional(batch.reviewedAt), batch.id + ) + ]; + for (let index = 0; index < users.length; index += 1) { + const user = users[index]; + const profile = profiles[index]; + const item = items[index]; + operations.push( + operation( + `INSERT INTO users ( + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel), + user.schoolId, user.classId, 1, 1, user.displayName, user.createdAt + ), + operation( + `INSERT INTO candidate_profiles ( + id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, + emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, + guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, + optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId, + optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone), + optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote), + optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt + ), + operation( + `UPDATE candidate_account_batch_items SET + candidate_number = ?, initial_password = ?, user_id = ?, created_at = ? WHERE id = ?`, + item.candidateNumber, item.initialPassword, item.userId, item.createdAt, item.id + ) + ); + } + operations.push(auditOperation(log)); + await transaction(operations); + }, async transferWorkflow(instance, action, log) { const operations = [ operation('UPDATE workflow_instances SET assignee_id = ? WHERE id = ?', optional(instance.assigneeId), instance.id), @@ -1642,28 +1818,6 @@ function createRepository({ client, location, read, transaction, close }) { auditOperation(log) ]); }, - async assignRegistrationNumber(registration, log) { - await transaction([ - operation( - 'UPDATE registrations SET registration_number = ?, number_rule_id = ? WHERE id = ?', - registration.registrationNumber, optional(registration.numberRuleId), registration.id - ), - auditOperation(log) - ]); - }, - async assignCandidateNumbers(users, registrations, log) { - const operations = users.map(user => operation( - 'UPDATE users SET candidate_number = ? WHERE id = ? AND (candidate_number IS NULL OR candidate_number = \'\')', - user.candidateNumber, user.id - )); - operations.push(...registrations.map(registration => operation( - `UPDATE registrations SET registration_number = ?, number_rule_id = ? - WHERE id = ? AND (registration_number IS NULL OR registration_number = '')`, - registration.registrationNumber, optional(registration.numberRuleId), registration.id - ))); - operations.push(auditOperation(log)); - await transaction(operations); - }, async createExam(exam, log) { const operations = [operation( `INSERT INTO exams ( @@ -1781,23 +1935,23 @@ async function createSqliteStore({ path, seed }) { ]); 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('center_change')) { + if (!definitionSql.includes('candidate_account_batch')) { connection.exec(` PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE; - CREATE TABLE workflow_definitions_v3 ( + CREATE TABLE workflow_definitions_v5 ( id TEXT PRIMARY KEY, - business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')), + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, UNIQUE (business_type, active) ) STRICT; - INSERT INTO workflow_definitions_v3 (id, business_type, name, active, updated_by, updated_at) + INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at) SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions; DROP TABLE workflow_definitions; - ALTER TABLE workflow_definitions_v3 RENAME TO workflow_definitions; + ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions; COMMIT; PRAGMA foreign_keys = ON; `); @@ -1958,13 +2112,35 @@ async function createSqliteStore({ path, seed }) { } } + if (existingSystem && Number(existingSystem.app_version || 1) < 5) { + const extension = seed(); + const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); + connection.exec('BEGIN IMMEDIATE'); + try { + if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt); + for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); + } + connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + 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, 4, ?, ?, ?) + VALUES (1, 5, ?, ?, ?) `).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'); @@ -2046,7 +2222,7 @@ 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 workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change') NOT 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); const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); @@ -2193,6 +2369,32 @@ async function createMysqlStore({ seed }) { connection.release(); } } + if (Number(metadataRows[0]?.app_version || 1) < 5) { + const extension = seed(); + const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1"); + if (batchWorkflow && !batchWorkflowRows.length) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt] + ); + for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } } if (!existing.length) { const initialState = seed(); @@ -2201,7 +2403,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, 4, ?, ?, ?) + VALUES (1, 5, ?, ?, ?) `, [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/server.mjs b/server.mjs index eb789c1..23be2a1 100644 --- a/server.mjs +++ b/server.mjs @@ -45,7 +45,7 @@ function seedDatabase() { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 4, createdAt: nowIso() }, + meta: { version: 5, createdAt: nowIso() }, settings: { selfRegistrationEnabled: false }, organization: { name: '海州市教育考试中心', @@ -112,7 +112,7 @@ function seedDatabase() { registrations: [ { id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'], - status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '', numberRuleId: null, + status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' } } ], @@ -133,6 +133,8 @@ function seedDatabase() { ], centerChangeRequests: [], centerChangeRooms: [], + candidateAccountBatches: [], + candidateAccountBatchItems: [], numberRules: [ { id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [ { id: 'segment_year', position: 1, type: 'year', value: '', width: 4 }, @@ -152,6 +154,9 @@ function seedDatabase() { ] }, { id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ { id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' } + ] }, + { id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' } ] } ], workflowInstances: [], @@ -365,8 +370,37 @@ function workflowScopeProfile(db, instance) { const registration = db.registrations.find(item => item.id === instance.businessId); return db.candidateProfiles.find(item => item.userId === registration?.userId) || null; } - const change = db.centerChangeRequests.find(item => item.id === instance.businessId); - return change ? centerScopeProfile(db, change.schoolId) : null; + if (instance.businessType === 'center_change') { + const change = db.centerChangeRequests.find(item => item.id === instance.businessId); + return change ? centerScopeProfile(db, change.schoolId) : null; + } + if (instance.businessType === 'candidate_account_batch') { + const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId); + return batch ? centerScopeProfile(db, batch.schoolId) : null; + } + return null; +} + +function candidateAccountBatchView(db, batch) { + const items = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position); + const quotaMap = new Map(); + for (const item of items) quotaMap.set(item.classId, (quotaMap.get(item.classId) || 0) + 1); + const instance = db.workflowInstances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batch.id); + return { + ...batch, + schoolName: db.schools.find(item => item.id === batch.schoolId)?.name || '', + requesterName: db.users.find(item => item.id === batch.requestedBy)?.displayName || '原提交人', + totalCount: items.length, + quotas: [...quotaMap.entries()].map(([classId, count]) => { + const schoolClass = db.classes.find(item => item.id === classId); + return { classId, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '', count }; + }), + items: items.map(item => { + const schoolClass = db.classes.find(entry => entry.id === item.classId); + return { ...item, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '' }; + }), + workflow: workflowView(db, instance) + }; } function centerChangeView(db, change) { @@ -691,24 +725,91 @@ async function handleAdmin(request, response, pathname) { await database.updateRegistrationSetting(enabled, log); return sendJson(response, 200, { ok: true, enabled }); } - if (pathname === '/api/admin/candidate-accounts' && request.method === 'POST') { + if (pathname === '/api/admin/candidate-account-batches' && request.method === 'GET') { + if (!requirePermission(user, response, 'candidates.write')) return true; + if (!['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '只有校级管理员可以申领批量报名号'); + const batches = db.candidateAccountBatches + .filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .map(item => candidateAccountBatchView(db, item)); + const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)); + return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) }); + } + if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') { + if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领'); + const body = await readJson(request); + const requestedQuotas = Array.isArray(body.quotas) ? body.quotas : []; + const quotas = requestedQuotas.map(item => ({ classId: cleanText(item.classId, 64), count: Number(item.count) })).filter(item => item.count > 0); + if (!quotas.length) return sendError(response, 400, '请至少为一个班级填写申领数量'); + if (new Set(quotas.map(item => item.classId)).size !== quotas.length) return sendError(response, 400, '同一班级只能填写一次申领数量'); + if (quotas.some(item => !Number.isInteger(item.count) || item.count < 1 || item.count > 200)) return sendError(response, 400, '每个班级一次可申领 1—200 个报名号'); + if (quotas.some(item => !db.classes.some(schoolClass => schoolClass.id === item.classId && schoolClass.schoolId === user.schoolId && schoolClass.active))) return sendError(response, 400, '只能为本校有效班级申领报名号'); + const totalCount = quotas.reduce((sum, item) => sum + item.count, 0); + if (totalCount > 500) return sendError(response, 400, '单个批次最多申领 500 个报名号'); + const batch = { id: uid('account_batch'), schoolId: user.schoolId, requestedBy: user.id, status: 'pending', reviewNote: '', createdAt: nowIso(), reviewedAt: null }; + const items = []; + let position = 1; + for (const quota of quotas) for (let index = 0; index < quota.count; index += 1) { + items.push({ id: uid('account_batch_item'), batchId: batch.id, classId: quota.classId, position, candidateNumber: '', initialPassword: '', userId: null, createdAt: null }); + position += 1; + } + const { instance, action } = createWorkflowSubmission(db, 'candidate_account_batch', batch.id, centerScopeProfile(db, user.schoolId), user.id); + const quotaSummary = quotas.map(item => `${db.classes.find(entry => entry.id === item.classId)?.name} ${item.count} 人`).join(';'); + const log = logAction(db, user, '提交批量报名号申领', `${totalCount} 个账户 · ${quotaSummary}`); + await database.createCandidateAccountBatch(batch, items, instance, action, log); + const fresh = await readDb(); + return sendJson(response, 202, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) }); + } + const accountBatchMatch = pathname.match(/^\/api\/admin\/candidate-account-batches\/([^/]+)$/); + if (accountBatchMatch && request.method === 'PATCH') { if (!requirePermission(user, response, 'candidates.write')) return true; const body = await readJson(request); - const name = cleanText(body.name, 50); - const gender = cleanText(body.gender, 10); - const initialPassword = String(body.initialPassword || ''); - const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId; - const classId = cleanText(body.classId, 64); - const school = db.schools.find(item => item.id === schoolId && item.active); - const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active); - if (!name || !['男', '女'].includes(gender) || initialPassword.length < 8 || !school || !schoolClass) return sendError(response, 400, '请填写姓名、性别、有效学校班级和至少 8 位初始密码'); - const generated = generateCandidateNumber(db, { schoolId, classId, gender }); - const userId = uid('usr'); - const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName: name, schoolId, classId, active: true, mustChangePassword: true, createdAt: nowIso() }; - const profile = { id: uid('profile'), userId, name, gender, idNumber: `PENDING-${userId}`, phone: '', email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; - const log = logAction(db, user, '创建考生账户', `${name} · ${generated.number} · ${school.name} ${schoolClass.name}`); - await database.createCandidate(candidateUser, profile, null, null, log); - return sendJson(response, 201, { ok: true, candidate: { ...profile, candidateNumber: generated.number, mustChangePassword: true } }); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效'); + const batch = db.candidateAccountBatches.find(item => item.id === accountBatchMatch[1] && item.status === 'pending'); + if (!batch) return sendError(response, 404, '待审批的批量报名号申请不存在'); + const instance = pendingWorkflow(db, 'candidate_account_batch', batch.id); + const workflow = instance && db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (!instance || !workflow || !step) return sendError(response, 409, '批量报名号审批流程状态异常'); + if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); + const note = cleanText(body.reviewNote, 300); + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() }; + const log = logAction(db, user, body.status === 'approved' ? '审批批量报名号申领' : '退回批量报名号申领', `${db.schools.find(item => item.id === batch.schoolId)?.name} · ${note || '无备注'}`); + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + batch.status = 'rejected'; batch.reviewNote = note; batch.reviewedAt = nowIso(); + await database.processWorkflow(instance, action, batch, log); + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId))[0]; + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + batch.reviewNote = note; + await database.processWorkflow(instance, action, batch, log); + } else { + const batchItems = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position); + if (!batchItems.length || batchItems.some(item => item.userId || item.candidateNumber)) return sendError(response, 409, '批次明细异常或已经生成过账号'); + const generationDb = { ...db, users: [...db.users] }; + const users = []; + const profiles = []; + for (const [index, item] of batchItems.entries()) { + const schoolClass = db.classes.find(entry => entry.id === item.classId && entry.schoolId === batch.schoolId); + if (!schoolClass) return sendError(response, 409, '批次包含无效班级,无法生成账号'); + const generated = generateCandidateNumber(generationDb, { schoolId: batch.schoolId, classId: item.classId, gender: '' }); + const userId = uid('usr'); + const initialPassword = `Init-${randomBytes(6).toString('base64url')}`; + const displayName = `待补录考生 ${String(index + 1).padStart(3, '0')}`; + const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName, schoolId: batch.schoolId, classId: item.classId, active: true, mustChangePassword: true, createdAt: nowIso() }; + const profile = { id: uid('profile'), userId, name: displayName, gender: '', idNumber: `PENDING-${userId}`, phone: '', email: '', school: db.schools.find(entry => entry.id === batch.schoolId)?.name || '', grade: schoolClass.name, schoolId: batch.schoolId, classId: item.classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; + item.candidateNumber = generated.number; item.initialPassword = initialPassword; item.userId = userId; item.createdAt = nowIso(); + users.push(candidateUser); profiles.push(profile); generationDb.users.push(candidateUser); + } + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + batch.status = 'approved'; batch.reviewNote = note; batch.reviewedAt = nowIso(); + await database.completeCandidateAccountBatch(batch, batchItems, users, profiles, instance, action, log); + } + const fresh = await readDb(); + return sendJson(response, 200, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) }); } if (pathname === '/api/admin/centers' && request.method === 'GET') { @@ -804,11 +905,7 @@ async function handleAdmin(request, response, pathname) { const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id }; let preview = ''; if (rule) preview = generateCandidateNumber(db, previewProfile).number; - const batchCandidates = db.registrations.filter(item => item.status === 'approved' && !item.registrationNumber).map(registration => { - const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - return { id: registration.id, examId: registration.examId, examName: db.exams.find(item => item.id === registration.examId)?.name || '', schoolId: profile?.schoolId || '', schoolName: profile?.school || '', candidateName: profile?.name || '', createdAt: registration.createdAt }; - }); - return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview, batchCandidates, exams: db.exams, schools: db.schools }); + return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview }); } if (pathname === '/api/admin/number-rules' && request.method === 'POST') { if (!requirePermission(user, response, '*')) return true; @@ -827,44 +924,11 @@ async function handleAdmin(request, response, pathname) { await database.saveNumberRule(rule, !existing, log); return sendJson(response, 200, { ok: true, rule }); } - if (pathname === '/api/admin/registration-numbers/batch' && request.method === 'POST') { - if (!requirePermission(user, response, '*')) return true; - const body = await readJson(request); - const activeRule = db.numberRules.find(item => item.active) || null; - const examId = cleanText(body.examId, 64); - const schoolId = cleanText(body.schoolId, 64); - const selectedIds = Array.isArray(body.registrationIds) ? new Set(body.registrationIds.map(item => cleanText(item, 64))) : null; - const eligible = db.registrations.filter(registration => { - if (registration.status !== 'approved' || registration.registrationNumber) return false; - if (examId && registration.examId !== examId) return false; - if (selectedIds && !selectedIds.has(registration.id)) return false; - const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - return Boolean(profile && (!schoolId || profile.schoolId === schoolId)); - }).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || a.id.localeCompare(b.id)).slice(0, 5000); - if (!eligible.length) return sendError(response, 409, '当前筛选条件下没有需要同步账户报名号的记录'); - const changedUsers = new Map(); - const generated = eligible.map(registration => { - const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - const account = db.users.find(item => item.id === registration.userId); - if (!account.candidateNumber) { - const result = generateCandidateNumber(db, profile); - account.candidateNumber = result.number; - changedUsers.set(account.id, account); - } - registration.registrationNumber = account.candidateNumber; - registration.numberRuleId = activeRule?.id || null; - return registration; - }); - const log = logAction(db, user, '批量同步账户报名号', `${generated.length} 条考试报名 · ${changedUsers.size} 个新账户号码`); - await database.assignCandidateNumbers([...changedUsers.values()], generated, log); - return sendJson(response, 200, { ok: true, count: generated.length, registrations: generated.map(item => ({ id: item.id, registrationNumber: item.registrationNumber })) }); - } - if (pathname === '/api/admin/workflows' && request.method === 'GET') { if (!requirePermission(user, response, '*')) return true; return sendJson(response, 200, { ok: true, workflows: db.workflows }); } - const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change)$/); + const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change|candidate_account_batch)$/); if (workflowDefinitionMatch && request.method === 'PUT') { if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); @@ -872,6 +936,7 @@ async function handleAdmin(request, response, pathname) { if (!workflow) return sendError(response, 404, '审批流程不存在'); const steps = Array.isArray(body.steps) ? body.steps : []; if (!steps.length || steps.some(item => !['school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个校级或超级管理员审批步骤'); + if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') return sendError(response, 400, '批量报名号申领的最终步骤必须由超级管理员审批'); workflow.name = cleanText(body.name, 80) || workflow.name; workflow.updatedBy = user.id; workflow.updatedAt = nowIso(); @@ -891,10 +956,13 @@ async function handleAdmin(request, response, pathname) { const profile = workflowScopeProfile(db, instance); const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null; const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null; + const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.find(item => item.id === instance.businessId) : null; return { ...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '', examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '', - centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null + centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null, + accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null, + batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0 }; }); const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser); @@ -925,6 +993,7 @@ async function handleAdmin(request, response, pathname) { const body = await readJson(request); const instance = db.workflowInstances.find(item => item.id === superviseMatch[1]); if (!instance) return sendError(response, 404, '流程不存在'); + if (instance.businessType === 'candidate_account_batch' && db.candidateAccountBatchItems.some(item => item.batchId === instance.businessId && item.userId)) return sendError(response, 409, '已生成账号的批次不可重新打开,避免重复建号'); const workflow = db.workflows.find(item => item.id === instance.workflowId); const requestedStep = Math.min(workflow.steps.length, Math.max(1, Number(body.currentStep || instance.currentStep))); const step = workflow.steps.find(item => item.position === requestedStep); @@ -941,7 +1010,9 @@ async function handleAdmin(request, response, pathname) { ? profile : instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) - : db.centerChangeRequests.find(item => item.id === instance.businessId); + : instance.businessType === 'center_change' + ? db.centerChangeRequests.find(item => item.id === instance.businessId) + : db.candidateAccountBatches.find(item => item.id === instance.businessId); business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null; const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`); await database.processWorkflow(instance, action, business, log); @@ -1055,22 +1126,6 @@ async function handleAdmin(request, response, pathname) { return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); } const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/); - const numberMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/registration-number$/); - if (request.method === 'POST' && numberMatch) { - if (!requirePermission(user, response, '*')) return true; - const registration = db.registrations.find(item => item.id === numberMatch[1]); - if (!registration) return sendError(response, 404, '报名记录不存在'); - if (!registration.registrationNumber) { - const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - const account = db.users.find(item => item.id === registration.userId); - if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号'); - registration.registrationNumber = account.candidateNumber; - registration.numberRuleId = db.numberRules.find(item => item.active)?.id || null; - const log = logAction(db, user, '同步账户报名号', `${profile?.name || registration.userId} · ${account.candidateNumber}`); - await database.assignRegistrationNumber(registration, log); - } - return sendJson(response, 200, { ok: true, registrationNumber: registration.registrationNumber }); - } if (request.method === 'POST' && admitMatch) { if (!requirePermission(user, response, '*')) return true; const registration = db.registrations.find(item => item.id === admitMatch[1]); diff --git a/styles.css b/styles.css index 59c8174..3259e61 100644 --- a/styles.css +++ b/styles.css @@ -14,6 +14,74 @@ --radius: 16px; } +/* 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); } +.batch-quota-head span, .ledger-title span { display: block; margin-bottom: 8px; color: #77d5d3; font-size: 11px; font-weight: 800; letter-spacing: .16em; } +.batch-quota-head h2, .ledger-title h2 { margin: 0; font-family: "STKaiti", "KaiTi", serif; font-size: 27px; } +.batch-quota-head p { max-width: 650px; margin: 8px 0 0; color: #c5d7e4; line-height: 1.7; } +.batch-quota-head > div:last-child { min-width: 118px; padding-left: 24px; border-left: 1px solid rgba(255,255,255,.2); } +.batch-quota-head > div:last-child small, .batch-quota-head > div:last-child span { margin: 0; color: #b9d0dd; font-size: 12px; letter-spacing: 0; } +.batch-quota-head > div:last-child strong { display: block; margin: 3px 0; color: #fff; font-family: "STKaiti", "KaiTi", serif; font-size: 36px; } +.quota-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; padding: 1px; background: #dfe7ec; } +.quota-grid > label { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 88px; padding: 18px 28px; background: #fff; } +.quota-grid > label > span:first-child strong, .quota-grid > label > span:first-child small { display: block; } +.quota-grid > label > span:first-child strong { color: #16334c; font-size: 16px; } +.quota-grid > label > span:first-child small { margin-top: 4px; color: #81909c; } +.quota-input { display: flex; align-items: center; overflow: hidden; border: 1px solid #ccd8df; border-radius: 7px; background: #f7fafb; } +.quota-input input { width: 82px; padding: 10px 8px 10px 14px; border: 0; outline: 0; color: #14334e; background: transparent; font: 700 18px "Microsoft YaHei UI", sans-serif; text-align: right; } +.quota-input em { padding: 0 13px 0 4px; color: #6f818e; font-size: 12px; font-style: normal; } +.batch-submit-bar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 20px 28px; border-top: 1px solid #e3eaee; background: #f7fafb; } +.batch-submit-bar p { margin: 0; } +.batch-submit-bar p strong, .batch-submit-bar p span { display: block; } +.batch-submit-bar p strong { color: #23445c; } +.batch-submit-bar p span { margin-top: 3px; color: #7b8c98; font-size: 12px; } +.account-batch-ledger { margin-top: 34px; } +.ledger-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 16px; } +.ledger-title span { color: #2b7b83; } +.ledger-title p { max-width: 440px; margin: 0; color: #778895; font-size: 13px; text-align: right; } +.account-batch-card { margin-bottom: 18px; padding: 0; overflow: hidden; border-left: 4px solid #d7a744; } +.account-batch-card.approved { border-left-color: #2e8c74; } +.account-batch-card.rejected { border-left-color: #b85d58; } +.account-batch-card > header { display: flex; justify-content: space-between; gap: 20px; padding: 24px 28px 18px; } +.account-batch-card > header span.mono { color: #82929d; font-size: 11px; } +.account-batch-card > header h2 { margin: 5px 0; color: #173750; font-family: "STKaiti", "KaiTi", serif; font-size: 21px; } +.account-batch-card > header p { margin: 0; color: #7c8c97; font-size: 12px; } +.batch-quota-summary { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 28px 18px; } +.batch-quota-summary span { display: inline-flex; gap: 10px; padding: 8px 11px; border: 1px solid #dce5ea; border-radius: 5px; background: #f7fafb; color: #29485e; } +.batch-quota-summary em { color: #28757c; font-style: normal; font-weight: 800; } +.batch-flow-line { display: grid; grid-template-columns: 110px 1fr auto; gap: 14px; align-items: center; padding: 14px 28px; border-top: 1px solid #e6ecef; background: #f8fafb; } +.batch-flow-line > span { color: #7d8d99; font-size: 12px; } +.batch-flow-line > strong { color: #203f56; } +.batch-flow-line > small { color: #657b89; } +.batch-review-note { display: flex; gap: 18px; padding: 14px 28px; border-top: 1px solid #e6ecef; color: #617783; font-size: 13px; } +.batch-review-note strong { color: #2e4d62; } +.credential-sheet { border-top: 1px solid #d9e5e6; background: #f1f7f6; } +.credential-sheet > header { display: flex; align-items: center; justify-content: space-between; padding: 18px 28px; } +.credential-sheet > header strong, .credential-sheet > header span { display: block; } +.credential-sheet > header strong { color: #1e594f; font-family: "STKaiti", "KaiTi", serif; font-size: 18px; } +.credential-sheet > header span { margin-top: 3px; color: #718985; font-size: 12px; } +.credential-sheet table { background: #fff; } +.credential-password { color: #8b5222; font-weight: 800; } +.flow-batch-snapshot { margin-bottom: 20px; padding: 20px; border: 1px solid #dbe7e8; border-radius: 9px; background: #f3f8f8; } +.flow-batch-snapshot header { display: flex; align-items: flex-end; justify-content: space-between; } +.flow-batch-snapshot header span { color: #378286; font-size: 11px; font-weight: 800; letter-spacing: .12em; } +.flow-batch-snapshot h3 { margin: 4px 0 0; color: #183e53; } +.flow-batch-snapshot header b { color: #2f7778; } +.flow-batch-snapshot > div { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 10px; } +.flow-batch-snapshot > div span { padding: 8px 11px; border: 1px solid #d2e1e2; border-radius: 5px; background: #fff; } +.flow-batch-snapshot > div strong, .flow-batch-snapshot > div small { display: block; } +.flow-batch-snapshot > div small, .flow-batch-snapshot > p { color: #70858b; font-size: 12px; } +.flow-batch-snapshot > p { margin: 0; } + +@media (max-width: 760px) { + .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; } + .batch-flow-line { grid-template-columns: 1fr; gap: 4px; } + .ledger-title p { text-align: left; } +} + * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; min-width: 320px; color: var(--ink); background: var(--paper); font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; -webkit-font-smoothing: antialiased; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 8c41ba9..e7bfbec 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -56,6 +56,7 @@ const schoolAdmin2 = createClient(); const classAdmin = createClient(); const candidate = createClient(); const selfCandidate = createClient(); +const batchCandidate = createClient(); const anonymous = createClient(); try { @@ -98,16 +99,21 @@ try { assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school'); assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class'); - const issuedAccount = await schoolAdmin.request('/api/admin/candidate-accounts', { method: 'POST', body: { - name: '测试考生', gender: '男', classId: 'class_hz1_302', initialPassword: 'Initial123!' - } }); - assert.equal(issuedAccount.response.status, 201, '校级管理员应能为本校考生创建固定报名号账户'); - const candidateNumber = issuedAccount.data.candidate.candidateNumber; - assert.match(candidateNumber, /^2026-HZ01-M-\d{4}$/, '账户报名号应按当前规则生成'); - const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: candidateNumber, password: 'Initial123!' } }); + assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号'); + const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [ + { classId: 'class_hz1_302', count: 1 } + ] } }); + assert.equal(singleAccountBatch.response.status, 202, '即使只申领一个账号,校级管理员也应提交审批'); + assert.ok(singleAccountBatch.data.batch.items.every(item => !item.candidateNumber), '最终批准前不得生成账号'); + const approvedSingleBatch = await admin.request(`/api/admin/candidate-account-batches/${singleAccountBatch.data.batch.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '单人名额核验通过' } }); + assert.equal(approvedSingleBatch.response.status, 200); + const candidateNumber = approvedSingleBatch.data.batch.items[0].candidateNumber; + const initialPassword = approvedSingleBatch.data.batch.items[0].initialPassword; + assert.match(candidateNumber, /^2026-HZ01-X-\d{4}$/, '占位账户应按当前规则生成固定报名号'); + const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: candidateNumber, password: initialPassword } }); assert.equal(loginCandidate.data.user.mustChangePassword, true, '学校下发账户首次登录必须修改初始密码'); assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未修改初始密码前不得进入考生业务'); - const changedPassword = await candidate.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: 'Initial123!', newPassword: 'Test12345!' } }); + const changedPassword = await candidate.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: initialPassword, newPassword: 'Test12345!' } }); assert.equal(changedPassword.data.user.mustChangePassword, false, '修改密码后应解除首次登录限制'); assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务'); const updateProfile = await candidate.request('/api/candidate/profile', { @@ -172,12 +178,39 @@ try { ] } }); assert.equal(saveNumberRule.response.status, 200, '超级管理员应可自由组合并启用报名号逻辑'); - assert.ok(numberRules.data.batchCandidates.some(item => item.id === 'reg_demo_2026'), '报名号页面应列出审核通过但缺少号码的记录'); - const batchNumbers = await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: { examId: 'exam_autumn_2026', schoolId: 'school_hz1' } }); - assert.equal(batchNumbers.response.status, 200, '超级管理员应可按考试和学校批量生成报名号'); - assert.equal(batchNumbers.data.count, 1, '批量生成只处理筛选范围内缺失号码的记录'); - assert.match(batchNumbers.data.registrations[0].registrationNumber, /^2026-HZ01-F-\d{4}$/); - assert.equal((await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: { examId: 'exam_autumn_2026', schoolId: 'school_hz1' } })).response.status, 409, '重复批量执行不得覆盖已有报名号'); + assert.equal(numberRules.data.batchCandidates, undefined, '超级管理员号码规则页不应再提供直接批量建号数据'); + assert.equal((await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: {} })).response.status, 404, '旧的超级管理员直接批量生成接口应移除'); + + const candidatesBeforeBatch = (await schoolAdmin.request('/api/admin/candidates')).data.candidates.length; + const createAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [ + { classId: 'class_hz1_301', count: 2 }, { classId: 'class_hz1_302', count: 1 } + ] } }); + assert.equal(createAccountBatch.response.status, 202, '校级管理员应可按多个班级数量提交批量建号申请'); + assert.equal(createAccountBatch.data.batch.totalCount, 3, '批次总数应等于各班级配额之和'); + assert.deepEqual(createAccountBatch.data.batch.quotas.map(item => item.count).sort(), [1, 2], '批次应保留每个班级的独立数量'); + assert.ok(createAccountBatch.data.batch.items.every(item => !item.candidateNumber && !item.initialPassword), '审批通过前不得提前生成报名号或初始密码'); + assert.equal((await schoolAdmin.request('/api/admin/candidates')).data.candidates.length, candidatesBeforeBatch, '审批通过前不得创建任何考生账户'); + const batchId = createAccountBatch.data.batch.id; + assert.equal((await schoolAdmin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved' } })).response.status, 403, '校级申请人不得绕过超级管理员审批'); + const batchFlowList = await admin.request('/api/admin/workflow-instances'); + const batchFlow = batchFlowList.data.instances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batchId); + assert.equal(batchFlow.batchTotalCount, 3, '超级管理员流程中心应展示批次总数'); + assert.deepEqual(batchFlow.accountBatch.quotas.map(item => item.count).sort(), [1, 2], '流程详情应展示班级配额'); + const approveAccountBatch = await admin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学籍名额核验通过' } }); + assert.equal(approveAccountBatch.response.status, 200, '超级管理员最终批准后应生成整批账号'); + assert.equal(approveAccountBatch.data.batch.status, 'approved'); + assert.equal(approveAccountBatch.data.batch.items.length, 3); + assert.ok(approveAccountBatch.data.batch.items.every(item => /^2026-HZ01-X-\d{4}$/.test(item.candidateNumber)), '批量占位账户应按规则使用未知性别 X 生成固定号码'); + assert.equal(new Set(approveAccountBatch.data.batch.items.map(item => item.candidateNumber)).size, 3, '批量生成的报名号必须唯一'); + assert.ok(approveAccountBatch.data.batch.items.every(item => item.initialPassword.startsWith('Init-')), '结果应向校级管理员返回随机初始密码'); + assert.equal((await schoolAdmin.request('/api/admin/candidates')).data.candidates.length, candidatesBeforeBatch + 3, '最终批准应原子创建全部考生账户'); + assert.equal((await admin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved' } })).response.status, 404, '重复审批不得再次生成账号'); + const schoolBatchResult = await schoolAdmin.request('/api/admin/candidate-account-batches'); + const returnedBatch = schoolBatchResult.data.batches.find(item => item.id === batchId); + assert.ok(returnedBatch.items.every(item => item.candidateNumber && item.initialPassword), '批准结果应回到校级管理员的批次详情'); + const firstIssued = returnedBatch.items[0]; + const batchLogin = await batchCandidate.request('/api/auth/login', { method: 'POST', body: { username: firstIssued.candidateNumber, password: firstIssued.initialPassword } }); + assert.equal(batchLogin.data.user.mustChangePassword, true, '批量下发账户首次登录也必须强制修改密码'); const now = Date.now(); const hour = 60 * 60 * 1000; @@ -314,6 +347,7 @@ try { const workflowDefinitions = await admin.request('/api/admin/workflows'); const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change'); assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批'); + assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批'); const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } }); assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程'); @@ -323,7 +357,7 @@ try { console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关'); console.log('✓ 多科目考试创建与考生自主选科报名'); console.log('✓ 审批流程设计、同级转交与超级管理员监督退回'); - console.log('✓ 自定义账户号码、旧记录批量同步、准考证与下载窗口限制'); + console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 成绩录入、发布与考生查询'); } finally {