Implement fixed candidate account onboarding
This commit is contained in:
+21
@@ -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.
|
||||||
@@ -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 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、两级审批、同级转交、报名号规则与旧记录批量同步、结构化考点考场及变更审批、多科目报名、准考证和成绩完整流程。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+43
-23
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user