diff --git a/README.md b/README.md index 9c5c00a..1952d41 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 衡准 · 考试信息管理系统 -一个完整可运行的双角色考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。 +一个完整可运行的分级权限考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。 ## 已实现功能 @@ -23,6 +23,16 @@ ### 管理后台 +- 超级、校级、班级三级管理员,同一级支持多个账号 +- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 +- 校级管理员只管理本校考生和报名流程,并提交本校考点、考场档案变更 +- 班级管理员只读查看本班考生、成绩和报名状态 +- 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程 +- 当前处理人可将流程转交给同范围的同级管理员 +- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 +- 按考试、学校筛选,批量为已审核且缺号的报名记录生成报名号 +- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态 +- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案 - 考务指标与审计日志 - 考生资料审核、通过或退回修改 - 考试报名及科目审核 @@ -41,6 +51,8 @@ - 规范关系模型、外键、唯一约束和业务索引 - 业务写入与审计日志使用原子事务提交 - 关键管理操作审计日志 +- 组织、学校、班级三级数据范围在服务端强制过滤 +- 审批实例、当前责任人、转交和监督操作全程留痕 - 桌面端与移动端响应式布局 - 零第三方运行时依赖 @@ -55,7 +67,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容或导入旧版 JSON/单表数据库;更改表结构后请删除本地 SQLite 文件并重新启动。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v3,补充分级权限、报名号、结构化考场和变更审批结构。 ## 数据库配置 @@ -78,15 +90,18 @@ npm start ```sql CREATE DATABASE exam_information CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password'; -GRANT SELECT, INSERT, UPDATE, CREATE ON exam_information.* TO 'exam_app'@'%'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'exam_app'@'%'; ``` 启动应用时设置连接信息,应用会自动创建以下关系表并初始化演示数据: -- `users`、`candidate_profiles` +- `schools`、`school_classes`、`users`、`candidate_profiles` - `exams`、`exam_subjects` - `registrations`、`registration_subjects`、`admit_cards` - `results`、`notices`、`audit_logs` +- `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms` +- `number_rules`、`number_rule_segments` +- `workflow_definitions`、`workflow_steps`、`workflow_instances`、`workflow_actions` - `organization`、`schema_metadata` 所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。 @@ -109,7 +124,11 @@ npm start | 角色 | 账号 | 密码 | | --- | --- | --- | -| 管理员 | `admin` | `Admin123!` | +| 超级管理员 | `admin` | `Admin123!` | +| 超级管理员(监督演示) | `supervisor` | `Admin123!` | +| 校级管理员 | `school_admin` | `School123!` | +| 同校校级管理员(转交演示) | `school_admin_2` | `School123!` | +| 班级管理员 | `class_admin` | `Class123!` | | 考生 | `13800138000` | `Candidate123!` | ## 自动化测试 @@ -118,7 +137,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖注册、审核、多科目报名、准考证生成与下载、通知发布、成绩发布和权限隔离完整流程。 +测试使用独立临时 SQLite 数据库,覆盖三级管理员数据范围、两级审批、同级转交、超级管理员监督退回、报名号规则与筛选批量生成、结构化考点考场及变更审批、班级只读、多科目报名、准考证和成绩完整流程。 ## 项目结构 diff --git a/app.js b/app.js index c0e247f..1ce71c0 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,8 @@ const state = { user: null, profile: null, publicData: { organization: {}, notices: [], exams: [], stats: {} }, + permissions: [], + scopeLabel: '', pageData: null, loading: false }; @@ -11,7 +13,8 @@ const modalRoot = document.querySelector('#modalRoot'); const statusLabels = { pending: '待审核', approved: '已通过', rejected: '需修改', published: '已发布', draft: '草稿', closed: '已结束', - open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费' + open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', + super: '超级管理员', school: '校级管理员', class: '班级管理员' }; const icons = { @@ -125,7 +128,7 @@ function renderPublicExam(exam) { function renderAuth(kind) { const login = kind === 'login'; - app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '欢迎回来,' : '从这里,'}
${login ? '继续你的考试进程。' : '开始你的应考旅程。'}

统一管理报名、审核、准考证与成绩,不错过每一个关键节点。

考试服务承诺

资料有状态、报名有回执、下载有时间、成绩有依据。

${login ? 'ACCOUNT LOGIN' : 'CREATE ACCOUNT'}

${login ? '登录衡准' : '考生自主注册'}

${login ? '使用考生账号或管理员账号进入系统。' : '请填写真实身份信息,注册后由管理员审核。'}

${login ? loginForm() : registerForm()}
${login ? '还没有考生账号?' : '已经注册过?'}
${login ? `
演示账号
` : ''}
`; + app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '欢迎回来,' : '从这里,'}
${login ? '继续你的考试进程。' : '开始你的应考旅程。'}

统一管理报名、审核、准考证与成绩,不错过每一个关键节点。

考试服务承诺

资料有状态、报名有回执、下载有时间、成绩有依据。

${login ? 'ACCOUNT LOGIN' : 'CREATE ACCOUNT'}

${login ? '登录衡准' : '考生自主注册'}

${login ? '使用考生账号或管理员账号进入系统。' : '请填写真实身份信息,注册后由管理员审核。'}

${login ? loginForm() : registerForm()}
${login ? '还没有考生账号?' : '已经注册过?'}
${login ? `
演示账号
` : ''}
`; } function loginForm() { @@ -133,27 +136,35 @@ function loginForm() { } function registerForm() { - return `
`; + const schools = state.publicData.schools || []; + return `
`; } const candidateNav = [ ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], ['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'] ]; -const adminNav = [ - ['dashboard', '工作台', 'home'], ['candidates', '考生审核', 'users'], ['registrations', '报名审核', 'check'], - ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], ['results', '成绩发布', 'chart'] -]; +function adminNavForUser() { + const level = state.user?.adminLevel || 'super'; + 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]]; + 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]]; +} function portalShell(role, page, content, title, description) { - const nav = role === 'admin' ? adminNav : candidateNav; + const nav = role === 'admin' ? adminNavForUser() : candidateNav; const roleName = role === 'admin' ? '管理后台' : '考生中心'; - return `
${roleName}/${h(title)}
${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? '系统管理员' : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; + const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; + return `
${roleName}/${h(title)}
${role === 'admin' && state.user?.adminLevel !== 'class' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; } function portalHeadingAction(role, page) { if (role === 'admin' && page === 'notices') return ``; if (role === 'admin' && page === 'exams') return ``; + if (role === 'admin' && page === 'admins') return ``; + if (role === 'admin' && page === 'centers') return ``; if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; return ''; } @@ -181,7 +192,7 @@ async function renderCandidate(page) { state.pageData = data; if (data.profile) state.profile = data.profile; const content = { - dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data.profile), exams: () => candidateExams(data), + dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data), registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations), results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices) }[page](); @@ -201,8 +212,10 @@ function candidateDashboard(data) { return `
${new Date().getHours() < 12 ? '上午好' : '下午好'}

${h(data.profile?.name || state.user.displayName)},下一步已为你标出。

${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}


${icons.user}
个人资料${statusLabels[data.profile?.status] || '未填写'}
${badge(data.profile?.status || 'pending')}
${icons.exam}
已报名考试${data.registrations.length} 场
${icons.ticket}
可下载准考证${data.registrations.filter(item => item.admitCard).length} 份
${icons.chart}
已发布成绩${data.results.length} 科

我的应考进度

自动更新
${steps.map((step, index) => `
${step[1] ? '✓' : index + 1}
${step[0]}${step[2]}
`).join('')}

最近通知

${data.notices.map(notice => ``).join('')}
`; } -function candidateProfile(profile) { - return `
01

实名信息

姓名与证件号码须与有效证件完全一致。

02

学籍与联系信息

用于资格审核和紧急情况联系。

${profile?.reviewNote ? `
审核意见

${h(profile.reviewNote)}

` : ''}

保存后资料状态将变为“待审核”。

`; +function candidateProfile(data) { + const { profile, schools = [], classes = [], workflow } = data; + const step = workflow?.currentStepDetail; + return `
${workflow ? `
当前审批${h(step?.name || statusLabels[workflow.status])}${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}
` : ''}
01

实名信息

姓名与证件号码须与有效证件完全一致。

02

学籍与联系信息

学校和班级决定资料的管理范围。

${profile?.reviewNote ? `
审核意见

${h(profile.reviewNote)}

` : ''}

保存后资料将按当前流程重新审批。

`; } function candidateExams(data) { @@ -210,7 +223,7 @@ function candidateExams(data) { } function candidateRegistrations(registrations) { - return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
报名编号
${h(reg.id)}
报名时间
${formatDate(reg.createdAt, true)}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '报名已进入审核队列,请耐心等待。' : '报名已经确认,请留意准考证下载通知。'}

${reg.admitCard ? `` : ''}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); + return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
报名号
${h(reg.registrationNumber || '审批通过后生成')}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '报名已进入审批流程,请留意当前步骤。' : '报名已经确认,请留意准考证下载通知。'}

