Implement batch candidate account requests and approval-based number eal
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
### 考生中心
|
||||
|
||||
- 报名号即考生账户,同一考生参加不同考试始终使用同一个号码
|
||||
- 学校管理员创建考生账户并下发报名号、初始密码
|
||||
- 学校管理员按一个或多个班级填写人数并提交批量申领(单人也按 1 人批次走审批)
|
||||
- 首次登录强制修改初始密码,完成后才能补全个人信息
|
||||
- 完整维护姓名、性别、证件号码、籍贯、出生日期、民族、家庭住址、手机号、邮箱、学校、班级、监护人和紧急联系人
|
||||
- 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号
|
||||
@@ -29,13 +29,14 @@
|
||||
|
||||
- 超级、校级、班级三级管理员,同一级支持多个账号
|
||||
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
|
||||
- 校级管理员只管理本校考生和报名流程,并提交本校考点、考场档案变更
|
||||
- 校级管理员只管理本校考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
|
||||
- 班级管理员只读查看本班考生、成绩和报名状态
|
||||
- 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程
|
||||
- 考生信息修改、考试报名、批量报名号申领、考点考场变更使用可配置的多步骤审批流程
|
||||
- 当前处理人可将流程转交给同范围的同级管理员
|
||||
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
|
||||
- 报名号在创建考生账户时只生成一次,后续考试报名自动复用
|
||||
- 按考试、学校筛选,为旧数据批量同步考生账户的固定报名号
|
||||
- 超级管理员只维护号码规则;校级批量申请最终批准后,系统原子生成固定报名号、随机初始密码和待补录账户
|
||||
- 批次结果按班级返回校级管理员,并可导出 CSV 安全下发
|
||||
- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态
|
||||
- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案
|
||||
- 考务指标与审计日志
|
||||
@@ -72,7 +73,7 @@ npm start
|
||||
|
||||
打开 <http://127.0.0.1:4173>。
|
||||
|
||||
本地开发无需额外配置,首次运行会自动创建 `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 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -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 `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
|
||||
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
|
||||
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
|
||||
if (role === 'admin' && page === 'candidates' && state.user.adminLevel !== 'class') return `<button class="solid-button" data-action="new-candidate-account">${icons.plus} 创建考生账户</button>`;
|
||||
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
|
||||
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
|
||||
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 = `<section class="panel batch-quota-panel"><div class="batch-quota-head"><div><span>SCHOOL ACCOUNT REQUEST</span><h2>按班级申领报名号</h2><p>只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。</p></div><div><small>单批上限</small><strong>500</strong><span>个账户</span></div></div><form data-form="candidate-account-batch"><div class="quota-grid">${classes.map(item => `<label><span><strong>${h(item.name)}</strong><small>${h(item.grade)}</small></span><span class="quota-input"><input type="number" min="0" max="200" value="0" data-class-id="${h(item.id)}"><em>人</em></span></label>`).join('')}</div><div class="batch-submit-bar"><p><strong>审批通过后生成</strong><span>固定报名号、随机初始密码、待补录考生账户</span></p><button class="solid-button" type="submit">提交批量申领</button></div></form></section>`;
|
||||
const ledger = batches.map(batch => {
|
||||
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="export-account-batch" data-id="${h(batch.id)}">导出 CSV</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
|
||||
return `<article class="panel account-batch-card ${h(batch.status)}"><header><div><span class="mono">${h(batch.id)}</span><h2>${h(batch.schoolName)} · ${batch.totalCount} 个报名号</h2><p>${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交</p></div>${badge(batch.status)}</header><div class="batch-quota-summary">${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><em>${item.count} 人</em></span>`).join('')}</div><div class="batch-flow-line"><span>当前进度</span><strong>${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}</strong><small>${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}</small></div>${batch.reviewNote ? `<div class="batch-review-note"><strong>审批意见</strong><span>${h(batch.reviewNote)}</span></div>` : ''}${resultRows}</article>`;
|
||||
}).join('');
|
||||
return `${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
|
||||
}
|
||||
|
||||
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 `<section class="account-number-principle"><span>ONE CANDIDATE · ONE NUMBER</span><strong>报名号属于考生账户</strong><p>创建账户时生成一次,参加不同考试时只复制到报名记录,不再重新编号。</p></section><div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>账户报名号组成</h2><p>规则用于新建考生账户;流水号为必选字段。</p></div><span>当前规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存并用于新账户</button></form></section><aside class="rule-preview"><span>新账户样例</span><strong>${h(data.preview || '2026-HZ01-F-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>已有考生账户的报名号不会因规则修改而变化。</small></aside></div><section class="panel batch-number-panel"><div class="batch-number-intro"><span>LEGACY SYNCHRONIZATION</span><h2>批量同步账户报名号</h2><p>用于旧数据或导入记录:缺号的考试报名会复制考生账户的固定报名号,不会为每场考试另建号码。</p></div><form data-form="batch-registration-numbers"><label><span>考试范围</span><select name="examId"><option value="">全部考试</option>${(data.exams || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>学校范围</span><select name="schoolId"><option value="">全部学校</option>${(data.schools || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="batch-ready"><small>待同步</small><strong>${candidates.length}</strong><span>条</span></div><button class="solid-button" type="submit" ${candidates.length ? '' : 'disabled'}>同步账户报名号</button></form><div class="batch-candidate-strip">${candidates.slice(0, 8).map(item => `<span><b>${h(item.candidateName)}</b><small>${h(item.schoolName)} · ${h(item.examName)}</small></span>`).join('') || '<p>当前所有考试报名都已关联固定账户报名号。</p>'}${candidates.length > 8 ? `<em>另有 ${candidates.length - 8} 条</em>` : ''}</div></section>`;
|
||||
return `<section class="account-number-principle"><span>ONE CANDIDATE · ONE NUMBER</span><strong>超级管理员只设计号码规则</strong><p>校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。</p></section><div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>账户报名号组成</h2><p>规则用于最终审批后的批量建号;流水号为必选字段。</p></div><span>当前规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存审批建号规则</button></form></section><aside class="rule-preview"><span>审批后账户样例</span><strong>${h(data.preview || '2026-HZ01-X-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>批量申领阶段尚无性别资料,因此性别段使用 X;考生补录资料后号码保持不变。</small></aside></div>`;
|
||||
}
|
||||
|
||||
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 `<div class="workflow-design-grid">${workflows.map(workflow => `<section class="panel workflow-designer"><header><div><span>${h(codes[workflow.businessType] || workflow.businessType)}</span><h2>${h(workflow.name)}</h2></div><button class="row-action" data-action="add-workflow-step" data-type="${h(workflow.businessType)}">添加步骤</button></header><form data-form="workflow-design" data-type="${h(workflow.businessType)}"><input name="name" value="${h(workflow.name)}" required><div class="workflow-step-editor" data-workflow-steps>${workflow.steps.map(step => workflowStepEditor(step)).join('')}</div><button class="solid-button" type="submit">保存流程</button></form></section>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
@@ -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 `<div class="workflow-board">${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 `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名或考点档案提交后,流程会显示在这里。')}</div>`;
|
||||
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领' };
|
||||
return `<div class="workflow-board">${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 `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`;
|
||||
}).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}</div>`;
|
||||
}
|
||||
|
||||
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(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`);
|
||||
}
|
||||
|
||||
function openCandidateAccountForm() {
|
||||
const { schools = [], classes = [] } = state.pageData;
|
||||
setModal(`<div class="modal-head"><div><span>ISSUE CANDIDATE NUMBER</span><h2>创建考生账户</h2><p>系统按当前报名号规则生成固定账户;考生首次登录必须修改初始密码。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="candidate-account"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与学籍一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label>` : `<input type="hidden" name="schoolId" value="${h(state.user.schoolId)}">`}<label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${state.user.adminLevel === 'school' ? classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('') : ''}</select></label><label><span>初始密码 *</span><input name="initialPassword" type="password" minlength="8" required placeholder="至少 8 位,线下安全告知考生"></label><div class="account-issue-note"><strong>下发给考生</strong><span>生成的报名号 + 本次设置的初始密码</span></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">生成报名号并创建账户</button></div></form>`);
|
||||
}
|
||||
|
||||
function centerRoomEditor(room = {}) {
|
||||
return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label><span>座位起号 *</span><input name="roomSeatStart" type="number" min="1" required value="${h(room.seatStart || 1)}"></label><label><span>座位止号 *</span><input name="roomSeatEnd" type="number" min="1" required value="${h(room.seatEnd || 30)}"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`;
|
||||
}
|
||||
@@ -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 => `<div><i></i><span><strong>${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}</strong><small>${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}</small></span><time>${formatDate(action.createdAt, true)}</time></div>`).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 ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatStart)}—${h(room.seatEnd)}</small></span>`).join('')}</div></section>` : '';
|
||||
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">确认处理</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
||||
const batch = instance.accountBatch;
|
||||
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
|
||||
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}${batchSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">${finalBatchStep ? '最终批准并生成账号' : '确认处理'}</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
||||
}
|
||||
|
||||
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(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || '待同步账户号码')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : `<div class="modal-foot">${state.user.adminLevel === 'super' && reg.status === 'approved' && !reg.registrationNumber ? `<button class="ghost-button" data-action="generate-registration-number" data-id="${h(reg.id)}">同步账户报名号</button>` : ''}<button class="solid-button" data-action="close-modal">关闭</button></div>`}`);
|
||||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||||
}
|
||||
|
||||
function openNoticeForm() {
|
||||
|
||||
+237
-35
@@ -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);
|
||||
|
||||
+129
-74
@@ -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,9 +370,38 @@ function workflowScopeProfile(db, instance) {
|
||||
const registration = db.registrations.find(item => item.id === instance.businessId);
|
||||
return db.candidateProfiles.find(item => item.userId === registration?.userId) || 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) {
|
||||
const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id);
|
||||
@@ -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 });
|
||||
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 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 } });
|
||||
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]);
|
||||
|
||||
+68
@@ -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; }
|
||||
|
||||
+49
-15
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user