diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..c6da99f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BISS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1952d41..4041459 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ ### 考生中心 -- 考生自主注册、登录和实名资料维护 +- 报名号即考生账户,同一考生参加不同考试始终使用同一个号码 +- 学校管理员创建考生账户并下发报名号、初始密码 +- 首次登录强制修改初始密码,完成后才能补全个人信息 +- 完整维护姓名、性别、证件号码、籍贯、出生日期、民族、家庭住址、手机号、邮箱、学校、班级、监护人和紧急联系人 +- 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号 - 资料审核状态与管理员审核意见 - 查看开放考试并自主选择多个报考科目 - 查看报名、缴费和审核状态 @@ -30,7 +34,8 @@ - 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程 - 当前处理人可将流程转交给同范围的同级管理员 - 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 -- 按考试、学校筛选,批量为已审核且缺号的报名记录生成报名号 +- 报名号在创建考生账户时只生成一次,后续考试报名自动复用 +- 按考试、学校筛选,为旧数据批量同步考生账户的固定报名号 - 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态 - 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案 - 考务指标与审计日志 @@ -67,7 +72,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v3,补充分级权限、报名号、结构化考场和变更审批结构。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v4,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、结构化考场和变更审批结构。 ## 数据库配置 @@ -129,7 +134,7 @@ npm start | 校级管理员 | `school_admin` | `School123!` | | 同校校级管理员(转交演示) | `school_admin_2` | `School123!` | | 班级管理员 | `class_admin` | `Class123!` | -| 考生 | `13800138000` | `Candidate123!` | +| 考生(首次登录需改密) | `2026-HZ01-F-0001` | `Candidate123!` | ## 自动化测试 @@ -137,7 +142,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖三级管理员数据范围、两级审批、同级转交、超级管理员监督退回、报名号规则与筛选批量生成、结构化考点考场及变更审批、班级只读、多科目报名、准考证和成绩完整流程。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、报名号规则与旧记录批量同步、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。 ## 项目结构 diff --git a/app.js b/app.js index 1ce71c0..f0a796f 100644 --- a/app.js +++ b/app.js @@ -103,13 +103,13 @@ function renderHome() { app.innerHTML = `${publicHeader()}
-
最新

HAIZHOU EXAMINATION SERVICE

每一次应考,
都有清晰的下一步。

从身份核验、选科报名到准考证与成绩查询,所有考试事项集中办理,进度实时可见。