${reg.admitCard ? `` : ''}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); } function candidateAdmit(registrations) { @@ -234,17 +247,25 @@ async function renderAdmin(page) { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'], - results: ['成绩发布', '录入单科成绩并控制是否对考生公开。'] + results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], + admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], + centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], + flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], + 'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'], + 'number-rules': ['报名号规则', '设计号码组成,并为缺失报名号的已审核记录批量生成。'] }; - if (!meta[page]) page = 'dashboard'; + 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; + const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : 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) + exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data), + admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), + 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data) }[page](); app.innerHTML = portalShell('admin', page, content, ...meta[page]); } catch (error) { renderError(error); } @@ -252,15 +273,18 @@ async function renderAdmin(page) { function adminDashboard(data) { const m = data.metrics; - return `
${icons.users}
考生总数${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
已发布考试${m.publishedExams}考试计划正常
${icons.bell}
已发布通知${m.notices}首页同步展示

当前待办

按优先级排列

最近操作

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

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

`).join('') || '

暂无操作记录

'}
`; + const canFlow = state.user.adminLevel !== 'class'; + return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

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

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

最近操作

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

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

`).join('') || '

当前账号暂无操作记录

'}
`; } function adminCandidates(candidates) { - return `
${candidates.map(item => ``).join('')}
考生证件号码学校 / 班级联系方式更新时间状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.gender || '未填写')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${h(item.phone)}${h(item.email || '')}${formatDate(item.updatedAt,true)}${badge(item.status)}
`; + const readOnly = state.user.adminLevel === 'class'; + return `
${candidates.map(item => ``).join('')}
考生证件号码学校 / 班级当前流程更新时间状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.gender || '未填写')} · ${h(item.phone)}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${h(item.workflow?.currentStepDetail?.name || '流程已结束')}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${badge(item.status)}
`; } function adminRegistrations(registrations) { - return `
${registrations.map(reg => ``).join('')}
考生考试报考科目报名时间缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.idNumber)}
${h(reg.exam.name)}${h(reg.exam.code)}
${reg.subjects.map(subject => `${h(subject.name)}`).join('')}
${formatDate(reg.createdAt,true)}${badge(reg.paymentStatus)}${badge(reg.status)}
`; + const readOnly = state.user.adminLevel === 'class'; + return `
${registrations.map(reg => ``).join('')}
考生考试 / 科目报名号当前流程缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '审批通过后生成')}${formatDate(reg.createdAt,true)}${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; } function adminExams(exams) { @@ -277,7 +301,47 @@ function adminAdmit(registrations) { } function adminResults(data) { - return `

录入单科成绩

保存后可立即发布

最近成绩

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

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

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

还没有成绩记录

'}
`; + const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

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

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

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

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

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

还没有成绩记录

'}
`; +} + +function adminUsers(data) { + return `

管理员层级决定可见范围和可执行操作;同一级可创建多名账号。

${data.admins.map(item => ``).join('')}
管理员登录账号层级绑定范围状态
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; +} + +function adminCenters(data) { + const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' }; + const cards = data.centers.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

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

考点变更台账

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

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; +} + +const numberSegmentMeta = { + year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], + sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] +}; + +function adminNumberRules(data) { + const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] }; + const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); + const types = Object.keys(numberSegmentMeta); + const candidates = data.batchCandidates || []; + return `

组合报名号

勾选字段并填写顺序;流水号为必选字段。

实时规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
BATCH GENERATION

批量生成报名号

只处理“审核通过且报名号为空”的记录,可按考试和学校缩小范围;批次一次提交、整体写入。

当前可生成${candidates.length}
${candidates.slice(0, 8).map(item => `${h(item.candidateName)}${h(item.schoolName)} · ${h(item.examName)}`).join('') || '

当前没有缺失报名号的已审核记录。

'}${candidates.length > 8 ? `另有 ${candidates.length - 8} 条` : ''}
`; +} + +function adminFlowDesign(workflows) { + const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE' }; + return `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

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

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}
`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名或考点档案提交后,流程会显示在这里。')}
`; } function emptyState(title, description, route, action) { @@ -317,6 +381,8 @@ async function refreshSession() { const session = await api('/api/auth/me'); state.user = session.user; state.profile = session.profile; + state.permissions = session.permissions || []; + state.scopeLabel = session.scopeLabel || ''; } document.addEventListener('click', async event => { @@ -343,13 +409,13 @@ document.addEventListener('click', async event => { } if (action === 'logout') { await api('/api/auth/logout', { method: 'POST' }); - state.user = null; state.profile = null; state.pageData = null; + state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return; } if (action === 'fill-demo') { const form = document.querySelector('[data-form="login"]'); - form.username.value = target.dataset.type === 'admin' ? 'admin' : '13800138000'; - form.password.value = target.dataset.type === 'admin' ? 'Admin123!' : 'Candidate123!'; + const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['13800138000', 'Candidate123!'] }; + [form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate; return; } if (action === 'open-notice') { @@ -359,9 +425,36 @@ document.addEventListener('click', async event => { if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'new-notice') return openNoticeForm(); if (action === 'new-exam') return openExamForm(); + if (action === 'new-admin') return openAdminForm(); + 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') { + document.querySelector('[data-center-rooms]')?.insertAdjacentHTML('beforeend', centerRoomEditor()); + return; + } + if (action === 'remove-center-room') { + const list = target.closest('[data-center-rooms]'); + if (list.children.length <= 1) return toast('至少保留一个考场', '考点档案必须包含结构化考场'); + target.closest('.center-room-editor').remove(); return; + } + if (action === 'open-flow') return openFlowDetail(target.dataset.id); + if (action === 'add-workflow-step') { + const form = document.querySelector(`[data-form="workflow-design"][data-type="${target.dataset.type}"]`); + form?.querySelector('[data-workflow-steps]')?.insertAdjacentHTML('beforeend', workflowStepEditor()); + return; + } + if (action === 'remove-workflow-step') { + const list = target.closest('[data-workflow-steps]'); + if (list.children.length <= 1) return toast('至少保留一步', '审批流程不能为空'); + target.closest('.workflow-step-row').remove(); return; + } 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 === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); if (registration.admitCard) return openAdmitPreview(registration); @@ -401,6 +494,19 @@ document.addEventListener('input', event => { }); document.addEventListener('change', event => { + if (event.target.matches('[data-action="school-select"]')) { + const form = event.target.closest('form'); + const classSelect = form?.querySelector('select[name="classId"]'); + if (classSelect) { + const classes = state.pageData?.classes || state.publicData.classes || []; + classSelect.innerHTML = `${classes.filter(item => item.schoolId === event.target.value).map(item => ``).join('')}`; + } + } + if (event.target.matches('[data-action="admin-level"]')) { + const form = event.target.closest('form'); + form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super'); + form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class'); + } if (event.target.matches('[data-action="result-registration"]')) { const option = event.target.selectedOptions[0]; const select = document.querySelector('#resultSubject'); @@ -441,6 +547,62 @@ document.addEventListener('submit', async event => { const body = formObject(form); await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body }); closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute(); + } else if (kind === 'admin-form') { + const body = formObject(form); + await api('/api/admin/admins', { method: 'POST', body }); + closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); + } else if (kind === 'center-form') { + const body = formObject(form); + const editing = Boolean(body.id); + body.rooms = [...form.querySelectorAll('.center-room-editor')].map(row => ({ + id: row.querySelector('[name="roomId"]').value || null, + code: row.querySelector('[name="roomCode"]').value, + name: row.querySelector('[name="roomName"]').value, + building: row.querySelector('[name="roomBuilding"]').value, + floor: row.querySelector('[name="roomFloor"]').value, + capacity: Number(row.querySelector('[name="roomCapacity"]').value), + seatStart: Number(row.querySelector('[name="roomSeatStart"]').value), + seatEnd: Number(row.querySelector('[name="roomSeatEnd"]').value), + roomType: row.querySelector('[name="roomType"]').value, + status: row.querySelector('[name="roomStatus"]').value, + notes: row.querySelector('[name="roomNotes"]').value + })); + await api(editing ? `/api/admin/centers/${body.id}` : '/api/admin/centers', { method: editing ? 'PATCH' : 'POST', body }); + closeModal(); toast(editing ? '考点变更已提交' : '新考点已提交', '审批通过后才会更新正式档案'); renderRoute(); + } else if (kind === 'number-rule') { + const raw = formObject(form); + const types = Object.keys(numberSegmentMeta); + const segments = types.filter(type => type === 'sequence' || form.querySelector(`[name="include_${type}"]`)?.checked).map(type => ({ + type, position: Number(raw[`position_${type}`] || 99), value: raw[`value_${type}`] || '', width: Number(raw[`width_${type}`] || 0) + })).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"]')]; + const steps = names.map((input, index) => ({ name: input.value, adminLevel: levels[index].value })); + await api(`/api/admin/workflows/${form.dataset.type}`, { method: 'PUT', body: { name: form.name.value, steps } }); + toast('审批流程已保存', `${steps.length} 个步骤已启用`); renderRoute(); + } else if (kind === 'flow-process') { + const body = formObject(form); + const path = body.businessType === 'profile_change' + ? `/api/admin/candidates/${body.businessId}` + : body.businessType === 'registration_review' + ? `/api/admin/registrations/${body.businessId}` + : `/api/admin/center-change-requests/${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') { + const body = formObject(form); + await api(`/api/admin/workflow-instances/${body.id}/transfer`, { method: 'PATCH', body }); + closeModal(); toast('流程已转交', '新责任人已收到待办'); renderRoute(); + } else if (kind === 'flow-supervise') { + const body = formObject(form); + await api(`/api/admin/workflow-instances/${body.id}/supervise`, { method: 'PATCH', body }); + closeModal(); toast('流程已监督调整', '节点与责任人已更新并记录'); renderRoute(); } else if (kind === 'notice-form') { const body = formObject(form); body.pinned = form.pinned.checked; await api('/api/admin/notices', { method: 'POST', body }); @@ -460,14 +622,44 @@ document.addEventListener('submit', async event => { finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } } }); +function openAdminForm() { + const { schools = [], classes = [] } = state.pageData; + setModal(``); +} + +function centerRoomEditor(room = {}) { + return `
结构化考场
`; +} + +function openCenterForm(center = null) { + const schools = state.pageData.schools || []; + const rooms = center?.rooms?.length ? center.rooms : [{}]; + setModal(``); +} + +function openFlowDetail(id) { + const instance = state.pageData.instances.find(item => item.id === id); + const currentLevel = instance.currentStepDetail?.adminLevel; + const available = state.pageData.availableAdmins.filter(item => item.adminLevel === currentLevel && (currentLevel === 'super' || item.schoolId === instance.assignee?.schoolId)); + const canProcess = instance.status === 'pending' && (state.user.adminLevel === 'super' || instance.assignee?.id === state.user.id); + const history = instance.actions.map(action => `
${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}
`).join(''); + const subject = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName; + const subjectDetail = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; + const change = instance.centerChange; + const changeSnapshot = change ? `
申请快照

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

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

流程轨迹

${history || '

暂无操作

'}
${canProcess ? `` : '
当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`); +} + function openCandidateReview(id) { const item = state.pageData.candidates.find(candidate => candidate.id === id); - setModal(`
证件号码
${h(item.idNumber)}
联系电话
${h(item.phone)}
就读学校
${h(item.school)}
年级班级
${h(item.grade)}
电子邮箱
${h(item.email || '未填写')}
联系地址
${h(item.address || '未填写')}
`); + const canReview = state.user.adminLevel !== 'class' && item.status === 'pending'; + setModal(`
证件号码
${h(item.idNumberMasked)}
联系电话
${h(item.phone)}
就读学校
${h(item.school)}
年级班级
${h(item.grade)}
当前步骤
${h(item.workflow?.currentStepDetail?.name || '流程已结束')}
当前责任人
${h(item.workflow?.assignee?.displayName || '—')}
${canReview ? `` : ''}`); } function openRegistrationReview(id) { const reg = state.pageData.registrations.find(item => item.id === id); - setModal(`
报考科目

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

资料状态
${badge(reg.candidate?.status)}
报名时间
${formatDate(reg.createdAt,true)}
缴费状态
${badge(reg.paymentStatus)}
`); + const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending'; + setModal(`
报考科目

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

报名号
${h(reg.registrationNumber || '审批通过后生成')}
当前步骤
${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
${canReview ? `` : ``}`); } function openNoticeForm() { diff --git a/database.mjs b/database.mjs index 44511ab..718f6f4 100644 --- a/database.mjs +++ b/database.mjs @@ -4,6 +4,8 @@ import { dirname, join, resolve } from 'node:path'; export const relationalTables = [ 'schema_metadata', 'organization', + 'schools', + 'school_classes', 'users', 'candidate_profiles', 'notices', @@ -13,6 +15,16 @@ export const relationalTables = [ 'registration_subjects', 'admit_cards', 'results', + 'test_centers', + 'test_rooms', + 'center_change_requests', + 'center_change_rooms', + 'number_rules', + 'number_rule_segments', + 'workflow_definitions', + 'workflow_steps', + 'workflow_instances', + 'workflow_actions', 'audit_logs' ]; @@ -34,11 +46,32 @@ const sqliteSchema = ` address TEXT NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS schools ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + address TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) + ) STRICT; + + CREATE TABLE IF NOT EXISTS school_classes ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name TEXT NOT NULL, + grade TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + UNIQUE (school_id, name) + ) STRICT; + CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), + admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), + school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, + class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), display_name TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; @@ -53,6 +86,8 @@ const sqliteSchema = ` email TEXT, school TEXT, grade TEXT, + school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, + class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, address TEXT, emergency_contact TEXT, emergency_phone TEXT, @@ -113,6 +148,8 @@ const sqliteSchema = ` created_at TEXT NOT NULL, reviewed_at TEXT, review_note TEXT, + registration_number TEXT UNIQUE, + number_rule_id TEXT, UNIQUE (user_id, exam_id) ) STRICT; @@ -143,6 +180,142 @@ const sqliteSchema = ` UNIQUE (registration_id, subject_id) ) STRICT; + CREATE TABLE IF NOT EXISTS test_centers ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + address TEXT NOT NULL, + contact TEXT, + manager_name TEXT, + manager_phone TEXT, + emergency_phone TEXT, + gate_open_time TEXT, + transport TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + notes TEXT, + rooms TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (school_id, name) + ) STRICT; + + CREATE TABLE IF NOT EXISTS test_rooms ( + id TEXT PRIMARY KEY, + center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE, + code TEXT NOT NULL, + name TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT, + capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0), + seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start), + room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + notes TEXT, + UNIQUE (center_id, code) + ) STRICT; + + CREATE TABLE IF NOT EXISTS center_change_requests ( + id TEXT PRIMARY KEY, + center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')), + code TEXT NOT NULL, + name TEXT NOT NULL, + address TEXT NOT NULL, + contact TEXT, + manager_name TEXT, + manager_phone TEXT, + emergency_phone TEXT, + gate_open_time TEXT, + transport TEXT, + center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')), + notes TEXT, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + reviewed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS center_change_rooms ( + id TEXT PRIMARY KEY, + request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE, + room_id TEXT, + code TEXT NOT NULL, + name TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT, + capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_start INTEGER NOT NULL DEFAULT 1, + seat_end INTEGER NOT NULL, + room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), + status TEXT NOT NULL CHECK (status IN ('active', 'inactive')), + notes TEXT, + UNIQUE (request_id, code) + ) STRICT; + + CREATE TABLE IF NOT EXISTS number_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + separator TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS number_rule_segments ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')), + value TEXT, + width INTEGER NOT NULL DEFAULT 0, + UNIQUE (rule_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')), + 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; + + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')), + UNIQUE (workflow_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_instances ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT, + business_type TEXT NOT NULL, + business_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + current_step INTEGER NOT NULL DEFAULT 1, + assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + completed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_actions ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')), + note TEXT, + from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, @@ -152,11 +325,16 @@ const sqliteSchema = ` ) STRICT; CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); + CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status); CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position); CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); + CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type); + 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_results_registration ON results(registration_id, published); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); `; @@ -179,15 +357,42 @@ const mysqlSchema = [ PRIMARY KEY (id), CONSTRAINT chk_organization_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS schools ( + id VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + address VARCHAR(255) NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uq_schools_name (name), + UNIQUE KEY uq_schools_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS school_classes ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + grade VARCHAR(60) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uq_classes_school_name (school_id, name), + CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) NOT NULL, username VARCHAR(100) NOT NULL, password_hash VARCHAR(255) NOT NULL, role ENUM('admin', 'candidate') NOT NULL, + admin_level ENUM('super', 'school', 'class') NULL, + school_id VARCHAR(64) NULL, + class_id VARCHAR(64) NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, display_name VARCHAR(100) NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), - UNIQUE KEY uq_users_username (username) + UNIQUE KEY uq_users_username (username), + KEY idx_users_admin_scope (role, admin_level, school_id, class_id), + CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, + CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS candidate_profiles ( id VARCHAR(64) NOT NULL, @@ -199,6 +404,8 @@ const mysqlSchema = [ email VARCHAR(160) NULL, school VARCHAR(160) NULL, grade VARCHAR(100) NULL, + school_id VARCHAR(64) NULL, + class_id VARCHAR(64) NULL, address VARCHAR(255) NULL, emergency_contact VARCHAR(100) NULL, emergency_phone VARCHAR(60) NULL, @@ -212,7 +419,9 @@ const mysqlSchema = [ UNIQUE KEY uq_profiles_id_number (id_number), KEY idx_profiles_status (status), CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL + CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, + CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS notices ( id VARCHAR(64) NOT NULL, @@ -269,8 +478,11 @@ const mysqlSchema = [ created_at VARCHAR(35) NOT NULL, reviewed_at VARCHAR(35) NULL, review_note VARCHAR(500) NULL, + registration_number VARCHAR(120) NULL, + number_rule_id VARCHAR(64) NULL, PRIMARY KEY (id), UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), + UNIQUE KEY uq_registrations_number (registration_number), KEY idx_registrations_status (status), KEY idx_registrations_exam (exam_id), CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, @@ -310,6 +522,163 @@ const mysqlSchema = [ CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS test_centers ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + address VARCHAR(255) NOT NULL, + contact VARCHAR(100) NULL, + manager_name VARCHAR(100) NULL, + manager_phone VARCHAR(60) NULL, + emergency_phone VARCHAR(60) NULL, + gate_open_time VARCHAR(40) NULL, + transport VARCHAR(500) NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + notes VARCHAR(1000) NULL, + rooms TEXT NOT NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_centers_code (code), + UNIQUE KEY uq_centers_school_name (school_id, name), + CONSTRAINT fk_centers_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS test_rooms ( + id VARCHAR(64) NOT NULL, + center_id VARCHAR(64) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(40) NULL, + capacity INT UNSIGNED NOT NULL, + seat_start INT UNSIGNED NOT NULL DEFAULT 1, + seat_end INT UNSIGNED NOT NULL, + room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + notes VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_rooms_center_code (center_id, code), + KEY idx_rooms_center (center_id, status, code), + CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS center_change_requests ( + id VARCHAR(64) NOT NULL, + center_id VARCHAR(64) NULL, + school_id VARCHAR(64) NOT NULL, + request_type ENUM('create', 'update') NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + address VARCHAR(255) NOT NULL, + contact VARCHAR(100) NULL, + manager_name VARCHAR(100) NULL, + manager_phone VARCHAR(60) NULL, + emergency_phone VARCHAR(60) NULL, + gate_open_time VARCHAR(40) NULL, + transport VARCHAR(500) NULL, + center_status ENUM('active', 'inactive') NOT NULL, + notes VARCHAR(1000) NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + requested_by VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_center_changes_school (school_id, status, created_at), + CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL, + CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, + CONSTRAINT fk_center_change_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 center_change_rooms ( + id VARCHAR(64) NOT NULL, + request_id VARCHAR(64) NOT NULL, + room_id VARCHAR(64) NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(40) NULL, + capacity INT UNSIGNED NOT NULL, + seat_start INT UNSIGNED NOT NULL DEFAULT 1, + seat_end INT UNSIGNED NOT NULL, + room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, + status ENUM('active', 'inactive') NOT NULL, + notes VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_center_change_rooms_code (request_id, code), + CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS number_rules ( + id VARCHAR(64) NOT NULL, + name VARCHAR(120) NOT NULL, + separator VARCHAR(10) NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT FALSE, + created_by VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS number_rule_segments ( + id VARCHAR(64) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL, + value VARCHAR(60) NULL, + width INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + 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 workflow_definitions ( + id VARCHAR(64) NOT NULL, + business_type ENUM('profile_change', 'registration_review', 'center_change') NOT NULL, + name VARCHAR(120) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + updated_by VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_workflow_type_active (business_type, active), + CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_steps ( + id VARCHAR(64) NOT NULL, + workflow_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + admin_level ENUM('school', 'super') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_workflow_steps_position (workflow_id, position), + CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_instances ( + id VARCHAR(64) NOT NULL, + workflow_id VARCHAR(64) NOT NULL, + business_type VARCHAR(40) NOT NULL, + business_id VARCHAR(64) NOT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + current_step INT UNSIGNED NOT NULL DEFAULT 1, + assignee_id VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + completed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_workflow_inbox (status, assignee_id, business_type), + CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id), + CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_actions ( + id VARCHAR(64) NOT NULL, + instance_id VARCHAR(64) NOT NULL, + actor_id VARCHAR(64) NULL, + action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL, + note VARCHAR(500) NULL, + from_assignee_id VARCHAR(64) NULL, + to_assignee_id VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + KEY idx_workflow_actions_instance (instance_id, created_at), + CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE, + CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS audit_logs ( id VARCHAR(64) NOT NULL, actor_id VARCHAR(64) NULL, @@ -323,7 +692,11 @@ const mysqlSchema = [ ]; function validateState(state, source = '数据库') { - const collections = ['users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', 'auditLogs']; + const collections = [ + 'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', + 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', + 'numberRules', 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' + ]; if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { throw new Error(`${source}中的应用数据格式无效`); } @@ -337,7 +710,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 1, app_version = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 3, app_version = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.meta?.createdAt || new Date().toISOString() ); add( @@ -345,21 +718,39 @@ function buildSeedOperations(state) { state.organization?.name || '', state.organization?.code || '', state.organization?.phone || '', state.organization?.address || '' ); + for (const school of state.schools) { + add( + 'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', + school.id, school.name, school.code, nullable(school.address), school.active === false ? 0 : 1 + ); + } + + for (const schoolClass of state.classes) { + add( + 'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1 + ); + } + for (const user of state.users) { add( - 'INSERT INTO users (id, username, password_hash, role, display_name, created_at) VALUES (?, ?, ?, ?, ?, ?)', - user.id, user.username, user.passwordHash, user.role, user.displayName, user.createdAt + `INSERT INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId), + nullable(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt ); } for (const profile of state.candidateProfiles) { add( `INSERT INTO candidate_profiles ( - id, user_id, name, gender, id_number, phone, email, school, grade, address, + id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone, - nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.address), + nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId), + nullable(profile.classId), nullable(profile.address), nullable(profile.emergencyContact), nullable(profile.emergencyPhone), profile.status, nullable(profile.reviewNote), nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt ); @@ -397,10 +788,12 @@ function buildSeedOperations(state) { for (const registration of state.registrations) { add( `INSERT INTO registrations ( - id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, + registration_number, number_rule_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, - registration.paymentStatus, registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote) + registration.paymentStatus, registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote), + nullable(registration.registrationNumber), nullable(registration.numberRuleId) ); for (const subjectId of registration.subjectIds) { add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId); @@ -426,6 +819,102 @@ function buildSeedOperations(state) { ); } + for (const center of state.testCenters) { + add( + `INSERT INTO test_centers ( + id, school_id, code, name, address, contact, manager_name, manager_phone, emergency_phone, + gate_open_time, transport, status, notes, rooms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + center.id, center.schoolId, center.code, center.name, center.address, nullable(center.contact), + nullable(center.managerName), nullable(center.managerPhone), nullable(center.emergencyPhone), + nullable(center.gateOpenTime), nullable(center.transport), center.status || 'active', nullable(center.notes), + center.rooms || '', center.updatedAt + ); + } + + for (const room of state.testRooms) { + add( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity), + Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, room.status || 'active', nullable(room.notes) + ); + } + + for (const request of state.centerChangeRequests) { + add( + `INSERT INTO center_change_requests ( + id, center_id, school_id, request_type, code, name, address, contact, manager_name, manager_phone, + emergency_phone, gate_open_time, transport, center_status, notes, status, review_note, + requested_by, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.id, nullable(request.centerId), request.schoolId, request.requestType, request.code, request.name, + request.address, nullable(request.contact), nullable(request.managerName), nullable(request.managerPhone), + nullable(request.emergencyPhone), nullable(request.gateOpenTime), nullable(request.transport), + request.centerStatus || 'active', nullable(request.notes), request.status, nullable(request.reviewNote), + nullable(request.requestedBy), request.createdAt, nullable(request.reviewedAt) + ); + } + + for (const room of state.centerChangeRooms) { + add( + `INSERT INTO center_change_rooms ( + id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, room.requestId, nullable(room.roomId), room.code, room.name, room.building, nullable(room.floor), + Number(room.capacity), Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, + room.status || 'active', nullable(room.notes) + ); + } + + for (const rule of state.numberRules) { + add( + 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, nullable(rule.createdBy), rule.updatedAt + ); + rule.segments.forEach((segment, index) => add( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', + segment.id, rule.id, Number(segment.position || index + 1), segment.type, nullable(segment.value), Number(segment.width || 0) + )); + } + + for (const workflow of state.workflows) { + add( + `INSERT INTO workflow_definitions ( + id, business_type, name, active, updated_by, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, + nullable(workflow.updatedBy), workflow.updatedAt + ); + workflow.steps.forEach((step, index) => add( + `INSERT INTO workflow_steps ( + id, workflow_id, position, name, admin_level + ) VALUES (?, ?, ?, ?, ?)`, + step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel + )); + } + + for (const instance of state.workflowInstances) { + add( + `INSERT INTO workflow_instances ( + id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status, + Number(instance.currentStep || 1), nullable(instance.assigneeId), instance.createdAt, nullable(instance.completedAt) + ); + } + + for (const action of state.workflowActions) { + add( + `INSERT INTO workflow_actions ( + id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + action.id, action.instanceId, nullable(action.actorId), action.action, nullable(action.note), + nullable(action.fromAssigneeId), nullable(action.toAssigneeId), action.createdAt + ); + } + for (const log of state.auditLogs) { add( 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', @@ -466,6 +955,29 @@ function stateFromRows(rows) { seat: row.seat, generatedAt: row.generated_at }])); + const ruleSegments = new Map(); + for (const row of rows.numberRuleSegments) { + const segments = ruleSegments.get(row.rule_id) || []; + segments.push({ + id: row.id, + type: row.type, + value: row.value || '', + width: Number(row.width || 0), + position: Number(row.position) + }); + ruleSegments.set(row.rule_id, segments); + } + const workflowSteps = new Map(); + for (const row of rows.workflowSteps) { + const steps = workflowSteps.get(row.workflow_id) || []; + steps.push({ + id: row.id, + name: row.name, + adminLevel: row.admin_level, + position: Number(row.position) + }); + workflowSteps.set(row.workflow_id, steps); + } const organization = rows.organization; const state = { @@ -476,11 +988,29 @@ function stateFromRows(rows) { phone: organization.phone, address: organization.address }, + schools: rows.schools.map(row => ({ + id: row.id, + name: row.name, + code: row.code, + address: row.address || '', + active: Boolean(row.active) + })), + classes: rows.classes.map(row => ({ + id: row.id, + schoolId: row.school_id, + name: row.name, + grade: row.grade, + active: Boolean(row.active) + })), users: rows.users.map(row => ({ id: row.id, username: row.username, passwordHash: row.password_hash, role: row.role, + adminLevel: row.admin_level || (row.role === 'admin' ? 'super' : null), + schoolId: row.school_id || null, + classId: row.class_id || null, + active: row.active == null ? true : Boolean(row.active), displayName: row.display_name, createdAt: row.created_at })), @@ -494,6 +1024,8 @@ function stateFromRows(rows) { email: row.email || '', school: row.school || '', grade: row.grade || '', + schoolId: row.school_id || null, + classId: row.class_id || null, address: row.address || '', emergencyContact: row.emergency_contact || '', emergencyPhone: row.emergency_phone || '', @@ -541,6 +1073,8 @@ function stateFromRows(rows) { createdAt: row.created_at, reviewedAt: row.reviewed_at, reviewNote: row.review_note || '', + registrationNumber: row.registration_number || '', + numberRuleId: row.number_rule_id || null, admitCard: admitCards.get(row.id) || null })), results: rows.results.map(row => ({ @@ -553,6 +1087,113 @@ function stateFromRows(rows) { updatedAt: row.updated_at, publishedAt: row.published_at })), + testCenters: rows.testCenters.map(row => ({ + id: row.id, + schoolId: row.school_id, + code: row.code || '', + name: row.name, + address: row.address, + contact: row.contact || '', + managerName: row.manager_name || '', + managerPhone: row.manager_phone || '', + emergencyPhone: row.emergency_phone || '', + gateOpenTime: row.gate_open_time || '', + transport: row.transport || '', + status: row.status || 'active', + notes: row.notes || '', + rooms: row.rooms || '', + updatedAt: row.updated_at + })), + testRooms: rows.testRooms.map(row => ({ + id: row.id, + centerId: row.center_id, + code: row.code, + name: row.name, + building: row.building, + floor: row.floor || '', + capacity: Number(row.capacity), + seatStart: Number(row.seat_start), + seatEnd: Number(row.seat_end), + roomType: row.room_type, + status: row.status, + notes: row.notes || '' + })), + centerChangeRequests: rows.centerChangeRequests.map(row => ({ + id: row.id, + centerId: row.center_id, + schoolId: row.school_id, + requestType: row.request_type, + code: row.code, + name: row.name, + address: row.address, + contact: row.contact || '', + managerName: row.manager_name || '', + managerPhone: row.manager_phone || '', + emergencyPhone: row.emergency_phone || '', + gateOpenTime: row.gate_open_time || '', + transport: row.transport || '', + centerStatus: row.center_status, + notes: row.notes || '', + status: row.status, + reviewNote: row.review_note || '', + requestedBy: row.requested_by, + createdAt: row.created_at, + reviewedAt: row.reviewed_at + })), + centerChangeRooms: rows.centerChangeRooms.map(row => ({ + id: row.id, + requestId: row.request_id, + roomId: row.room_id, + code: row.code, + name: row.name, + building: row.building, + floor: row.floor || '', + capacity: Number(row.capacity), + seatStart: Number(row.seat_start), + seatEnd: Number(row.seat_end), + roomType: row.room_type, + status: row.status, + notes: row.notes || '' + })), + numberRules: rows.numberRules.map(row => ({ + id: row.id, + name: row.name, + separator: row.separator, + active: Boolean(row.active), + createdBy: row.created_by, + updatedAt: row.updated_at, + segments: ruleSegments.get(row.id) || [] + })), + workflows: rows.workflows.map(row => ({ + id: row.id, + businessType: row.business_type, + name: row.name, + active: Boolean(row.active), + updatedBy: row.updated_by, + updatedAt: row.updated_at, + steps: workflowSteps.get(row.id) || [] + })), + workflowInstances: rows.workflowInstances.map(row => ({ + id: row.id, + workflowId: row.workflow_id, + businessType: row.business_type, + businessId: row.business_id, + status: row.status, + currentStep: Number(row.current_step), + assigneeId: row.assignee_id, + createdAt: row.created_at, + completedAt: row.completed_at + })), + workflowActions: rows.workflowActions.map(row => ({ + id: row.id, + instanceId: row.instance_id, + actorId: row.actor_id, + action: row.action, + note: row.note || '', + fromAssigneeId: row.from_assignee_id, + toAssigneeId: row.to_assignee_id, + createdAt: row.created_at + })), auditLogs: rows.auditLogs.map(row => ({ id: row.id, actorId: row.actor_id, @@ -568,6 +1209,8 @@ function readSqliteRows(connection) { return { system: connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(), organization: connection.prepare('SELECT * FROM organization WHERE id = 1').get(), + schools: connection.prepare('SELECT * FROM schools ORDER BY name, id').all(), + classes: connection.prepare('SELECT * FROM school_classes ORDER BY school_id, grade, name, id').all(), users: connection.prepare('SELECT * FROM users ORDER BY created_at, id').all(), profiles: connection.prepare('SELECT * FROM candidate_profiles ORDER BY updated_at, id').all(), notices: connection.prepare('SELECT * FROM notices ORDER BY publish_at, created_at, id').all(), @@ -577,6 +1220,16 @@ function readSqliteRows(connection) { registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(), admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(), results: connection.prepare('SELECT * FROM results ORDER BY id').all(), + testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(), + testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(), + centerChangeRequests: connection.prepare('SELECT * FROM center_change_requests ORDER BY created_at DESC, id').all(), + 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(), + 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(), + workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(), auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all() }; } @@ -587,6 +1240,8 @@ async function readMysqlRows(connection) { return { system: await one('SELECT * FROM schema_metadata WHERE id = 1'), organization: await one('SELECT * FROM organization WHERE id = 1'), + schools: await query('SELECT * FROM schools ORDER BY name, id'), + classes: await query('SELECT * FROM school_classes ORDER BY school_id, grade, name, id'), users: await query('SELECT * FROM users ORDER BY created_at, id'), profiles: await query('SELECT * FROM candidate_profiles ORDER BY updated_at, id'), notices: await query('SELECT * FROM notices ORDER BY publish_at, created_at, id'), @@ -596,6 +1251,16 @@ async function readMysqlRows(connection) { registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'), admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'), results: await query('SELECT * FROM results ORDER BY id'), + testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'), + testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'), + centerChangeRequests: await query('SELECT * FROM center_change_requests ORDER BY created_at DESC, id'), + 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'), + 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'), + workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'), auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC') }; } @@ -615,52 +1280,88 @@ function auditOperation(log) { ); } +function workflowInstanceOperation(instance) { + return operation( + `INSERT INTO workflow_instances ( + id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status, + Number(instance.currentStep || 1), optional(instance.assigneeId), instance.createdAt, optional(instance.completedAt) + ); +} + +function workflowActionOperation(action) { + return operation( + `INSERT INTO workflow_actions ( + id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + action.id, action.instanceId, optional(action.actorId), action.action, optional(action.note), + optional(action.fromAssigneeId), optional(action.toAssigneeId), action.createdAt + ); +} + +function workflowCreateOperations(instance, action) { + return [workflowInstanceOperation(instance), workflowActionOperation(action)]; +} + function createRepository({ client, location, read, transaction, close }) { return { client, location, read, close, - async createCandidate(user, profile) { - await transaction([ + async createCandidate(user, profile, instance, action) { + const operations = [ operation( - 'INSERT INTO users (id, username, password_hash, role, display_name, created_at) VALUES (?, ?, ?, ?, ?, ?)', - user.id, user.username, user.passwordHash, user.role, user.displayName, user.createdAt + `INSERT INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId), + optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt ), operation( `INSERT INTO candidate_profiles ( - id, user_id, name, gender, id_number, phone, email, school, grade, address, + id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, - optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.address), + optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId), + optional(profile.classId), optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt ) - ]); + ]; + if (instance && action) operations.push(...workflowCreateOperations(instance, action)); + await transaction(operations); }, - async updateCandidateProfile(profile, displayName) { - await transaction([ + async updateCandidateProfile(profile, displayName, instance, action) { + const operations = [ operation( `UPDATE candidate_profiles SET name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?, - emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ? + school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, + reviewed_at = ?, reviewer_id = ?, updated_at = ? WHERE id = ?`, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), - optional(profile.school), optional(profile.grade), optional(profile.address), optional(profile.emergencyContact), - optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), + optional(profile.school), optional(profile.grade), optional(profile.address), optional(profile.schoolId), + optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, + optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt, profile.id ), operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) - ]); + ]; + if (instance && action) operations.push(...workflowCreateOperations(instance, action)); + await transaction(operations); }, - async createRegistration(registration) { + async createRegistration(registration, instance, action) { const operations = [operation( `INSERT INTO registrations ( - id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, + registration_number, number_rule_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, - registration.paymentStatus, registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote) + registration.paymentStatus, registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote), + optional(registration.registrationNumber), optional(registration.numberRuleId) )]; for (const subjectId of registration.subjectIds) { operations.push(operation( @@ -668,6 +1369,7 @@ function createRepository({ client, location, read, transaction, close }) { registration.id, subjectId )); } + if (instance && action) operations.push(...workflowCreateOperations(instance, action)); await transaction(operations); }, async reviewCandidate(profile, log) { @@ -689,6 +1391,190 @@ function createRepository({ client, location, read, transaction, close }) { auditOperation(log) ]); }, + async processWorkflow(instance, action, business, 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) + ]; + if (instance.businessType === 'profile_change') { + operations.push(operation( + `UPDATE candidate_profiles SET + status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`, + business.status, optional(business.reviewNote), optional(business.reviewedAt), + optional(business.reviewerId), business.id + )); + } else if (instance.businessType === 'registration_review') { + operations.push(operation( + `UPDATE registrations SET + status = ?, payment_status = ?, reviewed_at = ?, review_note = ?, + registration_number = ?, number_rule_id = ? WHERE id = ?`, + business.status, business.paymentStatus, optional(business.reviewedAt), optional(business.reviewNote), + optional(business.registrationNumber), optional(business.numberRuleId), business.id + )); + } else { + 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 + )); + } + if (log) 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), + workflowActionOperation(action) + ]; + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async saveWorkflow(workflow, log) { + const operations = [ + operation( + `UPDATE workflow_definitions SET name = ?, active = ?, updated_by = ?, updated_at = ? WHERE id = ?`, + workflow.name, workflow.active ? 1 : 0, optional(workflow.updatedBy), workflow.updatedAt, workflow.id + ), + operation('DELETE FROM workflow_steps WHERE workflow_id = ?', workflow.id) + ]; + workflow.steps.forEach((step, index) => operations.push(operation( + `INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)`, + step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel + ))); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async saveNumberRule(rule, isNew, log) { + const operations = [operation('UPDATE number_rules SET active = 0 WHERE active = 1')]; + if (isNew) { + operations.push(operation( + 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + rule.id, rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt + )); + } else { + operations.push( + operation( + 'UPDATE number_rules SET name = ?, separator = ?, active = ?, created_by = ?, updated_at = ? WHERE id = ?', + rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt, rule.id + ), + operation('DELETE FROM number_rule_segments WHERE rule_id = ?', rule.id) + ); + } + rule.segments.forEach((segment, index) => operations.push(operation( + `INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)`, + segment.id, rule.id, Number(segment.position || index + 1), segment.type, + optional(segment.value), Number(segment.width || 0) + ))); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async createAdmin(user, log) { + await transaction([ + operation( + `INSERT INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, + user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), + user.active === false ? 0 : 1, user.displayName, user.createdAt + ), + auditOperation(log) + ]); + }, + async saveTestCenter(center, isNew, log) { + const centerOperation = isNew + ? operation( + `INSERT INTO test_centers ( + id, school_id, name, address, contact, rooms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms, center.updatedAt + ) + : operation( + `UPDATE test_centers SET + name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`, + center.name, center.address, optional(center.contact), center.rooms, center.updatedAt, center.id + ); + await transaction([centerOperation, auditOperation(log)]); + }, + async createCenterChangeRequest(request, rooms, instance, action, log) { + const operations = [ + operation( + `INSERT INTO center_change_requests ( + id, center_id, school_id, request_type, code, name, address, contact, manager_name, manager_phone, + emergency_phone, gate_open_time, transport, center_status, notes, status, review_note, + requested_by, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.id, optional(request.centerId), request.schoolId, request.requestType, request.code, request.name, + request.address, optional(request.contact), optional(request.managerName), optional(request.managerPhone), + optional(request.emergencyPhone), optional(request.gateOpenTime), optional(request.transport), + request.centerStatus, optional(request.notes), request.status, optional(request.reviewNote), + optional(request.requestedBy), request.createdAt, optional(request.reviewedAt) + ), + ...workflowCreateOperations(instance, action) + ]; + for (const room of rooms) operations.push(operation( + `INSERT INTO center_change_rooms ( + id, request_id, room_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, request.id, optional(room.roomId), room.code, room.name, room.building, optional(room.floor), + Number(room.capacity), Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, + optional(room.notes) + )); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async applyCenterChange(request, instance, action, center, rooms, 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 center_change_requests SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + request.status, optional(request.reviewNote), optional(request.reviewedAt), request.id + ) + ]; + if (request.status === 'approved' && center) { + if (request.requestType === 'create') { + operations.push(operation( + `INSERT INTO test_centers ( + id, school_id, code, name, address, contact, manager_name, manager_phone, emergency_phone, + gate_open_time, transport, status, notes, rooms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + center.id, center.schoolId, center.code, center.name, center.address, optional(center.contact), + optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes), + center.rooms || '', center.updatedAt + )); + } else { + operations.push(operation( + `UPDATE test_centers SET + code = ?, name = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?, + emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ? + WHERE id = ?`, + center.code, center.name, center.address, optional(center.contact), optional(center.managerName), + optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime), + optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt, center.id + )); + } + operations.push(operation('DELETE FROM test_rooms WHERE center_id = ?', center.id)); + for (const room of rooms) operations.push(operation( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, center.id, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes) + )); + } + operations.push(auditOperation(log)); + await transaction(operations); + }, async createAdmitCard(registrationId, admitCard, log) { await transaction([ operation( @@ -700,6 +1586,24 @@ 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 assignRegistrationNumbers(registrations, log) { + const operations = 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 ( @@ -793,15 +1697,149 @@ async function createSqliteStore({ path, seed }) { const connection = new DatabaseSync(path, { timeout: 5000 }); connection.exec('PRAGMA journal_mode = WAL;'); connection.exec('PRAGMA synchronous = NORMAL;'); + const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + const ensureColumns = (table, columns) => { + if (!tableExists(table)) return; + const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name)); + for (const [name, definition] of columns) { + if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } + }; + ensureColumns('users', [ + ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'] + ]); + ensureColumns('candidate_profiles', [['school_id', 'TEXT'], ['class_id', 'TEXT']]); + ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]); + ensureColumns('test_centers', [ + ['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], + ['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'] + ]); + 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')) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE workflow_definitions_v3 ( + id TEXT PRIMARY KEY, + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')), + 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) + 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; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } connection.exec(sqliteSchema); + const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(); + if (existingSystem && Number(existingSystem.app_version || 1) < 2) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const school of extension.schools) connection.prepare( + 'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)' + ).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1); + for (const schoolClass of extension.classes) connection.prepare( + 'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)' + ).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1); + connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run(); + for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare( + `INSERT OR IGNORE INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)` + ).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt); + for (const profile of extension.candidateProfiles) connection.prepare( + `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) + WHERE school = ? AND grade = ?` + ).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade); + if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) { + for (const center of extension.testCenters) connection.prepare( + 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt); + } + if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) { + for (const rule of extension.numberRules) { + connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt); + rule.segments.forEach((segment, index) => connection.prepare( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)' + ).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0))); + } + } + if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) { + for (const workflow of extension.workflows) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt); + workflow.steps.forEach((step, index) => connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel)); + } + } + connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 3) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const center of extension.testCenters) connection.prepare( + `UPDATE test_centers SET + code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), + manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), + gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), + status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) + WHERE id = ?` + ).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id); + connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run(); + if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) { + for (const room of extension.testRooms) connection.prepare( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)); + } + const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); + if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt); + for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); + } + connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 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, created_at) - VALUES (1, 1, ?, ?) + VALUES (1, 3, ?, ?) `).run(Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); @@ -856,7 +1894,136 @@ async function createMysqlStore({ seed }) { }); for (const statement of mysqlSchema) await pool.execute(statement); + const mysqlColumnMigrations = [ + "ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL", + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', + 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL', + 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL', + 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL', + '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" + ]; + for (const statement of mysqlColumnMigrations) await pool.execute(statement); const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); + if (existing.length) { + const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1'); + if (Number(metadataRows[0]?.app_version || 1) < 2) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const school of extension.schools) await connection.execute( + 'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', + [school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1] + ); + for (const schoolClass of extension.classes) await connection.execute( + 'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + [schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1] + ); + await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'"); + for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute( + `INSERT IGNORE INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, + [user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt] + ); + for (const profile of extension.candidateProfiles) await connection.execute( + `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) + WHERE school = ? AND grade = ?`, + [optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade] + ); + const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1'); + if (!centerRows.length) for (const center of extension.testCenters) await connection.execute( + 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt] + ); + const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1'); + if (!ruleRows.length) for (const rule of extension.numberRules) { + await connection.execute( + 'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt] + ); + for (const [index, segment] of rule.segments.entries()) await connection.execute( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', + [segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)] + ); + } + const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1'); + if (!workflowRows.length) for (const workflow of extension.workflows) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt] + ); + for (const [index, step] of workflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 3) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const center of extension.testCenters) await connection.execute( + `UPDATE test_centers SET + code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), + manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), + gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), + status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) + WHERE id = ?`, + [center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id] + ); + await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''"); + const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1'); + if (!roomRows.length) for (const room of extension.testRooms) await connection.execute( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)] + ); + const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); + const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1"); + if (centerWorkflow && !centerWorkflowRows.length) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt] + ); + for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + } if (!existing.length) { const initialState = seed(); const connection = await pool.getConnection(); @@ -864,7 +2031,7 @@ async function createMysqlStore({ seed }) { await connection.beginTransaction(); const [insert] = await connection.execute(` INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, created_at) - VALUES (1, 1, ?, ?) + VALUES (1, 3, ?, ?) `, [Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); @@ -878,6 +2045,11 @@ async function createMysqlStore({ seed }) { } } + const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); + if (!centerCodeIndexes.length) { + await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); + } + const transaction = async operations => { const connection = await pool.getConnection(); try { diff --git a/package.json b/package.json index ad88aa9..0855f62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hengzhun-exam-system", - "version": "1.0.0", + "version": "1.0.3", "private": true, "type": "module", "scripts": { diff --git a/server.mjs b/server.mjs index 55bdce2..9858d07 100644 --- a/server.mjs +++ b/server.mjs @@ -39,25 +39,40 @@ function verifyPassword(password, stored) { function seedDatabase() { const adminId = 'usr_admin'; + const schoolAdminId = 'usr_school_admin'; + const schoolAdmin2Id = 'usr_school_admin_2'; const candidateId = 'usr_demo'; const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 1, createdAt: nowIso() }, + meta: { version: 3, createdAt: nowIso() }, organization: { name: '海州市教育考试中心', code: 'HZ-EDU-032', phone: '0518-8602 3158', address: '海州市清河区文教路 18 号' }, + schools: [ + { id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true }, + { id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true } + ], + classes: [ + { id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true }, + { id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true }, + { id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true } + ], users: [ - { id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', displayName: '林老师', createdAt: nowIso() }, - { id: candidateId, username: '13800138000', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', createdAt: nowIso() } + { id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() }, + { id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() }, + { id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() }, + { id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() }, + { id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() }, + { id: candidateId, username: '13800138000', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, createdAt: nowIso() } ], candidateProfiles: [ { id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', - phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', + phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000', status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z' } @@ -95,7 +110,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', + status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '', numberRuleId: null, admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' } } ], @@ -103,6 +118,42 @@ function seedDatabase() { { id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }, { id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' } ], + testCenters: [ + { id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() }, + { id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() } + ], + testRooms: [ + { id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatStart: 1, seatEnd: 40, roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, + { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' } + ], + centerChangeRequests: [], + centerChangeRooms: [], + numberRules: [ + { id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [ + { id: 'segment_year', position: 1, type: 'year', value: '', width: 4 }, + { id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 }, + { id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 }, + { id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } + ] } + ], + workflows: [ + { id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' }, + { id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' }, + { id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' } + ] } + ], + workflowInstances: [], + workflowActions: [], auditLogs: [ { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } ] @@ -152,11 +203,20 @@ async function currentUser(request) { return null; } const db = await readDb(); - return db.users.find(user => user.id === session.userId) || null; + const user = db.users.find(item => item.id === session.userId) || null; + return user?.active === false ? null : user; } function safeUser(user) { - return { id: user.id, username: user.username, role: user.role, displayName: user.displayName }; + return { + id: user.id, + username: user.username, + role: user.role, + adminLevel: user.adminLevel || null, + schoolId: user.schoolId || null, + classId: user.classId || null, + displayName: user.displayName + }; } async function requireUser(request, response, role) { @@ -172,10 +232,193 @@ async function requireUser(request, response, role) { return user; } +const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' }; + +const permissionsByLevel = { + super: ['*'], + school: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], + class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read'] +}; + +function hasPermission(user, permission) { + if (user?.role !== 'admin') return false; + const permissions = permissionsByLevel[user.adminLevel || 'super'] || []; + return permissions.includes('*') || permissions.includes(permission); +} + +function requirePermission(user, response, permission) { + if (hasPermission(user, permission)) return true; + sendError(response, 403, '当前管理员层级无权执行此操作'); + return false; +} + +function profileInScope(user, profile) { + if (user.adminLevel === 'super') return true; + if (user.adminLevel === 'school') return Boolean(user.schoolId && profile.schoolId === user.schoolId); + return Boolean(user.classId && profile.classId === user.classId); +} + +function registrationInScope(db, user, registration) { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + return Boolean(profile && profileInScope(user, profile)); +} + +function adminScopeLabel(db, user) { + if (user.adminLevel === 'super') return '全部学校与班级'; + const school = db.schools.find(item => item.id === user.schoolId)?.name || '未绑定学校'; + if (user.adminLevel === 'school') return school; + const schoolClass = db.classes.find(item => item.id === user.classId)?.name || '未绑定班级'; + return `${school} · ${schoolClass}`; +} + +function adminsForStep(db, adminLevel, profile) { + return db.users.filter(item => { + if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false; + if (adminLevel === 'super') return true; + if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId); + return Boolean(profile?.classId && item.classId === profile.classId); + }); +} + +function activeWorkflow(db, businessType) { + return db.workflows.find(item => item.businessType === businessType && item.active); +} + +function createWorkflowSubmission(db, businessType, businessId, profile, actorId = null) { + const workflow = activeWorkflow(db, businessType); + if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 }); + const firstStep = workflow.steps[0]; + const assignee = adminsForStep(db, firstStep.adminLevel, profile)[0]; + if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 }); + const instance = { + id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1, + assigneeId: assignee.id, createdAt: nowIso(), completedAt: null + }; + const action = { + id: uid('flow_action'), instanceId: instance.id, actorId, action: 'submit', note: '提交审批', + fromAssigneeId: null, toAssigneeId: assignee.id, createdAt: nowIso() + }; + return { workflow, instance, action }; +} + +function workflowView(db, instance) { + if (!instance) return null; + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const assignee = db.users.find(item => item.id === instance.assigneeId); + const actions = db.workflowActions.filter(item => item.instanceId === instance.id).map(item => ({ + ...item, + actorName: db.users.find(user => user.id === item.actorId)?.displayName || '系统', + fromAssigneeName: db.users.find(user => user.id === item.fromAssigneeId)?.displayName || '', + toAssigneeName: db.users.find(user => user.id === item.toAssigneeId)?.displayName || '' + })); + return { + ...instance, + workflowName: workflow?.name || '未命名流程', + steps: workflow?.steps || [], + currentStepDetail: workflow?.steps.find(step => step.position === instance.currentStep) || null, + assignee: assignee ? safeUser(assignee) : null, + actions + }; +} + +function pendingWorkflow(db, businessType, businessId) { + return db.workflowInstances.find(item => item.businessType === businessType && item.businessId === businessId && item.status === 'pending'); +} + +function registrationSequence(db, rule, schoolId, year) { + const prefixParts = rule.segments.filter(item => item.type !== 'sequence').map(segment => segment.type === 'year' ? year : segment.type === 'school_code' ? db.schools.find(school => school.id === schoolId)?.code || '' : '').filter(Boolean); + const prefix = prefixParts.join(rule.separator); + return db.registrations.filter(item => item.registrationNumber && (!prefix || item.registrationNumber.startsWith(prefix))).length + 1; +} + +function generateRegistrationNumber(db, registration, profile) { + const rule = db.numberRules.find(item => item.active); + if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 }); + const school = db.schools.find(item => item.id === profile.schoolId); + const exam = db.exams.find(item => item.id === registration.examId); + const year = String(new Date(exam?.examStart || Date.now()).getFullYear()); + const sequence = registrationSequence(db, rule, profile.schoolId, year); + const parts = rule.segments.map(segment => { + if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4)); + if (segment.type === 'school_code') return school?.code || 'NOSCHOOL'; + if (segment.type === 'gender') return profile.gender === '男' ? 'M' : profile.gender === '女' ? 'F' : 'X'; + if (segment.type === 'sequence') return String(sequence).padStart(Math.max(1, segment.width || 4), '0'); + return cleanText(segment.value, 20).toUpperCase(); + }); + return { number: parts.join(rule.separator), ruleId: rule.id }; +} + function cleanText(value, max = 200) { return String(value ?? '').trim().slice(0, max); } +function centerScopeProfile(db, schoolId) { + const school = db.schools.find(item => item.id === schoolId); + return { schoolId, classId: null, school: school?.name || '', grade: '' }; +} + +function workflowScopeProfile(db, instance) { + if (instance.businessType === 'profile_change') return db.candidateProfiles.find(item => item.id === instance.businessId) || null; + if (instance.businessType === 'registration_review') { + const registration = db.registrations.find(item => item.id === instance.businessId); + return db.candidateProfiles.find(item => item.userId === registration?.userId) || null; + } + const change = db.centerChangeRequests.find(item => item.id === instance.businessId); + return change ? centerScopeProfile(db, change.schoolId) : null; +} + +function centerChangeView(db, change) { + const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id); + return { + ...change, + schoolName: db.schools.find(item => item.id === change.schoolId)?.name || '', + rooms: db.centerChangeRooms.filter(item => item.requestId === change.id), + workflow: workflowView(db, instance) + }; +} + +function parseCenterChange(db, body, schoolId, center = null) { + const code = cleanText(body.code, 30).toUpperCase(); + const name = cleanText(body.name, 100); + const address = cleanText(body.address, 200); + const rooms = Array.isArray(body.rooms) ? body.rooms : []; + if (!code || !name || !address) throw Object.assign(new Error('请填写考点代码、名称和详细地址'), { status: 400 }); + if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 }); + const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id) + || db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id); + if (duplicateCenter) throw Object.assign(new Error('考点代码已被正式档案或待审批申请占用'), { status: 409 }); + const roomCodes = new Set(); + const normalizedRooms = rooms.map((room, index) => { + const roomCode = cleanText(room.code, 30).toUpperCase(); + const roomName = cleanText(room.name, 80); + const building = cleanText(room.building, 80); + const capacity = Number(room.capacity); + const seatStart = Number(room.seatStart); + const seatEnd = Number(room.seatEnd); + if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1 || !Number.isInteger(seatStart) || !Number.isInteger(seatEnd) || seatStart < 1 || seatEnd < seatStart) { + throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋、容量或座位号范围无效`), { status: 400 }); + } + if (seatEnd - seatStart + 1 > capacity) throw Object.assign(new Error(`第 ${index + 1} 个考场的座位号数量不能超过考场容量`), { status: 400 }); + if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 }); + roomCodes.add(roomCode); + return { + id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName, + building, floor: cleanText(room.floor, 30), capacity, seatStart, seatEnd, + roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard', + status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300) + }; + }); + return { + center: { + schoolId, code, name, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50), + managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30), + gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500), + centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000) + }, + rooms: normalizedRooms + }; +} + function maskId(value) { const text = String(value || ''); return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text; @@ -194,7 +437,9 @@ function publicExam(exam) { function examRegistrationView(db, registration) { const exam = db.exams.find(item => item.id === registration.examId); const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)); - return { ...registration, exam, subjects }; + const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending') + || db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0]; + return { ...registration, exam, subjects, workflow: workflowView(db, instance) }; } function logAction(db, user, action, detail) { @@ -220,7 +465,7 @@ async function handlePublic(pathname, response) { if (pathname === '/api/public/home') { const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)); const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })); - return sendJson(response, 200, { ok: true, organization: db.organization, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } }); + return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } }); } const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/); if (noticeMatch) { @@ -236,7 +481,7 @@ async function handleAuth(request, response, pathname) { if (!user) return sendJson(response, 200, { ok: true, user: null }); const db = await readDb(); const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null; - return sendJson(response, 200, { ok: true, user: safeUser(user), profile }); + return sendJson(response, 200, { ok: true, user: safeUser(user), profile, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) }); } if (request.method === 'POST' && pathname === '/api/auth/register') { const body = await readJson(request); @@ -248,18 +493,24 @@ async function handleAuth(request, response, pathname) { if (!username || !name || !idNumber || !phone) return sendError(response, 400, '请完整填写账号和身份信息'); if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位'); const db = await readDb(); + const schoolId = cleanText(body.schoolId, 64); + 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 (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级'); if (db.users.some(user => user.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该账号已注册'); if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册'); const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() }; - const profile = { id: uid('profile'), userId: user.id, name, idNumber, phone, gender: cleanText(body.gender, 10), email: cleanText(body.email, 80), school: cleanText(body.school, 80), grade: cleanText(body.grade, 50), address: '', emergencyContact: '', emergencyPhone: '', status: 'pending', reviewNote: '', updatedAt: nowIso() }; - await database.createCandidate(user, profile); + const profile = { id: uid('profile'), userId: user.id, name, idNumber, phone, gender: cleanText(body.gender, 10), email: cleanText(body.email, 80), school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', status: 'pending', reviewNote: '', updatedAt: nowIso() }; + const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); + await database.createCandidate(user, profile, instance, action); return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' }); } if (request.method === 'POST' && pathname === '/api/auth/login') { const body = await readJson(request); const db = await readDb(); const user = db.users.find(item => item.username.toLowerCase() === cleanText(body.username, 50).toLowerCase()); - if (!user || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确'); + if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确'); const token = randomBytes(32).toString('hex'); sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 }); return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` }); @@ -283,19 +534,34 @@ async function handleCandidate(request, response, pathname) { const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)); const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId)); const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5); - return sendJson(response, 200, { ok: true, profile, registrations, results, notices }); + const profileInstance = pendingWorkflow(db, 'profile_change', profile.id) + || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0]; + return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices }); + } + if (request.method === 'GET' && pathname === '/api/candidate/profile') { + const instance = pendingWorkflow(db, 'profile_change', profile.id) + || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0]; + return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) }); } - if (request.method === 'GET' && pathname === '/api/candidate/profile') return sendJson(response, 200, { ok: true, profile }); if (request.method === 'PUT' && pathname === '/api/candidate/profile') { const body = await readJson(request); - const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'school', 'grade', 'address', 'emergencyContact', 'emergencyPhone']; + const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone']; for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80); + const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active); + const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active); + if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级'); + profile.schoolId = school.id; + profile.classId = schoolClass.id; + profile.school = school.name; + profile.grade = schoolClass.name; if (!profile.name || !profile.idNumber || !profile.phone || !profile.school) return sendError(response, 400, '姓名、证件号码、手机号和学校为必填项'); if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用'); profile.status = 'pending'; profile.reviewNote = ''; profile.updatedAt = nowIso(); - await database.updateCandidateProfile(profile, profile.name); + const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id); + const submission = existingWorkflow ? null : createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); + await database.updateCandidateProfile(profile, profile.name, submission?.instance, submission?.action); return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' }); } if (request.method === 'GET' && pathname === '/api/candidate/exams') { @@ -316,8 +582,9 @@ async function handleCandidate(request, response, pathname) { if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试'); const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])]; if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目'); - const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), admitCard: null }; - await database.createRegistration(registration); + const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), registrationNumber: '', numberRuleId: null, admitCard: null }; + const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id); + await database.createRegistration(registration, instance, action); return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); } if (request.method === 'GET' && pathname === '/api/candidate/results') { @@ -353,31 +620,344 @@ async function handleAdmin(request, response, pathname) { if (!user) return true; const db = await readDb(); + if (request.method === 'GET' && pathname === '/api/admin/context') { + return sendJson(response, 200, { + ok: true, + admin: safeUser(user), + adminLevelName: adminLevelNames[user.adminLevel || 'super'], + permissions: permissionsByLevel[user.adminLevel || 'super'], + scopeLabel: adminScopeLabel(db, user), + schools: db.schools, + classes: db.classes + }); + } + + if (pathname === '/api/admin/admins' && request.method === 'GET') { + if (!requirePermission(user, response, '*')) return true; + const admins = db.users.filter(item => item.role === 'admin').map(item => ({ + ...safeUser(item), + active: item.active, + levelName: adminLevelNames[item.adminLevel], + schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', + className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '' + })); + return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes }); + } + if (pathname === '/api/admin/admins' && request.method === 'POST') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const username = cleanText(body.username, 50); + const password = String(body.password || ''); + const displayName = cleanText(body.displayName, 50); + const adminLevel = cleanText(body.adminLevel, 20); + if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码'); + if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在'); + const schoolId = adminLevel === 'super' ? null : cleanText(body.schoolId, 64); + const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null; + if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校'); + if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级'); + const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() }; + const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`); + await database.createAdmin(created, log); + return sendJson(response, 201, { ok: true, admin: safeUser(created) }); + } + + if (pathname === '/api/admin/centers' && request.method === 'GET') { + if (!requirePermission(user, response, 'centers.read')) return true; + const centers = db.testCenters.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId).map(item => ({ + ...item, + schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', + rooms: db.testRooms.filter(room => room.centerId === item.id), + totalCapacity: db.testRooms.filter(room => room.centerId === item.id && room.status === 'active').reduce((sum, room) => sum + Number(room.capacity || 0), 0), + pendingChange: db.centerChangeRequests.some(change => change.centerId === item.id && change.status === 'pending') + })); + const changeRequests = db.centerChangeRequests + .filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .map(item => centerChangeView(db, item)); + return sendJson(response, 200, { ok: true, centers, changeRequests, schools: user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId) }); + } + if (pathname === '/api/admin/centers' && request.method === 'POST') { + if (!requirePermission(user, response, 'centers.write')) return true; + const body = await readJson(request); + const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId; + if (!db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '考点必须归属有效学校'); + const parsed = parseCenterChange(db, body, schoolId); + const change = { id: uid('center_change'), centerId: null, schoolId, requestType: 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; + const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, schoolId), user.id); + const log = logAction(db, user, '提交新增考点审批', `${change.name} · ${parsed.rooms.length} 个考场`); + await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log); + return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } }); + } + const centerMatch = pathname.match(/^\/api\/admin\/centers\/([^/]+)$/); + if (centerMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, 'centers.write')) return true; + const body = await readJson(request); + const center = db.testCenters.find(item => item.id === centerMatch[1]); + if (!center) return sendError(response, 404, '考点不存在'); + if (user.adminLevel !== 'super' && center.schoolId !== user.schoolId) return sendError(response, 403, '只能维护本校考点'); + if (db.centerChangeRequests.some(item => item.centerId === center.id && item.status === 'pending')) return sendError(response, 409, '该考点已有待审批变更,请处理完成后再提交'); + const parsed = parseCenterChange(db, body, center.schoolId, center); + const change = { id: uid('center_change'), centerId: center.id, schoolId: center.schoolId, requestType: 'update', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; + const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, center.schoolId), user.id); + const log = logAction(db, user, '提交考点变更审批', `${change.name} · ${parsed.rooms.length} 个考场`); + await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log); + return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } }); + } + const centerChangeMatch = pathname.match(/^\/api\/admin\/center-change-requests\/([^/]+)$/); + if (centerChangeMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, 'centers.write')) return true; + const body = await readJson(request); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效'); + const change = db.centerChangeRequests.find(item => item.id === centerChangeMatch[1] && item.status === 'pending'); + if (!change) return sendError(response, 404, '待审批的考点变更不存在'); + if (user.adminLevel !== 'super' && change.schoolId !== user.schoolId) return sendError(response, 403, '该变更不在你的学校范围内'); + const instance = pendingWorkflow(db, 'center_change', change.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' ? '审批考点变更' : '退回考点变更', `${change.name} · ${note || '无备注'}`); + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + change.status = 'rejected'; change.reviewNote = note; change.reviewedAt = nowIso(); + await database.applyCenterChange(change, instance, action, null, [], 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, change.schoolId))[0]; + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + change.reviewNote = note; + await database.processWorkflow(instance, action, change, log); + } else { + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + change.status = 'approved'; change.reviewNote = note; change.reviewedAt = nowIso(); + const centerId = change.centerId || uid('center'); + const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id); + const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId })); + const center = { + id: centerId, schoolId: change.schoolId, code: change.code, name: change.name, address: change.address, + contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone, + emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport, + status: change.centerStatus, notes: change.notes, + rooms: rooms.map(room => `${room.building} ${room.name}`).join(';'), updatedAt: nowIso() + }; + await database.applyCenterChange(change, instance, action, center, rooms, log); + } + return sendJson(response, 200, { ok: true, changeRequest: centerChangeView({ ...db, workflowActions: [...db.workflowActions, action] }, change) }); + } + + if (pathname === '/api/admin/number-rules' && request.method === 'GET') { + if (!requirePermission(user, response, '*')) return true; + const rule = db.numberRules.find(item => item.active) || null; + const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id }; + let preview = ''; + if (rule) { + const sampleRegistration = { examId: db.exams[0]?.id }; + preview = generateRegistrationNumber(db, sampleRegistration, 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 }); + } + if (pathname === '/api/admin/number-rules' && request.method === 'POST') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const allowedTypes = ['year', 'school_code', 'gender', 'sequence', 'literal']; + const requested = Array.isArray(body.segments) ? body.segments : []; + if (!requested.length || requested.some(item => !allowedTypes.includes(item.type)) || !requested.some(item => item.type === 'sequence')) return sendError(response, 400, '报名号规则至少包含一个流水号段'); + const existing = db.numberRules.find(item => item.id === body.id); + const rule = { + id: existing?.id || uid('rule'), name: cleanText(body.name, 80) || '自定义报名号规则', separator: cleanText(body.separator, 3), + active: true, createdBy: user.id, updatedAt: nowIso(), segments: requested.map((item, index) => ({ + id: uid('segment'), position: index + 1, type: item.type, value: cleanText(item.value, 20), width: Math.min(12, Math.max(0, Number(item.width || 0))) + })) + }; + const log = logAction(db, user, '更新报名号规则', `${rule.name} · ${rule.segments.map(item => item.type).join(' + ')}`); + 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 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 generated = eligible.map(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const result = generateRegistrationNumber(db, registration, profile); + registration.registrationNumber = result.number; + registration.numberRuleId = result.ruleId; + return registration; + }); + const log = logAction(db, user, '批量生成报名号', `${generated.length} 条 · ${examId || '全部考试'} · ${schoolId || '全部学校'}`); + await database.assignRegistrationNumbers(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)$/); + if (workflowDefinitionMatch && request.method === 'PUT') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const workflow = activeWorkflow(db, workflowDefinitionMatch[1]); + 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, '流程至少需要一个校级或超级管理员审批步骤'); + workflow.name = cleanText(body.name, 80) || workflow.name; + workflow.updatedBy = user.id; + workflow.updatedAt = nowIso(); + workflow.steps = steps.map((item, index) => ({ id: uid('workflow_step'), position: index + 1, name: cleanText(item.name, 80) || `第 ${index + 1} 步`, adminLevel: item.adminLevel })); + const log = logAction(db, user, '修改审批流程', `${workflow.name} · ${workflow.steps.length} 个步骤`); + await database.saveWorkflow(workflow, log); + return sendJson(response, 200, { ok: true, workflow }); + } + + if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') { + if (user.adminLevel === 'class') return sendError(response, 403, '班级管理员只读查看考生、成绩和报名状态'); + const instances = db.workflowInstances.filter(instance => { + if (user.adminLevel === 'super') return true; + const profile = workflowScopeProfile(db, instance); + return Boolean(profile && profileInScope(user, profile)); + }).map(instance => { + 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; + 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 + }; + }); + const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser); + return sendJson(response, 200, { ok: true, instances, availableAdmins, canSupervise: user.adminLevel === 'super' }); + } + const transferMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/transfer$/); + if (transferMatch && request.method === 'PATCH') { + const body = await readJson(request); + const instance = db.workflowInstances.find(item => item.id === transferMatch[1] && item.status === 'pending'); + if (!instance) return sendError(response, 404, '待处理流程不存在'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (user.adminLevel !== 'super' && instance.assigneeId !== user.id) return sendError(response, 403, '只有当前处理人可以转交该流程'); + const target = db.users.find(item => item.id === body.assigneeId && item.role === 'admin' && item.active && item.adminLevel === step?.adminLevel); + if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员'); + const profile = workflowScopeProfile(db, instance); + if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员'); + const previous = instance.assigneeId; + instance.assigneeId = target.id; + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() }; + const log = logAction(db, user, '转交审批流程', `${workflow.name} → ${target.displayName}`); + await database.transferWorkflow(instance, action, log); + return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + const superviseMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/supervise$/); + if (superviseMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const instance = db.workflowInstances.find(item => item.id === superviseMatch[1]); + if (!instance) return sendError(response, 404, '流程不存在'); + 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); + const profile = workflowScopeProfile(db, instance); + const eligible = adminsForStep(db, step.adminLevel, profile); + const assignee = eligible.find(item => item.id === body.assigneeId) || eligible[0]; + if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员'); + const previous = instance.assigneeId; + const previousStep = instance.currentStep; + instance.status = 'pending'; instance.completedAt = null; instance.currentStep = requestedStep; instance.assigneeId = assignee.id; + const note = cleanText(body.note, 300) || `超级管理员将流程调整到第 ${requestedStep} 步`; + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: requestedStep < previousStep ? 'return' : 'supervise', note, fromAssigneeId: previous, toAssigneeId: assignee.id, createdAt: nowIso() }; + const business = instance.businessType === 'profile_change' + ? profile + : instance.businessType === 'registration_review' + ? db.registrations.find(item => item.id === instance.businessId) + : db.centerChangeRequests.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); + return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + if (request.method === 'GET' && pathname === '/api/admin/dashboard') { - const pendingCandidates = db.candidateProfiles.filter(item => item.status === 'pending').length; - const pendingRegistrations = db.registrations.filter(item => item.status === 'pending').length; - return sendJson(response, 200, { ok: true, metrics: { candidates: db.candidateProfiles.length, pendingCandidates, registrations: db.registrations.length, pendingRegistrations, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length }, logs: db.auditLogs.slice(0, 8) }); + const profiles = db.candidateProfiles.filter(item => profileInScope(user, item)); + const registrations = db.registrations.filter(item => registrationInScope(db, user, item)); + const visibleFlows = db.workflowInstances.filter(instance => { + if (user.adminLevel === 'super') return true; + if (user.adminLevel === 'class') return false; + const business = workflowScopeProfile(db, instance); + return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending'); + }); + const pendingCandidates = profiles.filter(item => item.status === 'pending').length; + const pendingRegistrations = registrations.filter(item => item.status === 'pending').length; + return sendJson(response, 200, { + ok: true, + admin: safeUser(user), + scopeLabel: adminScopeLabel(db, user), + permissions: permissionsByLevel[user.adminLevel || 'super'], + metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingFlows: visibleFlows.filter(item => item.status === 'pending').length, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length }, + logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8) + }); } if (request.method === 'GET' && pathname === '/api/admin/candidates') { - const candidates = db.candidateProfiles.map(profile => ({ ...profile, idNumberMasked: maskId(profile.idNumber), username: db.users.find(item => item.id === profile.userId)?.username })); + if (!requirePermission(user, response, 'candidates.read')) return true; + const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => { + const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0]; + return { ...profile, idNumberMasked: maskId(profile.idNumber), username: db.users.find(item => item.id === profile.userId)?.username, workflow: workflowView(db, instance) }; + }); return sendJson(response, 200, { ok: true, candidates }); } const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); if (request.method === 'PATCH' && candidateMatch) { + if (!requirePermission(user, response, 'candidates.review')) return true; const body = await readJson(request); const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]); if (!profile) return sendError(response, 404, '考生资料不存在'); + if (!profileInScope(user, profile) && user.adminLevel !== 'super') return sendError(response, 403, '该考生不在你的数据范围内'); if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); - profile.status = body.status; - profile.reviewNote = cleanText(body.reviewNote, 300); - profile.reviewedAt = nowIso(); - profile.reviewerId = user.id; - const log = logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`); - await database.reviewCandidate(profile, log); - return sendJson(response, 200, { ok: true, profile }); + const instance = pendingWorkflow(db, 'profile_change', profile.id); + if (!instance) return sendError(response, 409, '当前没有待处理的考生信息流程'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + 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() }; + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id; + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0]; + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + profile.status = 'pending'; profile.reviewNote = note; + } else { + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + profile.status = 'approved'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id; + } + const log = logAction(db, user, body.status === 'approved' ? '处理考生信息流程' : '退回考生信息', `${profile.name}:${note || '无备注'}`); + await database.processWorkflow(instance, action, profile, log); + return sendJson(response, 200, { ok: true, profile, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); } if (request.method === 'GET' && pathname === '/api/admin/registrations') { - const registrations = db.registrations.map(registration => { + if (!requirePermission(user, response, 'registrations.read')) return true; + const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).map(registration => { const profile = db.candidateProfiles.find(item => item.userId === registration.userId); return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null }; }); @@ -385,21 +965,57 @@ async function handleAdmin(request, response, pathname) { } const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/); if (request.method === 'PATCH' && registrationMatch) { + if (!requirePermission(user, response, 'registrations.review')) return true; const body = await readJson(request); const registration = db.registrations.find(item => item.id === registrationMatch[1]); if (!registration) return sendError(response, 404, '报名记录不存在'); + if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内'); if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); - registration.status = body.status; - registration.reviewNote = cleanText(body.reviewNote, 300); - registration.reviewedAt = nowIso(); - if (body.status === 'approved') registration.paymentStatus = 'paid'; const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - const log = logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`); - await database.reviewRegistration(registration, log); - return sendJson(response, 200, { ok: true, registration }); + const instance = pendingWorkflow(db, 'registration_review', registration.id); + if (!instance) return sendError(response, 409, '当前没有待处理的报名审核流程'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + 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() }; + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso(); + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0]; + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + registration.status = 'pending'; registration.reviewNote = note; + } else { + const generated = registration.registrationNumber ? null : generateRegistrationNumber(db, registration, profile); + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + registration.status = 'approved'; registration.paymentStatus = 'paid'; registration.reviewNote = note; registration.reviewedAt = nowIso(); + if (generated) { registration.registrationNumber = generated.number; registration.numberRuleId = generated.ruleId; } + } + const log = logAction(db, user, body.status === 'approved' ? '处理报名审核流程' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`); + await database.processWorkflow(instance, action, registration, log); + 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 generated = generateRegistrationNumber(db, registration, profile); + registration.registrationNumber = generated.number; + registration.numberRuleId = generated.ruleId; + const log = logAction(db, user, '生成报名号', `${profile?.name || registration.userId} · ${generated.number}`); + 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]); if (!registration) return sendError(response, 404, '报名记录不存在'); if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证'); @@ -419,8 +1035,12 @@ async function handleAdmin(request, response, pathname) { } return sendJson(response, 200, { ok: true, admitCard: registration.admitCard }); } - if (request.method === 'GET' && pathname === '/api/admin/exams') return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) }); + if (request.method === 'GET' && pathname === '/api/admin/exams') { + if (!requirePermission(user, response, '*')) return true; + return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) }); + } if (request.method === 'POST' && pathname === '/api/admin/exams') { + if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const name = cleanText(body.name, 100); if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期'); @@ -434,6 +1054,7 @@ async function handleAdmin(request, response, pathname) { } const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/); if (request.method === 'PATCH' && examMatch) { + if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const exam = db.exams.find(item => item.id === examMatch[1]); if (!exam) return sendError(response, 404, '考试不存在'); @@ -458,8 +1079,12 @@ async function handleAdmin(request, response, pathname) { await database.updateExam(exam, log, replaceSubjects); return sendJson(response, 200, { ok: true, exam }); } - if (request.method === 'GET' && pathname === '/api/admin/notices') return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) }); + if (request.method === 'GET' && pathname === '/api/admin/notices') { + if (!requirePermission(user, response, '*')) return true; + return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) }); + } if (request.method === 'POST' && pathname === '/api/admin/notices') { + if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const title = cleanText(body.title, 120); const content = cleanText(body.content, 5000); @@ -471,6 +1096,7 @@ async function handleAdmin(request, response, pathname) { } const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/); if (request.method === 'PATCH' && noticeMatch) { + if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const notice = db.notices.find(item => item.id === noticeMatch[1]); if (!notice) return sendError(response, 404, '通知不存在'); @@ -485,16 +1111,19 @@ async function handleAdmin(request, response, pathname) { return sendJson(response, 200, { ok: true, notice }); } if (request.method === 'GET' && pathname === '/api/admin/results') { - const results = db.results.map(result => { + if (!requirePermission(user, response, 'results.read')) return true; + const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); + const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { const registration = db.registrations.find(item => item.id === result.registrationId); const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); const exam = db.exams.find(item => item.id === registration?.examId); const subject = exam?.subjects.find(item => item.id === result.subjectId); return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name }; }); - return sendJson(response, 200, { ok: true, results, registrations: db.registrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) }); + return sendJson(response, 200, { ok: true, results, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] }); } if (request.method === 'POST' && pathname === '/api/admin/results') { + if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved'); if (!registration) return sendError(response, 404, '已通过的报名记录不存在'); diff --git a/styles.css b/styles.css index fb2c18d..6d5b83e 100644 --- a/styles.css +++ b/styles.css @@ -164,3 +164,29 @@ button:disabled { cursor: not-allowed; opacity: .5; } .public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; } } @media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; transition-duration:.01ms !important; } } + +/* Role scopes, numbering and workflow studio */ +.hidden { display:none !important; } +.scope-banner { display:flex; align-items:center; gap:14px; margin-bottom:14px; padding:14px 18px; border:1px solid #d8e1f3; border-radius:11px; background:linear-gradient(90deg,#edf2fb,#f9fbff); } +.scope-banner > span { padding:5px 9px; border-radius:6px; color:#fff; background:var(--navy); font-size:8px; font-weight:700; } +.scope-banner div { display:grid; gap:3px; }.scope-banner strong { font-size:10px; }.scope-banner small { color:var(--muted); font-size:8px; } +.admin-level { display:inline-flex; padding:5px 8px; border-radius:6px; font-size:8px; font-weight:700; } +.level-super { color:#9f3932; background:#fbe6e4; }.level-school { color:#294d99; background:#e7edf9; }.level-class { color:#237259; background:#e2f2ec; } +.candidate-flow-note { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; margin:20px 20px 0; padding:12px 14px; border-left:3px solid var(--amber); border-radius:8px; background:#fff7e8; } +.candidate-flow-note span,.candidate-flow-note small { color:#94713a; font-size:8px; }.candidate-flow-note strong { font-size:10px; } +.center-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:15px; }.center-card { padding:20px; }.center-card header { display:flex; justify-content:space-between; gap:15px; }.center-card header span { color:var(--blue); font-size:8px; }.center-card h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.center-card dl { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:18px 0; }.center-card dl div { padding:10px; border-radius:7px; background:#f7f8fb; }.center-card dt { color:#969dac; font-size:7px; }.center-card dd { margin:4px 0 0; font-size:9px; }.room-map { padding:13px; border:1px dashed #cbd4e5; border-radius:8px; }.room-map span { color:#8b94a7; font-size:7px; }.room-map p { margin:7px 0 0; color:#4f5b73; font-family:Consolas,monospace; font-size:9px; line-height:1.8; }.center-card footer { margin-top:13px; color:#9299a9; font-size:7px; } +.number-rule-layout { display:grid; grid-template-columns:minmax(0,1.5fr) minmax(260px,.55fr); gap:16px; }.rule-builder { overflow:hidden; }.rule-builder .panel-title { height:auto; min-height:72px; }.rule-builder .panel-title p { margin:4px 0 0; color:var(--muted); font-size:8px; }.rule-builder form { display:grid; gap:17px; padding:20px; }.rule-builder label { display:grid; gap:7px; }.rule-builder label > span { color:#555f75; font-size:9px; font-weight:700; }.rule-builder input { min-height:42px; padding:8px 11px; border:1px solid #dce1ea; border-radius:8px; background:#fff; font-size:10px; }.segment-builder { display:grid; gap:8px; }.segment-option { grid-template-columns:20px 60px 1fr 92px 55px; align-items:center; gap:10px; padding:11px; border:1px solid var(--line); border-radius:9px; background:#fafbfc; }.segment-option.selected { border-color:#b8c5df; background:#f4f7fd; }.segment-option > input[type="checkbox"] { width:15px; height:15px; min-height:0; }.segment-order { display:grid; gap:2px; }.segment-order small { color:#989faf; font-size:6px; }.segment-order input,.segment-value,.segment-width { min-height:31px !important; padding:5px 7px !important; }.segment-copy { display:grid; gap:3px; }.segment-copy strong { font-size:9px; }.segment-copy small { color:#8f97a8; font-size:7px; }.rule-preview { position:relative; min-height:270px; display:flex; flex-direction:column; justify-content:center; padding:28px; border-radius:var(--radius); color:#fff; background:var(--navy); overflow:hidden; }.rule-preview::after { content:"号"; position:absolute; right:-18px; bottom:-75px; color:rgba(255,255,255,.04); font-family:"STKaiti"; font-size:180px; }.rule-preview span { color:#8291bc; font-size:8px; }.rule-preview strong { position:relative; z-index:1; margin:12px 0; font-family:Consolas,monospace; font-size:clamp(18px,2vw,29px); letter-spacing:1px; word-break:break-all; }.rule-preview p { color:#b8c2de; font-size:8px; }.rule-preview small { margin-top:auto; color:#8997bc; font-size:7px; line-height:1.8; } +.workflow-design-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:15px; }.workflow-designer { overflow:hidden; }.workflow-designer > header { display:flex; justify-content:space-between; align-items:flex-start; padding:19px 20px; border-bottom:1px solid var(--line); }.workflow-designer header span { color:#8993a8; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.workflow-designer h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:20px; font-weight:400; }.workflow-designer form { display:grid; gap:14px; padding:18px; }.workflow-designer form > input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:7px; font-size:10px; }.workflow-step-editor { position:relative; display:grid; gap:8px; }.workflow-step-editor::before { content:""; position:absolute; top:19px; bottom:19px; left:17px; width:2px; background:#dfe5f0; }.workflow-step-row { position:relative; z-index:1; display:grid; grid-template-columns:34px 1fr 125px 26px; align-items:center; gap:8px; }.workflow-step-row > i { width:34px; height:34px; border:7px solid #fff; border-radius:50%; background:var(--blue); box-shadow:0 0 0 1px #cbd5e8; }.workflow-step-row input,.workflow-step-row select { min-height:37px; padding:7px 9px; border:1px solid var(--line); border-radius:7px; background:#fff; font-size:9px; }.workflow-step-row button { width:26px; height:26px; border:0; border-radius:6px; color:var(--red); background:#fbe9e7; } +.workflow-board { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:15px; }.workflow-card { overflow:hidden; }.workflow-card > header { display:flex; justify-content:space-between; gap:14px; padding:18px 20px; border-bottom:1px solid var(--line); }.workflow-card header span:first-child { color:var(--blue); font-size:7px; }.workflow-card h2 { margin:5px 0; font-family:"STKaiti"; font-size:19px; font-weight:400; }.workflow-card header p { margin:0; color:#8f97a8; font-size:7px; }.workflow-track,.flow-detail-track { display:flex; overflow-x:auto; padding:18px 20px; }.workflow-track > div,.flow-detail-track > div { position:relative; min-width:130px; display:grid; grid-template-columns:28px 1fr; align-items:center; gap:7px; }.workflow-track > div:not(:last-child)::after,.flow-detail-track > div:not(:last-child)::after { content:""; position:absolute; top:13px; left:28px; right:0; height:1px; background:#dbe1ec; }.workflow-track i,.flow-detail-track i { position:relative; z-index:1; width:27px; height:27px; display:grid; place-items:center; border:1px solid #ccd4e2; border-radius:50%; color:#8a93a5; background:#fff; font-size:8px; font-style:normal; }.workflow-track .done i,.flow-detail-track .done i { border-color:var(--jade); color:#fff; background:var(--jade); }.workflow-track .current i,.flow-detail-track .current i { border-color:var(--red); color:#fff; background:var(--red); box-shadow:0 0 0 5px rgba(200,71,61,.1); }.workflow-track span,.flow-detail-track span { z-index:1; display:grid; gap:2px; padding-right:8px; background:#fff; }.workflow-track strong,.flow-detail-track strong { font-size:8px; }.workflow-track small,.flow-detail-track small { color:#9199a9; font-size:6px; }.workflow-owner { display:grid; grid-template-columns:90px 1fr auto; align-items:center; gap:8px; margin:0 20px 16px; padding:10px 12px; border-radius:7px; background:#f5f7fb; }.workflow-owner span,.workflow-owner small { color:#8e96a7; font-size:7px; }.workflow-owner strong { font-size:9px; }.workflow-card > footer { display:flex; align-items:center; justify-content:space-between; padding:12px 20px; border-top:1px solid var(--line); }.workflow-card footer > span { color:#8f97a8; font-size:7px; } +.flow-history { padding:0 24px 5px; }.flow-history h3,.supervisor-form h3 { margin:5px 0 13px; font-family:"STKaiti"; font-size:17px; font-weight:400; }.flow-history > div { display:grid; grid-template-columns:12px 1fr auto; gap:8px; padding:9px 0; border-top:1px solid #edf0f5; }.flow-history i { width:7px; height:7px; margin-top:4px; border-radius:50%; background:var(--blue); }.flow-history span { display:grid; gap:3px; }.flow-history strong { font-size:8px; }.flow-history small,.flow-history time { color:#929aaa; font-size:7px; }.compact-flow-form,.supervisor-form { margin-top:12px; padding-bottom:20px; border-top:1px solid var(--line); }.supervisor-form { margin-top:0; background:#f8f5ee; }.read-only-callout { margin:16px 24px; padding:12px; border-radius:8px; color:#7c6a4b; background:#fff4dd; font-size:8px; } +.results-admin-grid.read-only { grid-template-columns:1fr; }.results-admin-grid.read-only .published-results { max-width:none; } + +/* Controlled test-site dossiers and batch numbering */ +.center-summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:15px; }.center-summary > div { display:flex; align-items:baseline; gap:8px; padding:16px 18px; border:1px solid var(--line); border-radius:11px; background:#fff; }.center-summary span { color:#8790a2; font-size:8px; }.center-summary strong { margin-left:auto; color:var(--navy); font-family:Georgia,serif; font-size:24px; font-weight:400; }.center-dossier-grid { display:grid; gap:16px; }.center-dossier { overflow:hidden; }.center-dossier > header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; padding:19px 21px; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff,#f7f9fd); }.center-dossier > header > div:last-child { display:flex; align-items:center; gap:8px; }.center-dossier header span { color:#70809f; font-size:8px; }.center-dossier header h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:22px; font-weight:400; }.pending-mark { padding:5px 8px; border-radius:6px; color:#99631d !important; background:#fff0d3; font-weight:700; }.row-action:disabled { color:#9ca4b3; cursor:not-allowed; }.center-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; border-bottom:1px solid var(--line); background:var(--line); }.center-metrics > div { display:flex; align-items:baseline; gap:5px; padding:14px 20px; background:#fff; }.center-metrics small { margin-right:auto; color:#9098a9; font-size:7px; }.center-metrics strong { font-family:Georgia,serif; font-size:19px; font-weight:400; }.center-metrics span { color:#8f97a8; font-size:7px; }.center-profile { display:grid; grid-template-columns:1fr 1fr; gap:10px 24px; margin:0; padding:18px 21px; }.center-profile div { display:grid; grid-template-columns:74px 1fr; gap:7px; }.center-profile dt { color:#949cac; font-size:7px; }.center-profile dd { margin:0; color:#556077; font-size:8px; line-height:1.6; }.room-table-wrap { margin:0 20px 18px; overflow:auto; border:1px solid var(--line); border-radius:9px; }.room-table th { background:#f3f6fb; }.center-dossier > footer { display:flex; justify-content:space-between; gap:15px; padding:12px 21px; border-top:1px solid var(--line); color:#8b94a5; font-size:7px; }.center-change-ledger { margin-top:16px; overflow:hidden; }.center-change-ledger .panel-title { min-height:70px; height:auto; }.center-change-ledger .panel-title p { margin:4px 0 0; color:#8b94a5; font-size:7px; }.modal-card:has(.center-dossier-form) { width:min(980px,100%); }.center-dossier-form { gap:18px; }.center-form-section { display:grid; gap:13px; padding:16px; border:1px solid var(--line); border-radius:10px; background:#fbfcfe; }.center-form-section > h3,.center-form-section > header h3 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.center-form-section > header { display:flex; align-items:center; justify-content:space-between; gap:15px; }.center-form-section > header p { margin:4px 0 0; color:#8f97a8; font-size:7px; }.rooms-section > div { display:grid; gap:10px; }.center-room-editor { overflow:hidden; border:1px solid #dce3ef; border-radius:9px; background:#fff; }.center-room-editor > header { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; background:#f1f4fa; }.center-room-editor > header span { color:#5e6c87; font-size:8px; font-weight:700; }.center-room-editor > header button { border:0; color:#a74c45; background:transparent; font-size:7px; }.room-editor-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; padding:12px; }.room-editor-grid label { min-width:0; }.room-editor-grid input,.room-editor-grid select { width:100%; }.room-editor-grid .room-notes { grid-column:span 2; }.approval-callout { display:flex; align-items:center; gap:12px; padding:12px 14px; border-left:3px solid var(--amber); border-radius:8px; background:#fff7e9; }.approval-callout strong { color:#765c30; font-size:9px; }.approval-callout span { color:#94794d; font-size:8px; }.batch-number-panel { margin-top:16px; padding:21px; background:linear-gradient(120deg,#fff 0 65%,#f0f4fb); }.batch-number-intro span { color:#7786a5; font-family:Consolas,monospace; font-size:7px; letter-spacing:1.2px; }.batch-number-intro h2 { margin:5px 0; font-family:"STKaiti"; font-size:22px; font-weight:400; }.batch-number-intro p { margin:0; color:#8790a2; font-size:8px; }.batch-number-panel form { display:grid; grid-template-columns:1fr 1fr 130px auto; align-items:end; gap:12px; margin-top:18px; }.batch-number-panel form label { display:grid; gap:6px; }.batch-number-panel form label span { color:#68738a; font-size:8px; font-weight:700; }.batch-number-panel select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.batch-ready { display:flex; align-items:baseline; gap:5px; min-height:40px; padding:8px 12px; border:1px solid #d6dfef; border-radius:8px; background:#f5f8fd; }.batch-ready small { margin-right:auto; color:#7f899d; font-size:7px; }.batch-ready strong { color:var(--blue); font-family:Georgia,serif; font-size:20px; }.batch-ready span { color:#8892a5; font-size:7px; }.batch-candidate-strip { display:flex; flex-wrap:wrap; gap:7px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.batch-candidate-strip > span { display:grid; gap:2px; padding:7px 9px; border-radius:7px; background:#f2f5fa; }.batch-candidate-strip b { font-size:8px; }.batch-candidate-strip small,.batch-candidate-strip p,.batch-candidate-strip em { color:#8a93a5; font-size:7px; font-style:normal; }.flow-center-snapshot { margin:18px 24px 4px; padding:14px; border:1px solid #dce3ef; border-radius:10px; background:#f7f9fd; }.flow-center-snapshot header { display:flex; justify-content:space-between; align-items:center; }.flow-center-snapshot header span { color:#8390a8; font-size:7px; }.flow-center-snapshot h3 { margin:3px 0 0; font-size:12px; }.flow-center-snapshot header b { color:var(--blue); font-size:9px; }.flow-center-snapshot dl { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:12px 0; }.flow-center-snapshot dl div { display:grid; grid-template-columns:62px 1fr; gap:6px; }.flow-center-snapshot dt { color:#929bad; font-size:7px; }.flow-center-snapshot dd { margin:0; font-size:8px; }.flow-center-snapshot > div { display:flex; flex-wrap:wrap; gap:6px; }.flow-center-snapshot > div span { display:grid; gap:2px; padding:6px 8px; border-radius:6px; background:#fff; }.flow-center-snapshot > div strong { font-size:8px; }.flow-center-snapshot > div small { color:#8d96a8; font-size:7px; } + +@media (max-width: 1000px) { + .number-rule-layout,.workflow-design-grid,.workflow-board,.center-grid { grid-template-columns:1fr; }.batch-number-panel form { grid-template-columns:1fr 1fr; }.room-editor-grid { grid-template-columns:repeat(3,1fr); } +} +@media (max-width: 620px) { + .scope-banner { align-items:flex-start; }.candidate-flow-note { grid-template-columns:1fr; }.segment-option { grid-template-columns:18px 52px 1fr; }.segment-value,.segment-width { grid-column:2/-1; }.workflow-step-row { grid-template-columns:28px 1fr 26px; }.workflow-step-row > i { width:28px;height:28px; }.workflow-step-row select { grid-column:2/3; }.workflow-owner { grid-template-columns:1fr; }.center-card dl { grid-template-columns:1fr; }.number-rule-layout { display:block; }.rule-preview { margin-top:14px; }.center-summary,.center-metrics,.center-profile,.batch-number-panel form,.room-editor-grid,.flow-center-snapshot dl { grid-template-columns:1fr; }.center-dossier > header,.center-dossier > footer,.center-form-section > header,.approval-callout { align-items:flex-start; flex-direction:column; }.center-dossier > header > div:last-child { flex-wrap:wrap; }.room-editor-grid .room-notes { grid-column:auto; } +} diff --git a/tests/system.test.mjs b/tests/system.test.mjs index ae6f894..0655066 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -51,6 +51,9 @@ function createClient() { } const admin = createClient(); +const schoolAdmin = createClient(); +const schoolAdmin2 = createClient(); +const classAdmin = createClient(); const candidate = createClient(); const anonymous = createClient(); @@ -82,7 +85,7 @@ try { const register = await candidate.request('/api/auth/register', { method: 'POST', - body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', school: '海州市测试中学', grade: '高三(1)班' } + body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', schoolId: 'school_hz1', classId: 'class_hz1_302' } }); assert.equal(register.response.status, 201, '考生应可自主注册'); @@ -91,7 +94,7 @@ try { const updateProfile = await candidate.request('/api/candidate/profile', { method: 'PUT', - body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', phone: '13900009999', email: 'test@example.com', school: '海州市测试中学', grade: '高三(1)班', address: '海州市测试区 1 号', emergencyContact: '测试家长', emergencyPhone: '13800008888' } + body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市测试区 1 号', emergencyContact: '测试家长', emergencyPhone: '13800008888' } }); assert.equal(updateProfile.response.status, 200, '考生应可自主维护完整资料'); assert.equal(updateProfile.data.profile.status, 'pending', '资料修改后应重新进入审核'); @@ -103,6 +106,59 @@ try { const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } }); assert.equal(loginAdmin.data.user.role, 'admin'); + assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员'); + assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: 'School123!' } })).data.user.adminLevel, 'school'); + 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 adminDirectory = await admin.request('/api/admin/admins'); + assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员'); + const schoolCenters = await schoolAdmin.request('/api/admin/centers'); + assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点'); + const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: { + code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678', + managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案', + rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }] + } }); + assert.equal(newCenter.response.status, 202, '新增考点应创建审批申请而不是直接落库'); + assert.equal(newCenter.data.changeRequest.schoolId, 'school_hz1', '校级管理员新增考点必须自动归属本校'); + const beforeCenterApproval = await schoolAdmin.request('/api/admin/centers'); + assert.ok(!beforeCenterApproval.data.centers.some(item => item.code === 'HZ01-EAST'), '考点审批通过前不得进入正式档案'); + const approveCenter = await admin.request(`/api/admin/center-change-requests/${newCenter.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考务条件核验通过' } }); + assert.equal(approveCenter.response.status, 200, '超级管理员应可审批考点变更'); + const afterCenterApproval = await schoolAdmin.request('/api/admin/centers'); + const createdCenter = afterCenterApproval.data.centers.find(item => item.code === 'HZ01-EAST'); + assert.equal(createdCenter.rooms.length, 1, '审批通过后应同时写入结构化考场'); + assert.equal(createdCenter.totalCapacity, 30, '考场容量应汇总到考点档案'); + const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: { + ...createdCenter, managerName: '变更后负责人', rooms: [ + ...createdCenter.rooms, + { code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatStart: 31, seatEnd: 55, roomType: 'accessible', status: 'active', notes: '无障碍通道' } + ] + } }); + assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批'); + const unchangedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id); + assert.equal(unchangedCenter.managerName, '测试负责人', '变更审批通过前正式档案不得改变'); + await admin.request(`/api/admin/center-change-requests/${centerUpdate.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '同意扩充考场' } }); + const changedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id); + assert.equal(changedCenter.managerName, '变更后负责人', '审批通过后正式考点档案应更新'); + assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换'); + assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点'); + + const numberRules = await admin.request('/api/admin/number-rules'); + assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则'); + const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: { + id: numberRules.data.activeRule.id, name: '测试组合规则', separator: '-', segments: [ + { type: 'year', width: 4 }, { type: 'school_code' }, { type: 'gender' }, { type: 'sequence', width: 4 } + ] + } }); + 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, '重复批量执行不得覆盖已有报名号'); const now = Date.now(); const hour = 60 * 60 * 1000; @@ -119,6 +175,8 @@ try { assert.equal(createExam.response.status, 201); assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试'); const exam = createExam.data.exam; + const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } }); + assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划'); const draftResponse = await admin.request('/api/admin/exams', { method: 'POST', @@ -148,7 +206,16 @@ try { const profile = candidates.data.candidates.find(item => item.username === 'test_candidate'); assert.ok(profile, '管理员应能看到新注册考生'); assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料'); - const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '自动化测试审核通过' } }); + const schoolCandidates = await schoolAdmin.request('/api/admin/candidates'); + assert.ok(schoolCandidates.data.candidates.some(item => item.id === profile.id), '校级管理员应看到本校考生'); + const classCandidates = await classAdmin.request('/api/admin/candidates'); + assert.ok(classCandidates.data.candidates.some(item => item.id === profile.id), '班级管理员应看到本班考生'); + const classCannotReview = await classAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved' } }); + assert.equal(classCannotReview.response.status, 403, '班级管理员不得审核考生资料'); + + const schoolApproveProfile = await schoolAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校学籍复核通过' } }); + assert.equal(schoolApproveProfile.data.profile.status, 'pending', '校级初审后应进入超级管理员终审'); + const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审通过' } }); assert.equal(approveProfile.data.profile.status, 'approved'); const submitRegistration = await candidate.request('/api/candidate/registrations', { @@ -162,8 +229,23 @@ try { const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId); assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列'); - const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格核验通过' } }); + const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances'); + const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending'); + assert.ok(registrationFlow, '报名应建立可追踪的审批实例'); + const transfer = await schoolAdmin.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: 'usr_school_admin_2', note: '同级管理员协办' } }); + assert.equal(transfer.data.workflow.assignee.id, 'usr_school_admin_2', '同级管理员之间应可转交流程'); + const schoolApproveRegistration = await schoolAdmin2.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } }); + assert.equal(schoolApproveRegistration.data.registration.status, 'pending', '校级初审后报名仍应待终审'); + + const allFlows = await admin.request('/api/admin/workflow-instances'); + const supervisedFlow = allFlows.data.instances.find(item => item.id === registrationFlow.id); + assert.equal(supervisedFlow.currentStep, 2, '超级管理员应看到全部流程及当前节点'); + const supervisedReturn = await admin.request(`/api/admin/workflow-instances/${registrationFlow.id}/supervise`, { method: 'PATCH', body: { currentStep: 1, assigneeId: 'usr_school_admin', note: '抽查后退回学校复核' } }); + assert.equal(supervisedReturn.data.workflow.currentStep, 1, '超级管理员应可监督并退回流程节点'); + await schoolAdmin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校再次复核通过' } }); + const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } }); assert.equal(approveRegistration.data.registration.status, 'approved'); + assert.match(approveRegistration.data.registration.registrationNumber, /^2026-HZ01-M-\d{4}$/, '终审通过后应按年份、学校、性别和流水号生成报名号'); const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号'); @@ -190,6 +272,7 @@ try { const futureExam = futureExamResponse.data.exam; const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } }); const futureRegistrationId = futureRegistration.data.registration.id; + await schoolAdmin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } }); await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } }); await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`); @@ -204,14 +287,25 @@ try { assert.equal(publishResult.response.status, 200); const results = await candidate.request('/api/candidate/results'); assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); + const classResults = await classAdmin.request('/api/admin/results'); + assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩'); + assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩'); + + 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'), '流程设计应包含考点考场变更审批'); + 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, '超级管理员应可设计考生信息修改审批流程'); console.log('✓ 公开首页与通知读取'); console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`); - console.log('✓ 考生自主注册、完整资料维护与管理员审核'); + console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离'); + console.log('✓ 考生自主注册、完整资料维护与两级审批'); console.log('✓ 多科目考试创建与考生自主选科报名'); - console.log('✓ 报名审核、准考证生成、开放期下载与窗口限制'); + console.log('✓ 审批流程设计、同级转交与超级管理员监督退回'); + console.log('✓ 自定义规则、筛选批量报名号、准考证与下载窗口限制'); + console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 成绩录入、发布与考生查询'); - console.log('✓ 候选人与管理员角色权限隔离'); } finally { server.kill('SIGTERM'); await new Promise(resolveWait => server.once('exit', resolveWait));