Implement fixed candidate account onboarding

This commit is contained in:
2026-07-20 08:29:32 +08:00 Unverified
parent 5e6b83b2b2
commit 45d48c8ffa
7 changed files with 455 additions and 130 deletions
+21
View File
@@ -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.
+10 -5
View File
@@ -13,7 +13,11 @@
### 考生中心 ### 考生中心
- 考生自主注册、登录和实名资料维护 - 报名号即考生账户,同一考生参加不同考试始终使用同一个号码
- 学校管理员创建考生账户并下发报名号、初始密码
- 首次登录强制修改初始密码,完成后才能补全个人信息
- 完整维护姓名、性别、证件号码、籍贯、出生日期、民族、家庭住址、手机号、邮箱、学校、班级、监护人和紧急联系人
- 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号
- 资料审核状态与管理员审核意见 - 资料审核状态与管理员审核意见
- 查看开放考试并自主选择多个报考科目 - 查看开放考试并自主选择多个报考科目
- 查看报名、缴费和审核状态 - 查看报名、缴费和审核状态
@@ -30,7 +34,8 @@
- 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程 - 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程
- 当前处理人可将流程转交给同范围的同级管理员 - 当前处理人可将流程转交给同范围的同级管理员
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 - 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
- 按考试、学校筛选,批量为已审核且缺号的报名记录生成报名号 - 报名号在创建考生账户时只生成一次,后续考试报名自动复用
- 按考试、学校筛选,为旧数据批量同步考生账户的固定报名号
- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态 - 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态
- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案 - 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案
- 考务指标与审计日志 - 考务指标与审计日志
@@ -67,7 +72,7 @@ npm start
打开 <http://127.0.0.1:4173>。 打开 <http://127.0.0.1:4173>。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v3,补充分级权限、报名号、结构化考场和变更审批结构。 本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v4,补充分级权限、固定考生报名号、首次改密、注册开关、完整资料、结构化考场和变更审批结构。
## 数据库配置 ## 数据库配置
@@ -129,7 +134,7 @@ npm start
| 校级管理员 | `school_admin` | `School123!` | | 校级管理员 | `school_admin` | `School123!` |
| 同校校级管理员(转交演示) | `school_admin_2` | `School123!` | | 同校校级管理员(转交演示) | `school_admin_2` | `School123!` |
| 班级管理员 | `class_admin` | `Class123!` | | 班级管理员 | `class_admin` | `Class123!` |
| 考生 | `13800138000` | `Candidate123!` | | 考生(首次登录需改密) | `2026-HZ01-F-0001` | `Candidate123!` |
## 自动化测试 ## 自动化测试
@@ -137,7 +142,7 @@ npm start
npm test npm test
``` ```
测试使用独立临时 SQLite 数据库,覆盖三级管理员数据范围、两级审批、同级转交、超级管理员监督退回、报名号规则与筛选批量生成、结构化考点考场及变更审批、班级只读、多科目报名、准考证和成绩完整流程。 测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、报名号规则与旧记录批量同步、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。
## 项目结构 ## 项目结构
+65 -23
View File
@@ -103,13 +103,13 @@ function renderHome() {
app.innerHTML = `${publicHeader()}<main class="public-main"> app.innerHTML = `${publicHeader()}<main class="public-main">
<section class="hero"> <section class="hero">
<div class="hero-grid"> <div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">HAIZHOU EXAMINATION SERVICE</p><h1>每一次应考<br><em>都有清晰的下一步。</em></h1><p class="hero-lead">从身份核验、选科报名到准考证与成绩查询,所有考试事项集中办理,进度实时可见。</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/exams">继续报名 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="register">创建考生账号 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div> <div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">HAIZHOU EXAMINATION SERVICE</p><h1>一个报名号<br><em>贯穿每一次考试。</em></h1><p class="hero-lead">使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'} ${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div> </div>
</section> </section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section> <section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section> <section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>从注册到查分,只需五步</h2></div><p>每一步都会在考生中心显示当前状态和下一项操作。</p></div><div class="flow-track">${[['01','注册并填写资料','使用手机号注册,完善实名信息。'],['02','等待资料审核','管理员核验身份与学籍信息。'],['03','选择考试科目','在报名期内自主勾选报考科目。'],['04','下载准考证','管理员生成后,在开放期内下载。'],['05','查询考试成绩','成绩发布后登录平台查询。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section> <section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}</p></div><span>本平台展示数据仅用于系统演示</span></footer>`; </main><footer class="public-footer"><div>${brand()}<p>${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}</p></div><span>本平台展示数据仅用于系统演示</span></footer>`;
} }
@@ -128,16 +128,17 @@ function renderPublicExam(exam) {
function renderAuth(kind) { function renderAuth(kind) {
const login = kind === 'login'; const login = kind === 'login';
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '欢迎回来,' : '从这里,'}<br><em>${login ? '继续你的考试进程。' : '开始你的应考旅程。'}</em></h1><p>统一管理报名、审核、准考证与成绩,不错过每一个关键节点。</p></div><div class="auth-quote"><span>考试服务承诺</span><p>资料有状态、报名有回执、下载有时间、成绩有依据。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CREATE ACCOUNT'}</p><h2>${login ? '登录衡准' : '考生自主注册'}</h2><p>${login ? '使用考生账号或管理员账号进入系统。' : '请填写真实身份信息,注册后由管理员审核。'}</p>${login ? loginForm() : registerForm()}<div class="auth-switch">${login ? '还没有考生账号?' : '已经注册过?'}<button data-route="${login ? 'register' : 'login'}">${login ? '立即注册' : '返回登录'}</button></div>${login ? `<div class="demo-accounts"><strong>演示账号</strong><button data-action="fill-demo" data-type="candidate">考生:13800138000 / Candidate123!</button><button data-action="fill-demo" data-type="admin">超级管理员:admin / Admin123!</button><button data-action="fill-demo" data-type="school">校级管理员:school_admin / School123!</button><button data-action="fill-demo" data-type="class">班级管理员:class_admin / Class123!</button></div>` : ''}</div></section></main>`; const selfRegistration = state.publicData.selfRegistrationEnabled;
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}${login ? `<div class="demo-accounts"><strong>演示账号</strong><button data-action="fill-demo" data-type="candidate">考生:2026-HZ01-F-0001 / Candidate123!</button><button data-action="fill-demo" data-type="admin">超级管理员:admin / Admin123!</button><button data-action="fill-demo" data-type="school">校级管理员:school_admin / School123!</button><button data-action="fill-demo" data-type="class">班级管理员:class_admin / Class123!</button></div>` : ''}</div></section></main>`;
} }
function loginForm() { function loginForm() {
return `<form class="stack-form" data-form="login"><label><span>账号</span><input name="username" autocomplete="username" required placeholder="手机号或管理员账号"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="请输入登录密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`; return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
} }
function registerForm() { function registerForm() {
const schools = state.publicData.schools || []; const schools = state.publicData.schools || [];
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别</span><select name="gender"><option value="">请选择</option><option>男</option><option>女</option></select></label></div><label><span>证件号码 *</span><input name="idNumber" required placeholder="居民身份证号码"></label><div class="field-row"><label><span>手机号 *</span><input name="phone" required placeholder="用于接收通知"></label><label><span>登录账号 *</span><input name="username" required placeholder="建议使用手机号"></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我确认以上信息真实有效,并同意用于考试报名与身份核验。</span></label><button class="solid-button large" type="submit">注册考生账${icons.arrow}</button></form>`; return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名${icons.arrow}</button></form>`;
} }
const candidateNav = [ const candidateNav = [
@@ -164,6 +165,7 @@ function portalHeadingAction(role, page) {
if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`; if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`; if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`; if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
if (role === 'admin' && page === 'candidates' && state.user.adminLevel !== 'class') return `<button class="solid-button" data-action="new-candidate-account">${icons.plus} 创建考生账户</button>`;
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`; if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`; if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
return ''; return '';
@@ -173,8 +175,29 @@ function loadingPanel() {
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`; return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
} }
function onboardingShell(stage, content) {
const passwordDone = stage !== 'password';
return `<main class="onboarding-page"><aside class="onboarding-identity">${brand()}<span>固定报名号</span><strong>${h(state.user.candidateNumber)}</strong><p>这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。</p><div class="onboarding-steps"><div class="${stage === 'password' ? 'current' : 'done'}"><i>${passwordDone ? '✓' : '1'}</i><span><b>修改初始密码</b><small>设置仅本人知道的新密码</small></span></div><div class="${stage === 'profile' ? 'current' : passwordDone ? '' : ''}"><i>2</i><span><b>补全个人信息</b><small>实名、籍贯、住址和学籍信息</small></span></div><div><i>3</i><span><b>等待资料审核</b><small>审核通过后开始考试报名</small></span></div></div><button data-action="logout">退出当前账户</button></aside><section class="onboarding-work"><div class="onboarding-work-head"><span>FIRST SIGN-IN</span><h1>${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}</h1><p>${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}</p></div>${content}</section></main>`;
}
function passwordOnboardingForm() {
return `<section class="panel password-onboarding"><div class="password-rule"><b>新密码要求</b><span>至少 8 位,且不能与初始密码相同。</span></div><form class="stack-form" data-form="candidate-password"><label><span>当前初始密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>设置新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码并继续 ${icons.arrow}</button></form></section>`;
}
async function renderCandidate(page) { async function renderCandidate(page) {
if (state.user?.role !== 'candidate') return navigate('login'); 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 = { const meta = {
dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'], dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'],
profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'], profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'],
@@ -212,10 +235,11 @@ function candidateDashboard(data) {
return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`; return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
} }
function candidateProfile(data) { function candidateProfile(data, onboarding = false) {
const { profile, schools = [], classes = [], workflow } = data; const { profile, schools = [], classes = [], workflow } = data;
const step = workflow?.currentStepDetail; const step = workflow?.currentStepDetail;
return `<section class="panel form-panel">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<div class="form-section-title"><span>01</span><div><h2>实名信息</h2><p>姓名与证件号码须与有效证件完全一致。</p></div></div><form class="profile-form" data-form="candidate-profile"><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别</span><select name="gender"><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(profile?.idNumber)}"></label><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label></div><div class="form-section-title"><span>02</span><div><h2>学籍与联系信息</h2><p>学校和班级决定资料的管理范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>电子邮箱</span><input name="email" type="email" value="${h(profile?.email)}"></label><label><span>常住地址</span><input name="address" value="${h(profile?.address)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>保存后资料将按当前流程重新审批。</p><button class="solid-button" type="submit">保存并提交审批</button></div></form></section>`; const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber;
return `<section class="panel form-panel ${onboarding ? 'onboarding-profile' : ''}">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<form class="profile-form" data-form="candidate-profile"><div class="form-section-title"><span>01</span><div><h2>身份信息</h2><p>姓名和证件号码须与有效证件完全一致。</p></div></div><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(idNumber)}"></label><label><span>出生日期</span><input name="birthDate" type="date" value="${h(profile?.birthDate)}"></label><label><span>籍贯 *</span><input name="nativePlace" required value="${h(profile?.nativePlace)}" placeholder="例如:江苏海州"></label><label><span>民族</span><input name="ethnicity" value="${h(profile?.ethnicity)}" placeholder="例如:汉族"></label></div><div class="form-section-title"><span>02</span><div><h2>学校与班级</h2><p>学校和班级决定资料审批范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div><div class="form-section-title"><span>03</span><div><h2>家庭与联系信息</h2><p>用于考试通知、身份复核和紧急联系。</p></div></div><div class="form-grid"><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label><label><span>电子邮箱 *</span><input name="email" type="email" required value="${h(profile?.email)}"></label><label class="wide-field"><span>家庭住址 *</span><input name="address" required value="${h(profile?.address)}" placeholder="请填写省、市、区及详细门牌"></label><label><span>邮政编码</span><input name="postalCode" value="${h(profile?.postalCode)}"></label><label><span>监护人姓名</span><input name="guardianName" value="${h(profile?.guardianName)}"></label><label><span>监护人电话</span><input name="guardianPhone" value="${h(profile?.guardianPhone)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}</p><button class="solid-button" type="submit">${onboarding ? '提交个人信息' : '保存并提交审批'}</button></div></form></section>`;
} }
function candidateExams(data) { function candidateExams(data) {
@@ -223,7 +247,7 @@ function candidateExams(data) {
} }
function candidateRegistrations(registrations) { function candidateRegistrations(registrations) {
return registrations.length ? `<div class="registration-cards">${registrations.map(reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.status)}</header><div class="registration-info"><dl><div><dt>报名号</dt><dd class="mono">${h(reg.registrationNumber || '审批通过后生成')}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '报名已进入审批流程,请留意当前步骤。' : '报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`).join('')}</div>` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); return registrations.length ? `<div class="registration-cards">${registrations.map(reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.status)}</header><div class="registration-info"><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || state.user.candidateNumber)}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`).join('')}</div>` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
} }
function candidateAdmit(registrations) { function candidateAdmit(registrations) {
@@ -252,7 +276,7 @@ async function renderAdmin(page) {
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'], 'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'],
'number-rules': ['报名号规则', '设计号码组成,并为缺失报名号的已审核记录批量生成。'] 'number-rules': ['报名号规则', '设计新账户号码组成,并为旧考试记录批量同步固定报名号。']
}; };
const allowedPages = adminNavForUser().map(item => item[0]); const allowedPages = adminNavForUser().map(item => item[0]);
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
@@ -279,12 +303,12 @@ function adminDashboard(data) {
function adminCandidates(candidates) { function adminCandidates(candidates) {
const readOnly = state.user.adminLevel === 'class'; const readOnly = state.user.adminLevel === 'class';
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索姓名、证件号学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>考生</th><th>证件号码</th><th>学校 / 班级</th><th>当前流程</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small>${h(item.gender || '未填写')} · ${h(item.phone)}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${badge(item.status)}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`; return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索报名号、姓名、证件号学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 班级</th><th>账户进度</th><th>更新时间</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
} }
function adminRegistrations(registrations) { function adminRegistrations(registrations) {
const readOnly = state.user.adminLevel === 'class'; const readOnly = state.user.adminLevel === 'class';
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索考生、考试、报名号"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="registrationTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${registrations.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '审批通过后生成')}</strong><small>${formatDate(reg.createdAt,true)}</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.status)}</td><td><button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`; return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="registrationTable" placeholder="搜索考生、考试、固定报名号"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="registrationTable" data-status="all">全部</button><button data-action="status-filter" data-target="registrationTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="registrationTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="registrationTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="registrationTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>账户报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${registrations.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.status)}</td><td><button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
} }
function adminExams(exams) { function adminExams(exams) {
@@ -306,7 +330,7 @@ function adminResults(data) {
} }
function adminUsers(data) { function adminUsers(data) {
return `<section class="panel data-panel"><div class="data-toolbar"><p>管理员层级决定可见范围和可执行操作;同一级可创建多名账号。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th></tr></thead><tbody>${data.admins.map(item => `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td></tr>`).join('')}</tbody></table></div></section>`; return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel"><div class="data-toolbar"><p>管理员层级决定可见范围和可执行操作;同一级可创建多名账号。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th></tr></thead><tbody>${data.admins.map(item => `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td></tr>`).join('')}</tbody></table></div></section>`;
} }
function adminCenters(data) { function adminCenters(data) {
@@ -326,7 +350,7 @@ function adminNumberRules(data) {
const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); const byType = Object.fromEntries(rule.segments.map(item => [item.type, item]));
const types = Object.keys(numberSegmentMeta); const types = Object.keys(numberSegmentMeta);
const candidates = data.batchCandidates || []; const candidates = data.batchCandidates || [];
return `<div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>组合报名号</h2><p>勾选字段并填写顺序;流水号为必选字段。</p></div><span>实时规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存并启用规则</button></form></section><aside class="rule-preview"><span>报名号样例</span><strong>${h(data.preview || '2026-HZ01-F-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>历史报名号不会因规则修改而变化。</small></aside></div><section class="panel batch-number-panel"><div class="batch-number-intro"><span>BATCH GENERATION</span><h2>批量生成报名号</h2><p>只处理“审核通过且报名号为空”的记录,可按考试和学校缩小范围;批次一次提交、整体写入。</p></div><form data-form="batch-registration-numbers"><label><span>考试范围</span><select name="examId"><option value="">全部考试</option>${(data.exams || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>学校范围</span><select name="schoolId"><option value="">全部学校</option>${(data.schools || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="batch-ready"><small>当前可生成</small><strong>${candidates.length}</strong><span>条</span></div><button class="solid-button" type="submit" ${candidates.length ? '' : 'disabled'}>按筛选条件批量生成</button></form><div class="batch-candidate-strip">${candidates.slice(0, 8).map(item => `<span><b>${h(item.candidateName)}</b><small>${h(item.schoolName)} · ${h(item.examName)}</small></span>`).join('') || '<p>当前没有缺失报名号的已审核记录。</p>'}${candidates.length > 8 ? `<em>另有 ${candidates.length - 8} 条</em>` : ''}</div></section>`; return `<section class="account-number-principle"><span>ONE CANDIDATE · ONE NUMBER</span><strong>报名号属于考生账户</strong><p>创建账户时生成一次,参加不同考试时只复制到报名记录,不再重新编号。</p></section><div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>账户报名号组成</h2><p>规则用于新建考生账户;流水号为必选字段。</p></div><span>当前规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存并用于新账户</button></form></section><aside class="rule-preview"><span>新账户样例</span><strong>${h(data.preview || '2026-HZ01-F-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>已有考生账户的报名号不会因规则修改而变化。</small></aside></div><section class="panel batch-number-panel"><div class="batch-number-intro"><span>LEGACY SYNCHRONIZATION</span><h2>批量同步账户报名号</h2><p>用于旧数据或导入记录:缺号的考试报名会复制考生账户的固定报名号,不会为每场考试另建号码。</p></div><form data-form="batch-registration-numbers"><label><span>考试范围</span><select name="examId"><option value="">全部考试</option>${(data.exams || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>学校范围</span><select name="schoolId"><option value="">全部学校</option>${(data.schools || []).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="batch-ready"><small>待同步</small><strong>${candidates.length}</strong><span>条</span></div><button class="solid-button" type="submit" ${candidates.length ? '' : 'disabled'}>同步账户报名号</button></form><div class="batch-candidate-strip">${candidates.slice(0, 8).map(item => `<span><b>${h(item.candidateName)}</b><small>${h(item.schoolName)} · ${h(item.examName)}</small></span>`).join('') || '<p>当前所有考试报名都已关联固定账户报名号。</p>'}${candidates.length > 8 ? `<em>另有 ${candidates.length - 8} 条</em>` : ''}</div></section>`;
} }
function adminFlowDesign(workflows) { function adminFlowDesign(workflows) {
@@ -414,7 +438,7 @@ document.addEventListener('click', async event => {
} }
if (action === 'fill-demo') { if (action === 'fill-demo') {
const form = document.querySelector('[data-form="login"]'); 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; [form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate;
return; return;
} }
@@ -426,6 +450,7 @@ document.addEventListener('click', async event => {
if (action === 'new-notice') return openNoticeForm(); if (action === 'new-notice') return openNoticeForm();
if (action === 'new-exam') return openExamForm(); if (action === 'new-exam') return openExamForm();
if (action === 'new-admin') return openAdminForm(); if (action === 'new-admin') return openAdminForm();
if (action === 'new-candidate-account') return openCandidateAccountForm();
if (action === 'new-center') return openCenterForm(); if (action === 'new-center') return openCenterForm();
if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id)); if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id));
if (action === 'add-center-room') { 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 === 'review-registration') return openRegistrationReview(target.dataset.id);
if (action === 'generate-registration-number') { if (action === 'generate-registration-number') {
await api(`/api/admin/registrations/${target.dataset.id}/registration-number`, { method: 'POST' }); await api(`/api/admin/registrations/${target.dataset.id}/registration-number`, { method: 'POST' });
toast('报名号已生成', '已按当前启用规则写入'); return renderRoute(); toast('账户报名号已同步', '考试记录已关联考生的固定号码'); return renderRoute();
} }
if (action === 'generate-admit') { if (action === 'generate-admit') {
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
@@ -527,13 +552,18 @@ document.addEventListener('submit', async event => {
if (kind === 'login') { if (kind === 'login') {
const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) }); const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) });
state.user = data.user; await refreshSession(); 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') { } else if (kind === 'register') {
await api('/api/auth/register', { method: 'POST', body: formObject(form) }); const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
toast('注册成功', '请登录后继续完善资料'); navigate('login'); setModal(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
} 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') { } else if (kind === 'candidate-profile') {
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); 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') { } else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
@@ -551,6 +581,13 @@ document.addEventListener('submit', async event => {
const body = formObject(form); const body = formObject(form);
await api('/api/admin/admins', { method: 'POST', body }); await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); 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') { } else if (kind === 'center-form') {
const body = formObject(form); const body = formObject(form);
const editing = Boolean(body.id); 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) type, position: Number(raw[`position_${type}`] || 99), value: raw[`value_${type}`] || '', width: Number(raw[`width_${type}`] || 0)
})).sort((a, b) => a.position - b.position); })).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 } }); 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') { } else if (kind === 'batch-registration-numbers') {
const result = await api('/api/admin/registration-numbers/batch', { method: 'POST', body: formObject(form) }); 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') { } else if (kind === 'workflow-design') {
const names = [...form.querySelectorAll('[name="stepName"]')]; const names = [...form.querySelectorAll('[name="stepName"]')];
const levels = [...form.querySelectorAll('[name="stepLevel"]')]; const levels = [...form.querySelectorAll('[name="stepLevel"]')];
@@ -627,6 +664,11 @@ function openAdminForm() {
setModal(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`); setModal(`<div class="modal-head"><div><span>ADMIN SCOPE</span><h2>添加分级管理员</h2><p>同一级可以创建多个账号;校级和班级管理员必须绑定数据范围。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="admin-form"><div class="field-row"><label><span>姓名 *</span><input name="displayName" required></label><label><span>管理员层级 *</span><select name="adminLevel" data-action="admin-level"><option value="school">校级管理员</option><option value="class">班级管理员</option><option value="super">超级管理员</option></select></label></div><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label data-admin-school><span>绑定学校</span><select name="schoolId" data-action="school-select"><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label class="hidden" data-admin-class><span>绑定班级</span><select name="classId"><option value="">请先选择学校</option>${classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">创建管理员</button></div></form>`);
} }
function openCandidateAccountForm() {
const { schools = [], classes = [] } = state.pageData;
setModal(`<div class="modal-head"><div><span>ISSUE CANDIDATE NUMBER</span><h2>创建考生账户</h2><p>系统按当前报名号规则生成固定账户;考生首次登录必须修改初始密码。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="candidate-account"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与学籍一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label>` : `<input type="hidden" name="schoolId" value="${h(state.user.schoolId)}">`}<label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${state.user.adminLevel === 'school' ? classes.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('') : ''}</select></label><label><span>初始密码 *</span><input name="initialPassword" type="password" minlength="8" required placeholder="至少 8 位,线下安全告知考生"></label><div class="account-issue-note"><strong>下发给考生</strong><span>生成的报名号 + 本次设置的初始密码</span></div><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">生成报名号并创建账户</button></div></form>`);
}
function centerRoomEditor(room = {}) { function centerRoomEditor(room = {}) {
return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label><span>座位起号 *</span><input name="roomSeatStart" type="number" min="1" required value="${h(room.seatStart || 1)}"></label><label><span>座位止号 *</span><input name="roomSeatEnd" type="number" min="1" required value="${h(room.seatEnd || 30)}"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`; return `<section class="center-room-editor"><input type="hidden" name="roomId" value="${h(room.id || '')}"><header><span>结构化考场</span><button type="button" data-action="remove-center-room">移除</button></header><div class="room-editor-grid"><label><span>考场代码 *</span><input name="roomCode" required value="${h(room.code || '')}" placeholder="如 001"></label><label><span>考场名称 *</span><input name="roomName" required value="${h(room.name || '')}" placeholder="如 第 001 考场"></label><label><span>楼栋 *</span><input name="roomBuilding" required value="${h(room.building || '')}" placeholder="如 教学楼 A"></label><label><span>楼层</span><input name="roomFloor" value="${h(room.floor || '')}" placeholder="如 2 层"></label><label><span>容量 *</span><input name="roomCapacity" type="number" min="1" required value="${h(room.capacity || 30)}"></label><label><span>座位起号 *</span><input name="roomSeatStart" type="number" min="1" required value="${h(room.seatStart || 1)}"></label><label><span>座位止号 *</span><input name="roomSeatEnd" type="number" min="1" required value="${h(room.seatEnd || 30)}"></label><label><span>考场类型</span><select name="roomType"><option value="standard" ${room.roomType === 'standard' ? 'selected' : ''}>标准考场</option><option value="computer" ${room.roomType === 'computer' ? 'selected' : ''}>机考考场</option><option value="accessible" ${room.roomType === 'accessible' ? 'selected' : ''}>无障碍考场</option><option value="spare" ${room.roomType === 'spare' ? 'selected' : ''}>备用考场</option></select></label><label><span>状态</span><select name="roomStatus"><option value="active" ${room.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${room.status === 'inactive' ? 'selected' : ''}>停用</option></select></label><label class="room-notes"><span>考场备注</span><input name="roomNotes" value="${h(room.notes || '')}" placeholder="设备、无障碍设施或备用安排"></label></div></section>`;
} }
@@ -653,13 +695,13 @@ function openFlowDetail(id) {
function openCandidateReview(id) { function openCandidateReview(id) {
const item = state.pageData.candidates.find(candidate => candidate.id === id); const item = state.pageData.candidates.find(candidate => candidate.id === id);
const canReview = state.user.adminLevel !== 'class' && item.status === 'pending'; const canReview = state.user.adminLevel !== 'class' && item.status === 'pending';
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p>最后更新:${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`); setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>${canReview ? '处理' : '查看'} ${h(item.name)} 的资料</h2><p class="mono">报名号 ${h(item.candidateNumber)} · 更新于 ${formatDate(item.updatedAt,true)}</p></div><button data-action="close-modal">×</button></div><div class="review-profile"><dl><div><dt>证件号码</dt><dd class="mono">${h(item.idNumberMasked)}</dd></div><div><dt>性别 / 籍贯</dt><dd>${h(item.gender)} · ${h(item.nativePlace)}</dd></div><div><dt>联系电话</dt><dd>${h(item.phone)}</dd></div><div><dt>电子邮箱</dt><dd>${h(item.email)}</dd></div><div><dt>就读学校</dt><dd>${h(item.school)}</dd></div><div><dt>年级班级</dt><dd>${h(item.grade)}</dd></div><div><dt>家庭住址</dt><dd>${h(item.address)}</dd></div><div><dt>监护人</dt><dd>${h(item.guardianName || item.emergencyContact)} · ${h(item.guardianPhone || item.emergencyPhone)}</dd></div><div><dt>当前步骤</dt><dd>${h(item.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>当前责任人</dt><dd>${h(item.workflow?.assignee?.displayName || '—')}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="candidate-review"><input type="hidden" name="id" value="${h(item.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回考生修改</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="填写核验说明或需要补充的资料">${h(item.reviewNote)}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
} }
function openRegistrationReview(id) { function openRegistrationReview(id) {
const reg = state.pageData.registrations.find(item => item.id === id); const reg = state.pageData.registrations.find(item => item.id === id);
const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending'; const canReview = state.user.adminLevel !== 'class' && reg.status === 'pending';
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>报名号</dt><dd class="mono">${h(reg.registrationNumber || '审批通过后生成')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : `<div class="modal-foot">${state.user.adminLevel === 'super' && reg.status === 'approved' && !reg.registrationNumber ? `<button class="ghost-button" data-action="generate-registration-number" data-id="${h(reg.id)}">生成报名号</button>` : ''}<button class="solid-button" data-action="close-modal">关闭</button></div>`}`); setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || '待同步账户号码')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">确认处理</button></div></form>` : `<div class="modal-foot">${state.user.adminLevel === 'super' && reg.status === 'approved' && !reg.registrationNumber ? `<button class="ghost-button" data-action="generate-registration-number" data-id="${h(reg.id)}">同步账户报名号</button>` : ''}<button class="solid-button" data-action="close-modal">关闭</button></div>`}`);
} }
function openNoticeForm() { function openNoticeForm() {
+206 -32
View File
@@ -35,6 +35,7 @@ const sqliteSchema = `
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL DEFAULT 1, schema_version INTEGER NOT NULL DEFAULT 1,
app_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 created_at TEXT NOT NULL
) STRICT; ) STRICT;
@@ -66,12 +67,14 @@ const sqliteSchema = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
candidate_number TEXT UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')),
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')), admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
class_id TEXT REFERENCES school_classes(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)), 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, display_name TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) STRICT; ) STRICT;
@@ -91,6 +94,13 @@ const sqliteSchema = `
address TEXT, address TEXT,
emergency_contact TEXT, emergency_contact TEXT,
emergency_phone 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')), status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
review_note TEXT, review_note TEXT,
reviewed_at TEXT, reviewed_at TEXT,
@@ -148,7 +158,7 @@ const sqliteSchema = `
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
reviewed_at TEXT, reviewed_at TEXT,
review_note TEXT, review_note TEXT,
registration_number TEXT UNIQUE, registration_number TEXT,
number_rule_id TEXT, number_rule_id TEXT,
UNIQUE (user_id, exam_id) UNIQUE (user_id, exam_id)
) STRICT; ) STRICT;
@@ -344,6 +354,7 @@ const mysqlSchema = [
id TINYINT UNSIGNED NOT NULL, id TINYINT UNSIGNED NOT NULL,
schema_version INT UNSIGNED NOT NULL DEFAULT 1, schema_version INT UNSIGNED NOT NULL DEFAULT 1,
app_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, created_at VARCHAR(35) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1)
@@ -380,16 +391,19 @@ const mysqlSchema = [
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) NOT NULL, id VARCHAR(64) NOT NULL,
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,
candidate_number VARCHAR(120) NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
role ENUM('admin', 'candidate') NOT NULL, role ENUM('admin', 'candidate') NOT NULL,
admin_level ENUM('super', 'school', 'class') NULL, admin_level ENUM('super', 'school', 'class') NULL,
school_id VARCHAR(64) NULL, school_id VARCHAR(64) NULL,
class_id VARCHAR(64) NULL, class_id VARCHAR(64) NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, active BOOLEAN NOT NULL DEFAULT TRUE,
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
display_name VARCHAR(100) NOT NULL, display_name VARCHAR(100) NOT NULL,
created_at VARCHAR(35) NOT NULL, created_at VARCHAR(35) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username), 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), 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_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 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, address VARCHAR(255) NULL,
emergency_contact VARCHAR(100) NULL, emergency_contact VARCHAR(100) NULL,
emergency_phone VARCHAR(60) 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, status ENUM('pending', 'approved', 'rejected') NOT NULL,
review_note VARCHAR(500) NULL, review_note VARCHAR(500) NULL,
reviewed_at VARCHAR(35) NULL, reviewed_at VARCHAR(35) NULL,
@@ -482,7 +503,6 @@ const mysqlSchema = [
number_rule_id VARCHAR(64) NULL, number_rule_id VARCHAR(64) NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_registrations_user_exam (user_id, exam_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_status (status),
KEY idx_registrations_exam (exam_id), KEY idx_registrations_exam (exam_id),
CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 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; const nullable = value => value == null || value === '' ? null : value;
add( add(
'UPDATE schema_metadata SET schema_version = 3, app_version = ?, created_at = ? WHERE id = 1', 'UPDATE schema_metadata SET schema_version = 4, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.meta?.createdAt || new Date().toISOString() Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
); );
add( add(
'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)', 'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)',
@@ -735,10 +756,11 @@ function buildSeedOperations(state) {
for (const user of state.users) { for (const user of state.users) {
add( add(
`INSERT INTO users ( `INSERT INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, must_change_password, display_name, created_at
user.id, user.username, user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId), ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullable(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt 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( add(
`INSERT INTO candidate_profiles ( `INSERT INTO candidate_profiles (
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address,
emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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, 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.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
nullable(profile.classId), nullable(profile.address), 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 nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt
); );
} }
@@ -982,6 +1007,7 @@ function stateFromRows(rows) {
const organization = rows.organization; const organization = rows.organization;
const state = { const state = {
meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at }, meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at },
settings: { selfRegistrationEnabled: Boolean(rows.system.self_registration_enabled) },
organization: { organization: {
name: organization.name, name: organization.name,
code: organization.code, code: organization.code,
@@ -1005,12 +1031,14 @@ function stateFromRows(rows) {
users: rows.users.map(row => ({ users: rows.users.map(row => ({
id: row.id, id: row.id,
username: row.username, username: row.username,
candidateNumber: row.candidate_number || '',
passwordHash: row.password_hash, passwordHash: row.password_hash,
role: row.role, role: row.role,
adminLevel: row.admin_level || (row.role === 'admin' ? 'super' : null), adminLevel: row.admin_level || (row.role === 'admin' ? 'super' : null),
schoolId: row.school_id || null, schoolId: row.school_id || null,
classId: row.class_id || null, classId: row.class_id || null,
active: row.active == null ? true : Boolean(row.active), active: row.active == null ? true : Boolean(row.active),
mustChangePassword: Boolean(row.must_change_password),
displayName: row.display_name, displayName: row.display_name,
createdAt: row.created_at createdAt: row.created_at
})), })),
@@ -1029,6 +1057,13 @@ function stateFromRows(rows) {
address: row.address || '', address: row.address || '',
emergencyContact: row.emergency_contact || '', emergencyContact: row.emergency_contact || '',
emergencyPhone: row.emergency_phone || '', 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, status: row.status,
reviewNote: row.review_note || '', reviewNote: row.review_note || '',
reviewedAt: row.reviewed_at, reviewedAt: row.reviewed_at,
@@ -1310,28 +1345,33 @@ function createRepository({ client, location, read, transaction, close }) {
location, location,
read, read,
close, close,
async createCandidate(user, profile, instance, action) { async createCandidate(user, profile, instance, action, log = null) {
const operations = [ const operations = [
operation( operation(
`INSERT INTO users ( `INSERT INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, must_change_password, display_name, created_at
user.id, user.username, user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId), ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt 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( operation(
`INSERT INTO candidate_profiles ( `INSERT INTO candidate_profiles (
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address, id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address,
emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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, 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.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId),
optional(profile.classId), optional(profile.address), 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 optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
) )
]; ];
if (instance && action) operations.push(...workflowCreateOperations(instance, action)); if (instance && action) operations.push(...workflowCreateOperations(instance, action));
if (log) operations.push(auditOperation(log));
await transaction(operations); await transaction(operations);
}, },
async updateCandidateProfile(profile, displayName, instance, action) { async updateCandidateProfile(profile, displayName, instance, action) {
@@ -1340,12 +1380,14 @@ function createRepository({ client, location, read, transaction, close }) {
`UPDATE candidate_profiles SET `UPDATE candidate_profiles SET
name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?, name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?,
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, 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 = ?`, WHERE id = ?`,
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), 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.school), optional(profile.grade), optional(profile.address), optional(profile.schoolId),
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, 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 optional(profile.reviewerId), profile.updatedAt, profile.id
), ),
operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) 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)); if (instance && action) operations.push(...workflowCreateOperations(instance, action));
await transaction(operations); 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) { async createRegistration(registration, instance, action) {
const operations = [operation( const operations = [operation(
`INSERT INTO registrations ( `INSERT INTO registrations (
@@ -1595,12 +1651,16 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log) auditOperation(log)
]); ]);
}, },
async assignRegistrationNumbers(registrations, log) { async assignCandidateNumbers(users, registrations, log) {
const operations = registrations.map(registration => operation( 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 = ? `UPDATE registrations SET registration_number = ?, number_rule_id = ?
WHERE id = ? AND (registration_number IS NULL OR registration_number = '')`, WHERE id = ? AND (registration_number IS NULL OR registration_number = '')`,
registration.registrationNumber, optional(registration.numberRuleId), registration.id registration.registrationNumber, optional(registration.numberRuleId), registration.id
)); )));
operations.push(auditOperation(log)); operations.push(auditOperation(log));
await transaction(operations); await transaction(operations);
}, },
@@ -1706,9 +1766,14 @@ async function createSqliteStore({ path, seed }) {
} }
}; };
ensureColumns('users', [ 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('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
ensureColumns('test_centers', [ ensureColumns('test_centers', [
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], ['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(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(); const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
if (existingSystem && Number(existingSystem.app_version || 1) < 2) { 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()) { if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
const initialState = seed(); const initialState = seed();
connection.exec('BEGIN IMMEDIATE'); connection.exec('BEGIN IMMEDIATE');
try { try {
connection.prepare(` connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, created_at) INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 3, ?, ?) VALUES (1, 4, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()); `).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); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT'); connection.exec('COMMIT');
} catch (error) { } 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 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 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 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 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 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 registration_number VARCHAR(120) NULL',
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL', 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS 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" "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); 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'); const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
if (existing.length) { if (existing.length) {
const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1'); const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1');
@@ -2023,6 +2160,39 @@ async function createMysqlStore({ seed }) {
connection.release(); 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) { if (!existing.length) {
const initialState = seed(); const initialState = seed();
@@ -2030,9 +2200,9 @@ async function createMysqlStore({ seed }) {
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
const [insert] = await connection.execute(` const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, created_at) INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 3, ?, ?) VALUES (1, 4, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()]); `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) { if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
} }
@@ -2049,6 +2219,10 @@ async function createMysqlStore({ seed }) {
if (!centerCodeIndexes.length) { if (!centerCodeIndexes.length) {
await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); 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 transaction = async operations => {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
+102 -45
View File
@@ -45,7 +45,8 @@ function seedDatabase() {
const examId = 'exam_autumn_2026'; const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026'; const registrationId = 'reg_demo_2026';
return { return {
meta: { version: 3, createdAt: nowIso() }, meta: { version: 4, createdAt: nowIso() },
settings: { selfRegistrationEnabled: false },
organization: { organization: {
name: '海州市教育考试中心', name: '海州市教育考试中心',
code: 'HZ-EDU-032', 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: 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: 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: '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: [ candidateProfiles: [
{ {
id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', 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', phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000', 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' 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, adminLevel: user.adminLevel || null,
schoolId: user.schoolId || null, schoolId: user.schoolId || null,
classId: user.classId || 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 = { const permissionsByLevel = {
super: ['*'], 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'] 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'); 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 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); 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); const rule = db.numberRules.find(item => item.active);
if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 }); if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 });
const school = db.schools.find(item => item.id === profile.schoolId); const school = db.schools.find(item => item.id === profile.schoolId);
const exam = db.exams.find(item => item.id === registration.examId); const sequence = candidateSequence(db, rule, profile.schoolId, year);
const year = String(new Date(exam?.examStart || Date.now()).getFullYear());
const sequence = registrationSequence(db, rule, profile.schoolId, year);
const parts = rule.segments.map(segment => { const parts = rule.segments.map(segment => {
if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4)); if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4));
if (segment.type === 'school_code') return school?.code || 'NOSCHOOL'; if (segment.type === 'school_code') return school?.code || 'NOSCHOOL';
@@ -465,7 +467,7 @@ async function handlePublic(pathname, response) {
if (pathname === '/api/public/home') { 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 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 })); 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\/([^/]+)$/); const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
if (noticeMatch) { if (noticeMatch) {
@@ -485,36 +487,52 @@ async function handleAuth(request, response, pathname) {
} }
if (request.method === 'POST' && pathname === '/api/auth/register') { if (request.method === 'POST' && pathname === '/api/auth/register') {
const body = await readJson(request); const body = await readJson(request);
const username = cleanText(body.username, 50);
const password = String(body.password || ''); const password = String(body.password || '');
const name = cleanText(body.name, 30); const name = cleanText(body.name, 30);
const idNumber = cleanText(body.idNumber, 30); const gender = cleanText(body.gender, 10);
const phone = cleanText(body.phone, 30); if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别');
if (!username || !name || !idNumber || !phone) return sendError(response, 400, '请完整填写账号和身份信息');
if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位'); if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位');
const db = await readDb(); const db = await readDb();
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
const schoolId = cleanText(body.schoolId, 64); const schoolId = cleanText(body.schoolId, 64);
const classId = cleanText(body.classId, 64); const classId = cleanText(body.classId, 64);
const school = db.schools.find(item => item.id === schoolId && item.active); 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); const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级'); if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
if (db.users.some(user => user.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该账号已注册'); const draftProfile = { schoolId, classId, gender };
if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册'); const generated = generateCandidateNumber(db, draftProfile);
const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() }; const userId = uid('usr');
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 user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() };
const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); 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, instance, action); await database.createCandidate(user, profile, null, null);
return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' }); return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
} }
if (request.method === 'POST' && pathname === '/api/auth/login') { if (request.method === 'POST' && pathname === '/api/auth/login') {
const body = await readJson(request); const body = await readJson(request);
const db = await readDb(); 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, '账号或密码不正确'); if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
const token = randomBytes(32).toString('hex'); const token = randomBytes(32).toString('hex');
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 }); 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` }); 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') { if (request.method === 'POST' && pathname === '/api/auth/logout') {
const token = parseCookies(request).hz_session; const token = parseCookies(request).hz_session;
if (token) sessions.delete(token); if (token) sessions.delete(token);
@@ -529,6 +547,9 @@ async function handleCandidate(request, response, pathname) {
if (!user) return true; if (!user) return true;
const db = await readDb(); const db = await readDb();
const profile = db.candidateProfiles.find(item => item.userId === user.id); 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') { if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)); 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') { if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
const body = await readJson(request); 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); 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 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); 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.classId = schoolClass.id;
profile.school = school.name; profile.school = school.name;
profile.grade = schoolClass.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, '证件号码已被其他考生使用'); if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
profile.status = 'pending'; profile.status = 'pending';
profile.profileCompleted = true;
profile.reviewNote = ''; profile.reviewNote = '';
profile.updatedAt = nowIso(); profile.updatedAt = nowIso();
const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id); 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, '你已经报名该考试'); 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 : [])]; 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, '请选择有效的报考科目'); 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); const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id);
await database.createRegistration(registration, instance, action); await database.createRegistration(registration, instance, action);
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); 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 || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.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 (pathname === '/api/admin/admins' && request.method === 'POST') {
if (!requirePermission(user, response, '*')) return true; if (!requirePermission(user, response, '*')) return true;
@@ -661,6 +683,33 @@ async function handleAdmin(request, response, pathname) {
await database.createAdmin(created, log); await database.createAdmin(created, log);
return sendJson(response, 201, { ok: true, admin: safeUser(created) }); 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 (pathname === '/api/admin/centers' && request.method === 'GET') {
if (!requirePermission(user, response, 'centers.read')) return true; 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 rule = db.numberRules.find(item => item.active) || null;
const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id }; const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id };
let preview = ''; let preview = '';
if (rule) { if (rule) preview = generateCandidateNumber(db, previewProfile).number;
const sampleRegistration = { examId: db.exams[0]?.id };
preview = generateRegistrationNumber(db, sampleRegistration, previewProfile).number;
}
const batchCandidates = db.registrations.filter(item => item.status === 'approved' && !item.registrationNumber).map(registration => { const batchCandidates = db.registrations.filter(item => item.status === 'approved' && !item.registrationNumber).map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId); const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
return { id: registration.id, examId: registration.examId, examName: db.exams.find(item => item.id === registration.examId)?.name || '', schoolId: profile?.schoolId || '', schoolName: profile?.school || '', candidateName: profile?.name || '', createdAt: registration.createdAt }; return { 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 (pathname === '/api/admin/registration-numbers/batch' && request.method === 'POST') {
if (!requirePermission(user, response, '*')) return true; if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request); const body = await readJson(request);
const activeRule = db.numberRules.find(item => item.active) || null;
const examId = cleanText(body.examId, 64); const examId = cleanText(body.examId, 64);
const schoolId = cleanText(body.schoolId, 64); const schoolId = cleanText(body.schoolId, 64);
const selectedIds = Array.isArray(body.registrationIds) ? new Set(body.registrationIds.map(item => cleanText(item, 64))) : null; 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); const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
return Boolean(profile && (!schoolId || profile.schoolId === schoolId)); 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); }).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 generated = eligible.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId); const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const result = generateRegistrationNumber(db, registration, profile); const account = db.users.find(item => item.id === registration.userId);
registration.registrationNumber = result.number; if (!account.candidateNumber) {
registration.numberRuleId = result.ruleId; 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; return registration;
}); });
const log = logAction(db, user, '批量生成报名号', `${generated.length} 条 · ${examId || '全部考试'} · ${schoolId || '全部学校'}`); const log = logAction(db, user, '批量同步账户报名号', `${generated.length}考试报名 · ${changedUsers.size} 个新账户号码`);
await database.assignRegistrationNumbers(generated, log); 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 })) }); 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; if (!requirePermission(user, response, 'candidates.read')) return true;
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => { 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]; 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\/([^/]+)$/); const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
if (request.method === 'PATCH' && candidateMatch) { 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; instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
registration.status = 'pending'; registration.reviewNote = note; registration.status = 'pending'; registration.reviewNote = note;
} else { } 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; instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
registration.status = 'approved'; registration.paymentStatus = 'paid'; registration.reviewNote = note; registration.reviewedAt = nowIso(); 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}`); 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); 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) return sendError(response, 404, '报名记录不存在');
if (!registration.registrationNumber) { if (!registration.registrationNumber) {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId); const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const generated = generateRegistrationNumber(db, registration, profile); const account = db.users.find(item => item.id === registration.userId);
registration.registrationNumber = generated.number; if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号');
registration.numberRuleId = generated.ruleId; registration.registrationNumber = account.candidateNumber;
const log = logAction(db, user, '生成报名号', `${profile?.name || registration.userId} · ${generated.number}`); 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); await database.assignRegistrationNumber(registration, log);
} }
return sendJson(response, 200, { ok: true, registrationNumber: registration.registrationNumber }); return sendJson(response, 200, { ok: true, registrationNumber: registration.registrationNumber });
+8 -2
View File
File diff suppressed because one or more lines are too long
+43 -23
View File
@@ -55,6 +55,7 @@ const schoolAdmin = createClient();
const schoolAdmin2 = createClient(); const schoolAdmin2 = createClient();
const classAdmin = createClient(); const classAdmin = createClient();
const candidate = createClient(); const candidate = createClient();
const selfCandidate = createClient();
const anonymous = createClient(); const anonymous = createClient();
try { try {
@@ -82,27 +83,13 @@ try {
assert.equal(publicHome.response.status, 200); assert.equal(publicHome.response.status, 200);
assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知');
assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目'); 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', 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, '考生应可自主注册'); assert.equal(closedRegister.response.status, 403, '自主注册关闭时公开注册必须拒绝');
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, '考生不得访问管理接口');
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } }); const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } });
assert.equal(loginAdmin.data.user.role, 'admin'); assert.equal(loginAdmin.data.user.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 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'); 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'); const adminDirectory = await admin.request('/api/admin/admins');
assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员'); assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员');
const schoolCenters = await schoolAdmin.request('/api/admin/centers'); const schoolCenters = await schoolAdmin.request('/api/admin/centers');
@@ -203,7 +222,7 @@ try {
assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试'); assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试');
const candidates = await admin.request('/api/admin/candidates'); 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.ok(profile, '管理员应能看到新注册考生');
assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料'); assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料');
const schoolCandidates = await schoolAdmin.request('/api/admin/candidates'); 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: '学校再次复核通过' } }); 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: '科目与资格终审通过' } }); const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } });
assert.equal(approveRegistration.data.registration.status, 'approved'); 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: '海州市测试中学' } }); const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号'); 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 futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } });
const futureRegistrationId = futureRegistration.data.registration.id; const futureRegistrationId = futureRegistration.data.registration.id;
await schoolAdmin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } }); 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: '海州市测试中学' } }); await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`); const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`);
assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载'); assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载');
@@ -300,10 +320,10 @@ try {
console.log('✓ 公开首页与通知读取'); console.log('✓ 公开首页与通知读取');
console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`); console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`);
console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离'); console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离');
console.log('✓ 考生自主注册、完整资料维护与两级审批'); console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关');
console.log('✓ 多科目考试创建与考生自主选科报名'); console.log('✓ 多科目考试创建与考生自主选科报名');
console.log('✓ 审批流程设计、同级转交与超级管理员监督退回'); console.log('✓ 审批流程设计、同级转交与超级管理员监督退回');
console.log('✓ 自定义规则、筛选批量报名号、准考证与下载窗口限制'); console.log('✓ 自定义账户号码、旧记录批量同步、准考证与下载窗口限制');
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
console.log('✓ 成绩录入、发布与考生查询'); console.log('✓ 成绩录入、发布与考生查询');
} finally { } finally {