${state.user?.role === 'candidate' ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
+
最新

HAIZHOU EXAMINATION SERVICE

一个报名号,
贯穿每一次考试。

使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
${featured ? renderHeroTicket(featured) : '
暂无开放考试
'}

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
-

SERVICE FLOW

从注册到查分,只需五步

每一步都会在考生中心显示当前状态和下一项操作。

${[['01','注册并填写资料','使用手机号注册,完善实名信息。'],['02','等待资料审核','管理员核验身份与学籍信息。'],['03','选择考试科目','在报名期内自主勾选报考科目。'],['04','下载准考证','管理员生成后,在开放期内下载。'],['05','查询考试成绩','成绩发布后登录平台查询。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
+

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
`; } @@ -128,16 +128,17 @@ 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 ? `
演示账号
` : ''}
`; + const selfRegistration = state.publicData.selfRegistrationEnabled; + app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '凭一个号码,' : '自主申请,'}
${login ? '办理每一次考试。' : '领取固定报名号。'}

报名号就是考生账户,不因考试、科目或年度报名而改变。

首次登录顺序

修改初始密码 → 补全个人信息 → 等待资料审核。

${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}

${login ? '报名号登录' : '自主申请报名号'}

${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}

${login ? loginForm() : selfRegistration ? registerForm() : '
自主注册已关闭学校管理员会为考生创建账户并下发初始密码。
'}${login && selfRegistration ? `
还没有报名号?
` : !login ? '
已经有报名号?
' : ''}${login ? `
演示账号
` : ''}
`; } function loginForm() { - return `
`; + return `
`; } function registerForm() { const schools = state.publicData.schools || []; - return `
`; + return `
`; } const candidateNav = [ @@ -164,6 +165,7 @@ function portalHeadingAction(role, page) { if (role === 'admin' && page === 'notices') return ``; if (role === 'admin' && page === 'exams') return ``; if (role === 'admin' && page === 'admins') return ``; + if (role === 'admin' && page === 'candidates' && state.user.adminLevel !== 'class') return ``; if (role === 'admin' && page === 'centers') return ``; if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; return ''; @@ -173,8 +175,29 @@ function loadingPanel() { return `
正在读取数据
`; } +function onboardingShell(stage, content) { + const passwordDone = stage !== 'password'; + return `
FIRST SIGN-IN

${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}

${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}

${content}
`; +} + +function passwordOnboardingForm() { + return `
新密码要求至少 8 位,且不能与初始密码相同。
`; +} + async function renderCandidate(page) { if (state.user?.role !== 'candidate') return navigate('login'); + if (state.user.mustChangePassword) { + app.innerHTML = onboardingShell('password', passwordOnboardingForm()); + return; + } + if (!state.profile?.profileCompleted) { + try { + const data = await api('/api/candidate/profile'); + state.pageData = data; state.profile = data.profile; + app.innerHTML = onboardingShell('profile', candidateProfile(data, true)); + } catch (error) { renderError(error); } + return; + } const meta = { dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'], profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'], @@ -212,10 +235,11 @@ 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(data) { +function candidateProfile(data, onboarding = false) { 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)}

` : ''}

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

`; + const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber; + return `
${workflow ? `
当前审批${h(step?.name || statusLabels[workflow.status])}${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}
` : ''}
01

身份信息

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

02

学校与班级

学校和班级决定资料审批范围。

03

家庭与联系信息

用于考试通知、身份复核和紧急联系。

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

${h(profile.reviewNote)}

` : ''}

${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}

`; } function candidateExams(data) { @@ -223,7 +247,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.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', '去考试报名'); + return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${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) { @@ -252,7 +276,7 @@ async function renderAdmin(page) { centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], 'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'], - 'number-rules': ['报名号规则', '设计号码组成,并为缺失报名号的已审核记录批量生成。'] + 'number-rules': ['报名号规则', '设计新账户号码组成,并为旧考试记录批量同步固定报名号。'] }; const allowedPages = adminNavForUser().map(item => item[0]); if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; @@ -279,12 +303,12 @@ function adminDashboard(data) { function adminCandidates(candidates) { const readOnly = state.user.adminLevel === 'class'; - return `
${candidates.map(item => ``).join('')}
考生证件号码学校 / 班级当前流程更新时间状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.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)}
`; + return `
${candidates.map(item => ``).join('')}
报名号 / 考生证件号码学校 / 班级账户进度更新时间资料状态操作
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h(item.grade || '')}${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}${h(item.workflow?.assignee?.displayName || '')}${formatDate(item.updatedAt,true)}${item.profileCompleted ? badge(item.status) : '未完成'}
`; } function adminRegistrations(registrations) { 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)}
`; + 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 || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; } function adminExams(exams) { @@ -306,7 +330,7 @@ function adminResults(data) { } 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')}
`; + return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

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

${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) { @@ -326,7 +350,7 @@ function adminNumberRules(data) { 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} 条` : ''}
`; + return `
ONE CANDIDATE · ONE NUMBER报名号属于考生账户

创建账户时生成一次,参加不同考试时只复制到报名记录,不再重新编号。

账户报名号组成

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

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

批量同步账户报名号

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

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

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

'}${candidates.length > 8 ? `另有 ${candidates.length - 8} 条` : ''}
`; } function adminFlowDesign(workflows) { @@ -414,7 +438,7 @@ document.addEventListener('click', async event => { } if (action === 'fill-demo') { const form = document.querySelector('[data-form="login"]'); - const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['13800138000', 'Candidate123!'] }; + const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['2026-HZ01-F-0001', 'Candidate123!'] }; [form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate; return; } @@ -426,6 +450,7 @@ document.addEventListener('click', async event => { if (action === 'new-notice') return openNoticeForm(); if (action === 'new-exam') return openExamForm(); if (action === 'new-admin') return openAdminForm(); + if (action === 'new-candidate-account') return openCandidateAccountForm(); if (action === 'new-center') return openCenterForm(); if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id)); if (action === 'add-center-room') { @@ -453,7 +478,7 @@ document.addEventListener('click', async event => { 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(); + toast('账户报名号已同步', '考试记录已关联考生的固定号码'); return renderRoute(); } if (action === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); @@ -527,13 +552,18 @@ document.addEventListener('submit', async event => { if (kind === 'login') { const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) }); state.user = data.user; await refreshSession(); - toast('登录成功', `欢迎,${data.user.displayName}`); navigate(`${data.user.role}/dashboard`); + toast('登录成功', `欢迎,${data.user.displayName}`); navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`); } else if (kind === 'register') { - await api('/api/auth/register', { method: 'POST', body: formObject(form) }); - toast('注册成功', '请登录后继续完善资料'); navigate('login'); + const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) }); + setModal(`
固定报名号${h(data.registrationNumber)}

以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。

`); + } else if (kind === 'candidate-password') { + const body = formObject(form); + if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); + await api('/api/auth/change-password', { method: 'POST', body }); + await refreshSession(); toast('密码修改成功', '下一步请补全个人信息'); navigate('candidate/onboarding'); } else if (kind === 'candidate-profile') { const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); - state.profile = data.profile; toast('资料已提交', '管理员审核后会更新状态'); renderRoute(); + state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); } else if (kind === 'exam-registration') { const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); @@ -551,6 +581,13 @@ document.addEventListener('submit', async event => { const body = formObject(form); await api('/api/admin/admins', { method: 'POST', body }); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); + } else if (kind === 'candidate-account') { + const data = await api('/api/admin/candidate-accounts', { method: 'POST', body: formObject(form) }); + closeModal(); toast('考生账户已创建', `报名号:${data.candidate.candidateNumber}`); renderRoute(); + } else if (kind === 'self-registration-setting') { + const enabled = form.enabled.value === 'true'; + await api('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled } }); + await refreshPublic(); toast(enabled ? '自主注册已开放' : '自主注册已关闭', enabled ? '公开入口现在可以申请报名号' : '仅保留学校下发账户流程'); renderRoute(); } else if (kind === 'center-form') { const body = formObject(form); const editing = Boolean(body.id); @@ -576,10 +613,10 @@ document.addEventListener('submit', async event => { 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(); + 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(); + toast('报名号同步完成', `已为 ${result.count} 条考试报名关联固定账户号码`); renderRoute(); } else if (kind === 'workflow-design') { const names = [...form.querySelectorAll('[name="stepName"]')]; const levels = [...form.querySelectorAll('[name="stepLevel"]')]; @@ -627,6 +664,11 @@ function openAdminForm() { setModal(``); } +function openCandidateAccountForm() { + const { schools = [], classes = [] } = state.pageData; + setModal(``); +} + function centerRoomEditor(room = {}) { return `
结构化考场
`; } @@ -653,13 +695,13 @@ function openFlowDetail(id) { function openCandidateReview(id) { const item = state.pageData.candidates.find(candidate => candidate.id === id); 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 ? `` : ''}`); + setModal(`
证件号码
${h(item.idNumberMasked)}
性别 / 籍贯
${h(item.gender)} · ${h(item.nativePlace)}
联系电话
${h(item.phone)}
电子邮箱
${h(item.email)}
就读学校
${h(item.school)}
年级班级
${h(item.grade)}
家庭住址
${h(item.address)}
监护人
${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}
当前步骤
${h(item.workflow?.currentStepDetail?.name || '流程已结束')}
当前责任人
${h(item.workflow?.assignee?.displayName || '—')}
${canReview ? `` : ''}`); } function openRegistrationReview(id) { const reg = state.pageData.registrations.find(item => item.id === id); const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending'; - setModal(`
报考科目

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

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

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

账户报名号
${h(reg.registrationNumber || '待同步账户号码')}
当前步骤
${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 718f6f4..4bba987 100644 --- a/database.mjs +++ b/database.mjs @@ -35,6 +35,7 @@ const sqliteSchema = ` id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL DEFAULT 1, app_version INTEGER NOT NULL DEFAULT 1, + self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)), created_at TEXT NOT NULL ) STRICT; @@ -66,12 +67,14 @@ const sqliteSchema = ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, + candidate_number TEXT 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)), + must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), display_name TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; @@ -91,6 +94,13 @@ const sqliteSchema = ` address TEXT, emergency_contact TEXT, emergency_phone TEXT, + native_place TEXT, + birth_date TEXT, + ethnicity TEXT, + postal_code TEXT, + guardian_name TEXT, + guardian_phone TEXT, + profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)), status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), review_note TEXT, reviewed_at TEXT, @@ -148,7 +158,7 @@ const sqliteSchema = ` created_at TEXT NOT NULL, reviewed_at TEXT, review_note TEXT, - registration_number TEXT UNIQUE, + registration_number TEXT, number_rule_id TEXT, UNIQUE (user_id, exam_id) ) STRICT; @@ -344,6 +354,7 @@ const mysqlSchema = [ id TINYINT UNSIGNED NOT NULL, schema_version INT UNSIGNED NOT NULL DEFAULT 1, app_version INT UNSIGNED NOT NULL DEFAULT 1, + self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) @@ -380,16 +391,19 @@ const mysqlSchema = [ `CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) NOT NULL, username VARCHAR(100) NOT NULL, + candidate_number VARCHAR(120) 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, + must_change_password BOOLEAN NOT NULL DEFAULT FALSE, 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_candidate_number (candidate_number), 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 @@ -409,6 +423,13 @@ const mysqlSchema = [ address VARCHAR(255) NULL, emergency_contact VARCHAR(100) NULL, emergency_phone VARCHAR(60) NULL, + native_place VARCHAR(160) NULL, + birth_date VARCHAR(20) NULL, + ethnicity VARCHAR(60) NULL, + postal_code VARCHAR(20) NULL, + guardian_name VARCHAR(100) NULL, + guardian_phone VARCHAR(60) NULL, + profile_completed BOOLEAN NOT NULL DEFAULT FALSE, status ENUM('pending', 'approved', 'rejected') NOT NULL, review_note VARCHAR(500) NULL, reviewed_at VARCHAR(35) NULL, @@ -482,7 +503,6 @@ const mysqlSchema = [ 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, @@ -710,8 +730,9 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - '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() + 'UPDATE schema_metadata SET schema_version = 4, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, + state.meta?.createdAt || new Date().toISOString() ); add( 'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)', @@ -735,10 +756,11 @@ function buildSeedOperations(state) { for (const user of state.users) { add( `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 + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, nullable(user.candidateNumber), user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId), + nullable(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, user.displayName, user.createdAt ); } @@ -746,12 +768,15 @@ function buildSeedOperations(state) { add( `INSERT INTO candidate_profiles ( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, + guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone, 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.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate), + nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone), + profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote), nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt ); } @@ -982,6 +1007,7 @@ function stateFromRows(rows) { const organization = rows.organization; const state = { meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at }, + settings: { selfRegistrationEnabled: Boolean(rows.system.self_registration_enabled) }, organization: { name: organization.name, code: organization.code, @@ -1005,12 +1031,14 @@ function stateFromRows(rows) { users: rows.users.map(row => ({ id: row.id, username: row.username, + candidateNumber: row.candidate_number || '', 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), + mustChangePassword: Boolean(row.must_change_password), displayName: row.display_name, createdAt: row.created_at })), @@ -1029,6 +1057,13 @@ function stateFromRows(rows) { address: row.address || '', emergencyContact: row.emergency_contact || '', emergencyPhone: row.emergency_phone || '', + nativePlace: row.native_place || '', + birthDate: row.birth_date || '', + ethnicity: row.ethnicity || '', + postalCode: row.postal_code || '', + guardianName: row.guardian_name || '', + guardianPhone: row.guardian_phone || '', + profileCompleted: Boolean(row.profile_completed), status: row.status, reviewNote: row.review_note || '', reviewedAt: row.reviewed_at, @@ -1310,28 +1345,33 @@ function createRepository({ client, location, read, transaction, close }) { location, read, close, - async createCandidate(user, profile, instance, action) { + async createCandidate(user, profile, instance, action, log = null) { const operations = [ operation( `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 + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, optional(user.candidateNumber), user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId), + optional(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, user.displayName, user.createdAt ), operation( `INSERT INTO candidate_profiles ( id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, - emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name, + guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), 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.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate), + optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), + profile.profileCompleted ? 1 : 0, profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt ) ]; if (instance && action) operations.push(...workflowCreateOperations(instance, action)); + if (log) operations.push(auditOperation(log)); await transaction(operations); }, async updateCandidateProfile(profile, displayName, instance, action) { @@ -1340,12 +1380,14 @@ function createRepository({ client, location, read, transaction, close }) { `UPDATE candidate_profiles SET name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?, school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, - reviewed_at = ?, reviewer_id = ?, updated_at = ? + native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?, + profile_completed = ?, 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.schoolId), optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, - optional(profile.reviewNote), optional(profile.reviewedAt), + optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), + optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt, profile.id ), operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) @@ -1353,6 +1395,20 @@ function createRepository({ client, location, read, transaction, close }) { if (instance && action) operations.push(...workflowCreateOperations(instance, action)); await transaction(operations); }, + async changePassword(user, log) { + const operations = [operation( + 'UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?', + user.passwordHash, user.mustChangePassword ? 1 : 0, user.id + )]; + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async updateRegistrationSetting(enabled, log) { + await transaction([ + operation('UPDATE schema_metadata SET self_registration_enabled = ? WHERE id = 1', enabled ? 1 : 0), + auditOperation(log) + ]); + }, async createRegistration(registration, instance, action) { const operations = [operation( `INSERT INTO registrations ( @@ -1595,12 +1651,16 @@ function createRepository({ client, location, read, transaction, close }) { auditOperation(log) ]); }, - async assignRegistrationNumbers(registrations, log) { - const operations = registrations.map(registration => operation( + async assignCandidateNumbers(users, registrations, log) { + const operations = users.map(user => operation( + 'UPDATE users SET candidate_number = ? WHERE id = ? AND (candidate_number IS NULL OR candidate_number = \'\')', + user.candidateNumber, user.id + )); + operations.push(...registrations.map(registration => operation( `UPDATE registrations SET registration_number = ?, number_rule_id = ? WHERE id = ? AND (registration_number IS NULL OR registration_number = '')`, registration.registrationNumber, optional(registration.numberRuleId), registration.id - )); + ))); operations.push(auditOperation(log)); await transaction(operations); }, @@ -1706,9 +1766,14 @@ async function createSqliteStore({ path, seed }) { } }; ensureColumns('users', [ - ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'] + ['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'], + ['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'] + ]); + ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]); + ensureColumns('candidate_profiles', [ + ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'], + ['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'] ]); - 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'], @@ -1738,7 +1803,37 @@ async function createSqliteStore({ path, seed }) { `); } } + if (tableExists('registrations')) { + const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || ''; + if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE registrations_v4 ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), + created_at TEXT NOT NULL, + reviewed_at TEXT, + review_note TEXT, + registration_number TEXT, + number_rule_id TEXT, + UNIQUE (user_id, exam_id) + ) STRICT; + INSERT INTO registrations_v4 ( + id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id + ) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations; + DROP TABLE registrations; + ALTER TABLE registrations_v4 RENAME TO registrations; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } connection.exec(sqliteSchema); + connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)'); const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(); if (existingSystem && Number(existingSystem.app_version || 1) < 2) { @@ -1833,14 +1928,44 @@ async function createSqliteStore({ path, seed }) { } } + if (existingSystem && Number(existingSystem.app_version || 1) < 4) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare( + `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), + must_change_password = COALESCE(must_change_password, ?) WHERE id = ?` + ).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id); + connection.prepare(`UPDATE users SET candidate_number = COALESCE( + (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), + 'CAND-' || substr(id, -10) + ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run(); + for (const profile of extension.candidateProfiles) connection.prepare( + `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), + ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), + guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?` + ).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id); + connection.prepare(`UPDATE registrations SET registration_number = ( + SELECT candidate_number FROM users WHERE users.id = registrations.user_id + ) WHERE registration_number IS NULL OR registration_number = ''`).run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 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, 3, ?, ?) - `).run(Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()); + INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) + VALUES (1, 4, ?, ?, ?) + `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); } catch (error) { @@ -1899,8 +2024,18 @@ async function createMysqlStore({ seed }) { '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 users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE', + 'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE', '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 candidate_profiles ADD COLUMN IF NOT EXISTS native_place VARCHAR(160) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS birth_date VARCHAR(20) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS ethnicity VARCHAR(60) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL', + 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE', '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', @@ -1914,6 +2049,8 @@ async function createMysqlStore({ seed }) { "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 [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); + if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number'); 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'); @@ -2023,6 +2160,39 @@ async function createMysqlStore({ seed }) { connection.release(); } } + if (Number(metadataRows[0]?.app_version || 1) < 4) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const user of extension.users.filter(item => item.role === 'candidate')) await connection.execute( + `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), + must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`, + [optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id] + ); + await connection.execute(`UPDATE users SET candidate_number = COALESCE( + (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), + CONCAT('CAND-', RIGHT(id, 10)) + ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`); + for (const profile of extension.candidateProfiles) await connection.execute( + `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), + ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), + guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`, + [optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id] + ); + await connection.execute(`UPDATE registrations JOIN users ON users.id = registrations.user_id + SET registrations.registration_number = users.candidate_number + WHERE registrations.registration_number IS NULL OR registrations.registration_number = ''`); + await connection.execute('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } } if (!existing.length) { const initialState = seed(); @@ -2030,9 +2200,9 @@ async function createMysqlStore({ seed }) { try { await connection.beginTransaction(); const [insert] = await connection.execute(` - INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, created_at) - VALUES (1, 3, ?, ?) - `, [Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()]); + INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) + VALUES (1, 4, ?, ?, ?) + `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); } @@ -2049,6 +2219,10 @@ async function createMysqlStore({ seed }) { 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 [candidateNumberIndexes] = await pool.execute("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); + if (!candidateNumberIndexes.length) { + await pool.execute('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); + } const transaction = async operations => { const connection = await pool.getConnection(); diff --git a/server.mjs b/server.mjs index 9858d07..eb789c1 100644 --- a/server.mjs +++ b/server.mjs @@ -45,7 +45,8 @@ function seedDatabase() { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 3, createdAt: nowIso() }, + meta: { version: 4, createdAt: nowIso() }, + settings: { selfRegistrationEnabled: false }, organization: { name: '海州市教育考试中心', code: 'HZ-EDU-032', @@ -67,13 +68,14 @@ function seedDatabase() { { 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() } + { id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() } ], candidateProfiles: [ { id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000', + nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false, status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z' } ], @@ -215,7 +217,9 @@ function safeUser(user) { adminLevel: user.adminLevel || null, schoolId: user.schoolId || null, classId: user.classId || null, - displayName: user.displayName + displayName: user.displayName, + candidateNumber: user.candidateNumber || null, + mustChangePassword: Boolean(user.mustChangePassword) }; } @@ -236,7 +240,7 @@ const adminLevelNames = { super: '超级管理员', school: '校级管理员', c const permissionsByLevel = { super: ['*'], - school: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], + school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read'] }; @@ -325,19 +329,17 @@ 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) { +function candidateSequence(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; + return db.users.filter(item => item.role === 'candidate' && item.candidateNumber && (!prefix || item.candidateNumber.startsWith(prefix))).length + 1; } -function generateRegistrationNumber(db, registration, profile) { +function generateCandidateNumber(db, profile, year = String(new Date().getFullYear())) { 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 sequence = candidateSequence(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'; @@ -465,7 +467,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, 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 } }); + return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, 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) { @@ -485,36 +487,52 @@ async function handleAuth(request, response, pathname) { } if (request.method === 'POST' && pathname === '/api/auth/register') { const body = await readJson(request); - const username = cleanText(body.username, 50); const password = String(body.password || ''); const name = cleanText(body.name, 30); - const idNumber = cleanText(body.idNumber, 30); - const phone = cleanText(body.phone, 30); - if (!username || !name || !idNumber || !phone) return sendError(response, 400, '请完整填写账号和身份信息'); + const gender = cleanText(body.gender, 10); + if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别'); if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位'); const db = await readDb(); + if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录'); 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: 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: '注册成功,请等待管理员审核资料' }); + const draftProfile = { schoolId, classId, gender }; + const generated = generateCandidateNumber(db, draftProfile); + const userId = uid('usr'); + const user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() }; + const profile = { id: uid('profile'), userId, name, idNumber: `PENDING-${userId}`, phone: '', gender, email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; + await database.createCandidate(user, profile, null, null); + return sendJson(response, 201, { ok: true, registrationNumber: generated.number, 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()); + const account = cleanText(body.username, 120).toLowerCase(); + const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account); 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` }); } + if (request.method === 'POST' && pathname === '/api/auth/change-password') { + const user = await requireUser(request, response); + if (!user) return true; + const body = await readJson(request); + const currentPassword = String(body.currentPassword || ''); + const newPassword = String(body.newPassword || ''); + if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确'); + if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位'); + if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与初始密码相同'); + user.passwordHash = hashPassword(newPassword); + user.mustChangePassword = false; + const db = await readDb(); + const log = logAction(db, user, '修改登录密码', user.role === 'candidate' ? `报名号 ${user.candidateNumber}` : user.username); + await database.changePassword(user, log); + return sendJson(response, 200, { ok: true, user: safeUser(user) }); + } if (request.method === 'POST' && pathname === '/api/auth/logout') { const token = parseCookies(request).hz_session; if (token) sessions.delete(token); @@ -529,6 +547,9 @@ async function handleCandidate(request, response, pathname) { if (!user) return true; const db = await readDb(); const profile = db.candidateProfiles.find(item => item.userId === user.id); + if (user.mustChangePassword) return sendError(response, 428, '首次登录必须先修改初始密码'); + const profileRoute = pathname === '/api/candidate/profile'; + if (!profile.profileCompleted && !profileRoute) return sendError(response, 428, '请先补全个人信息并提交审核'); if (request.method === 'GET' && pathname === '/api/candidate/dashboard') { const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)); @@ -545,7 +566,7 @@ async function handleCandidate(request, response, pathname) { } if (request.method === 'PUT' && pathname === '/api/candidate/profile') { const body = await readJson(request); - const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone']; + const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone']; 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); @@ -554,9 +575,10 @@ async function handleCandidate(request, response, pathname) { 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 (!profile.name || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) 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.profileCompleted = true; profile.reviewNote = ''; profile.updatedAt = nowIso(); const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id); @@ -582,7 +604,7 @@ 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(), registrationNumber: '', numberRuleId: null, admitCard: null }; + const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || 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: '考试报名已提交' }); @@ -641,7 +663,7 @@ async function handleAdmin(request, response, pathname) { 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 }); + return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled }); } if (pathname === '/api/admin/admins' && request.method === 'POST') { if (!requirePermission(user, response, '*')) return true; @@ -661,6 +683,33 @@ async function handleAdmin(request, response, pathname) { await database.createAdmin(created, log); return sendJson(response, 201, { ok: true, admin: safeUser(created) }); } + if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const enabled = Boolean(body.enabled); + const log = logAction(db, user, enabled ? '开启自主注册' : '关闭自主注册', enabled ? '考生可从公开入口申请报名号' : '仅允许使用学校下发的报名号登录'); + await database.updateRegistrationSetting(enabled, log); + return sendJson(response, 200, { ok: true, enabled }); + } + if (pathname === '/api/admin/candidate-accounts' && request.method === 'POST') { + if (!requirePermission(user, response, 'candidates.write')) return true; + const body = await readJson(request); + const name = cleanText(body.name, 50); + const gender = cleanText(body.gender, 10); + const initialPassword = String(body.initialPassword || ''); + const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId; + const classId = cleanText(body.classId, 64); + const school = db.schools.find(item => item.id === schoolId && item.active); + const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active); + if (!name || !['男', '女'].includes(gender) || initialPassword.length < 8 || !school || !schoolClass) return sendError(response, 400, '请填写姓名、性别、有效学校班级和至少 8 位初始密码'); + const generated = generateCandidateNumber(db, { schoolId, classId, gender }); + const userId = uid('usr'); + const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName: name, schoolId, classId, active: true, mustChangePassword: true, createdAt: nowIso() }; + const profile = { id: uid('profile'), userId, name, gender, idNumber: `PENDING-${userId}`, phone: '', email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; + const log = logAction(db, user, '创建考生账户', `${name} · ${generated.number} · ${school.name} ${schoolClass.name}`); + await database.createCandidate(candidateUser, profile, null, null, log); + return sendJson(response, 201, { ok: true, candidate: { ...profile, candidateNumber: generated.number, mustChangePassword: true } }); + } if (pathname === '/api/admin/centers' && request.method === 'GET') { if (!requirePermission(user, response, 'centers.read')) return true; @@ -754,10 +803,7 @@ async function handleAdmin(request, response, pathname) { 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; - } + if (rule) preview = generateCandidateNumber(db, previewProfile).number; const batchCandidates = db.registrations.filter(item => item.status === 'approved' && !item.registrationNumber).map(registration => { const profile = db.candidateProfiles.find(item => item.userId === registration.userId); return { id: registration.id, examId: registration.examId, examName: db.exams.find(item => item.id === registration.examId)?.name || '', schoolId: profile?.schoolId || '', schoolName: profile?.school || '', candidateName: profile?.name || '', createdAt: registration.createdAt }; @@ -784,6 +830,7 @@ async function handleAdmin(request, response, pathname) { if (pathname === '/api/admin/registration-numbers/batch' && request.method === 'POST') { if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); + const activeRule = db.numberRules.find(item => item.active) || null; const examId = cleanText(body.examId, 64); const schoolId = cleanText(body.schoolId, 64); const selectedIds = Array.isArray(body.registrationIds) ? new Set(body.registrationIds.map(item => cleanText(item, 64))) : null; @@ -794,16 +841,22 @@ async function handleAdmin(request, response, pathname) { 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, '当前筛选条件下没有审核通过且尚未生成报名号的记录'); + if (!eligible.length) return sendError(response, 409, '当前筛选条件下没有需要同步账户报名号的记录'); + const changedUsers = new Map(); const generated = eligible.map(registration => { const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - const result = generateRegistrationNumber(db, registration, profile); - registration.registrationNumber = result.number; - registration.numberRuleId = result.ruleId; + const account = db.users.find(item => item.id === registration.userId); + if (!account.candidateNumber) { + const result = generateCandidateNumber(db, profile); + account.candidateNumber = result.number; + changedUsers.set(account.id, account); + } + registration.registrationNumber = account.candidateNumber; + registration.numberRuleId = activeRule?.id || null; return registration; }); - const log = logAction(db, user, '批量生成报名号', `${generated.length} 条 · ${examId || '全部考试'} · ${schoolId || '全部学校'}`); - await database.assignRegistrationNumbers(generated, log); + const log = logAction(db, user, '批量同步账户报名号', `${generated.length} 条考试报名 · ${changedUsers.size} 个新账户号码`); + await database.assignCandidateNumbers([...changedUsers.values()], generated, log); return sendJson(response, 200, { ok: true, count: generated.length, registrations: generated.map(item => ({ id: item.id, registrationNumber: item.registrationNumber })) }); } @@ -919,9 +972,10 @@ async function handleAdmin(request, response, pathname) { 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) }; + const account = db.users.find(item => item.id === profile.userId); + return { ...profile, idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber), username: account?.username, candidateNumber: account?.candidateNumber || '', mustChangePassword: Boolean(account?.mustChangePassword), workflow: workflowView(db, instance) }; }); - return sendJson(response, 200, { ok: true, candidates }); + return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) }); } const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); if (request.method === 'PATCH' && candidateMatch) { @@ -989,10 +1043,12 @@ async function handleAdmin(request, response, pathname) { 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); + const account = db.users.find(item => item.id === registration.userId); + if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配'); 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; } + registration.registrationNumber = account.candidateNumber; + registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId; } 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); @@ -1006,10 +1062,11 @@ async function handleAdmin(request, response, pathname) { 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}`); + const account = db.users.find(item => item.id === registration.userId); + if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号'); + registration.registrationNumber = account.candidateNumber; + registration.numberRuleId = db.numberRules.find(item => item.active)?.id || null; + const log = logAction(db, user, '同步账户报名号', `${profile?.name || registration.userId} · ${account.candidateNumber}`); await database.assignRegistrationNumber(registration, log); } return sendJson(response, 200, { ok: true, registrationNumber: registration.registrationNumber }); diff --git a/styles.css b/styles.css index 6d5b83e..59c8174 100644 --- a/styles.css +++ b/styles.css @@ -184,9 +184,15 @@ button:disabled { cursor: not-allowed; opacity: .5; } /* 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; } +/* Candidate account issuance and first-login onboarding */ +.registration-closed { display:grid; gap:12px; padding:20px; border:1px solid #dde3ee; border-radius:11px; background:#f7f9fc; }.registration-closed strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.registration-closed span { color:#7f889b; font-size:9px; line-height:1.8; } +.issued-number { display:grid; justify-items:center; gap:10px; padding:32px 24px; text-align:center; }.issued-number > span { color:#818ca4; font-size:8px; }.issued-number > strong { padding:13px 18px; border:1px dashed #aebbd4; border-radius:9px; color:var(--navy); background:#f4f7fd; font-family:Consolas,monospace; font-size:25px; letter-spacing:1px; }.issued-number p { max-width:420px; margin:0; color:#858ea1; font-size:8px; line-height:1.8; } +.onboarding-page { min-height:100vh; display:grid; grid-template-columns:minmax(330px,36%) 1fr; background:#f3f5f9; }.onboarding-identity { position:sticky; top:0; min-height:100vh; display:flex; flex-direction:column; align-items:flex-start; padding:42px clamp(30px,5vw,72px); color:#fff; background:var(--navy); }.onboarding-identity > span { margin-top:82px; color:#8593ba; font-size:8px; letter-spacing:1px; }.onboarding-identity > strong { max-width:100%; margin:10px 0 15px; overflow-wrap:anywhere; font-family:Consolas,monospace; font-size:clamp(22px,3vw,36px); letter-spacing:1px; }.onboarding-identity > p { max-width:390px; margin:0; color:#abb6d2; font-size:10px; line-height:1.9; }.onboarding-identity > button { margin-top:auto; border:0; color:#98a6ca; background:transparent; font-size:8px; }.onboarding-steps { width:100%; display:grid; gap:0; margin-top:55px; }.onboarding-steps > div { position:relative; display:grid; grid-template-columns:34px 1fr; gap:12px; min-height:72px; opacity:.45; }.onboarding-steps > div:not(:last-child)::after { content:""; position:absolute; top:34px; bottom:0; left:16px; width:1px; background:#53638d; }.onboarding-steps > div.current,.onboarding-steps > div.done { opacity:1; }.onboarding-steps i { z-index:1; width:34px; height:34px; display:grid; place-items:center; border:1px solid #63739d; border-radius:50%; color:#c1cae0; background:var(--navy); font-size:9px; font-style:normal; }.onboarding-steps .current i { border-color:#fff; color:var(--navy); background:#fff; box-shadow:0 0 0 6px rgba(255,255,255,.08); }.onboarding-steps .done i { border-color:var(--jade); color:#fff; background:var(--jade); }.onboarding-steps span { display:grid; align-content:start; gap:4px; padding-top:3px; }.onboarding-steps b { font-size:10px; }.onboarding-steps small { color:#96a3c5; font-size:7px; }.onboarding-work { width:min(900px,100%); padding:55px clamp(24px,5vw,70px) 70px; }.onboarding-work-head { margin-bottom:24px; }.onboarding-work-head > span { color:#7d89a2; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.onboarding-work-head h1 { margin:8px 0; font-family:"STKaiti"; font-size:34px; font-weight:400; }.onboarding-work-head p { margin:0; color:#828b9d; font-size:9px; }.password-onboarding { max-width:560px; padding:23px; }.password-rule { display:grid; gap:4px; margin-bottom:18px; padding:12px; border-left:3px solid var(--blue); background:#eef3fb; }.password-rule b { font-size:9px; }.password-rule span { color:#6e7990; font-size:8px; }.password-onboarding form { display:grid; gap:14px; }.onboarding-profile .profile-form { padding-top:0; }.profile-form .wide-field { grid-column:span 2; } +.registration-policy { display:flex; align-items:center; justify-content:space-between; gap:25px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f1f5fc); }.registration-policy > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.registration-policy h2 { margin:5px 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.registration-policy p { margin:0; color:#848d9f; font-size:8px; }.registration-policy form { display:flex; align-items:center; gap:11px; }.policy-state { padding:6px 9px; border-radius:99px; color:#8b5f22; background:#fff0d4; font-size:8px; font-weight:700; }.policy-state.open { color:#247157; background:#e2f2ec; }.onboarding-badge { display:inline-flex; padding:5px 8px; border-radius:6px; color:#8a6229; background:#fff0d6; font-size:8px; font-weight:700; }.account-issue-note { display:flex; align-items:center; gap:10px; padding:11px 13px; border-radius:8px; color:#6d5733; background:#fff5e3; }.account-issue-note strong { font-size:8px; }.account-issue-note span { font-size:7px; }.account-number-principle { display:grid; grid-template-columns:auto 1fr; gap:4px 18px; margin-bottom:15px; padding:17px 20px; border-left:4px solid var(--blue); border-radius:10px; color:#fff; background:var(--navy); }.account-number-principle span { grid-row:1/3; align-self:center; color:#8290b5; font-family:Consolas,monospace; font-size:7px; writing-mode:vertical-rl; letter-spacing:1px; }.account-number-principle strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.account-number-principle p { margin:0; color:#aeb8d2; font-size:8px; } + @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); } + .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); }.onboarding-page { grid-template-columns:290px 1fr; }.onboarding-identity { padding:32px 28px; }.onboarding-work { padding:42px 28px 60px; } } @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; } + .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; }.onboarding-page { display:block; }.onboarding-identity { position:relative; min-height:auto; padding:25px 20px; }.onboarding-identity > span { margin-top:35px; }.onboarding-identity > p { display:none; }.onboarding-identity > button { position:absolute; top:26px; right:20px; }.onboarding-steps { margin-top:30px; }.onboarding-steps > div { min-height:58px; }.onboarding-work { padding:30px 14px 45px; }.profile-form .wide-field { grid-column:auto; }.registration-policy { align-items:flex-start; flex-direction:column; }.registration-policy form { width:100%; justify-content:space-between; }.account-number-principle { grid-template-columns:1fr; }.account-number-principle span { grid-row:auto; writing-mode:horizontal-tb; } } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 0655066..8c41ba9 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -55,6 +55,7 @@ const schoolAdmin = createClient(); const schoolAdmin2 = createClient(); const classAdmin = createClient(); const candidate = createClient(); +const selfCandidate = createClient(); const anonymous = createClient(); try { @@ -82,27 +83,13 @@ try { assert.equal(publicHome.response.status, 200); assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目'); + assert.equal(publicHome.data.selfRegistrationEnabled, false, '自主注册默认应关闭'); - const register = await candidate.request('/api/auth/register', { + const closedRegister = await candidate.request('/api/auth/register', { method: 'POST', - body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', schoolId: 'school_hz1', classId: 'class_hz1_302' } + body: { password: 'Test12345!', name: '测试考生', gender: '男', schoolId: 'school_hz1', classId: 'class_hz1_302' } }); - assert.equal(register.response.status, 201, '考生应可自主注册'); - - const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: 'test_candidate', password: 'Test12345!' } }); - assert.equal(loginCandidate.data.user.role, 'candidate'); - - const updateProfile = await candidate.request('/api/candidate/profile', { - method: 'PUT', - 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', '资料修改后应重新进入审核'); - const refreshedSession = await candidate.request('/api/auth/me'); - assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步'); - - const candidateCannotAdmin = await candidate.request('/api/admin/dashboard'); - assert.equal(candidateCannotAdmin.response.status, 403, '考生不得访问管理接口'); + assert.equal(closedRegister.response.status, 403, '自主注册关闭时公开注册必须拒绝'); const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } }); assert.equal(loginAdmin.data.user.role, 'admin'); @@ -111,6 +98,38 @@ try { assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school'); assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class'); + const issuedAccount = await schoolAdmin.request('/api/admin/candidate-accounts', { method: 'POST', body: { + name: '测试考生', gender: '男', classId: 'class_hz1_302', initialPassword: 'Initial123!' + } }); + assert.equal(issuedAccount.response.status, 201, '校级管理员应能为本校考生创建固定报名号账户'); + const candidateNumber = issuedAccount.data.candidate.candidateNumber; + assert.match(candidateNumber, /^2026-HZ01-M-\d{4}$/, '账户报名号应按当前规则生成'); + const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: candidateNumber, password: 'Initial123!' } }); + assert.equal(loginCandidate.data.user.mustChangePassword, true, '学校下发账户首次登录必须修改初始密码'); + assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未修改初始密码前不得进入考生业务'); + const changedPassword = await candidate.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: 'Initial123!', newPassword: 'Test12345!' } }); + assert.equal(changedPassword.data.user.mustChangePassword, false, '修改密码后应解除首次登录限制'); + assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务'); + const updateProfile = await candidate.request('/api/candidate/profile', { + method: 'PUT', + body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市测试区 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' } + }); + assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料'); + assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成'); + assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核'); + const refreshedSession = await candidate.request('/api/auth/me'); + assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步'); + const candidateCannotAdmin = await candidate.request('/api/admin/dashboard'); + assert.equal(candidateCannotAdmin.response.status, 403, '考生不得访问管理接口'); + + assert.equal((await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: true } })).response.status, 200, '超级管理员应能开启自主注册'); + const openHome = await anonymous.request('/api/public/home'); + assert.equal(openHome.data.selfRegistrationEnabled, true, '公开端应同步自主注册开关'); + const selfRegister = await selfCandidate.request('/api/auth/register', { method: 'POST', body: { password: 'Self12345!', name: '自主注册考生', gender: '女', schoolId: 'school_hz3', classId: 'class_hz3_301' } }); + assert.equal(selfRegister.response.status, 201, '开关开启后考生应可自主申请固定报名号'); + assert.match(selfRegister.data.registrationNumber, /^2026-HZ03-F-\d{4}$/); + await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: false } }); + 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'); @@ -203,7 +222,7 @@ try { assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试'); const candidates = await admin.request('/api/admin/candidates'); - const profile = candidates.data.candidates.find(item => item.username === 'test_candidate'); + const profile = candidates.data.candidates.find(item => item.candidateNumber === candidateNumber); assert.ok(profile, '管理员应能看到新注册考生'); assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料'); const schoolCandidates = await schoolAdmin.request('/api/admin/candidates'); @@ -245,7 +264,7 @@ try { 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}$/, '终审通过后应按年份、学校、性别和流水号生成报名号'); + assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号'); const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号'); @@ -273,7 +292,8 @@ try { 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: '通过' } }); + const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } }); + assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号'); await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`); assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载'); @@ -300,10 +320,10 @@ try { 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('✓ 成绩录入、发布与考生查询'); } finally {