Implement new application functionality and supporting components
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 衡准 · 考试信息管理系统
|
# 衡准 · 考试信息管理系统
|
||||||
|
|
||||||
一个完整可运行的双角色考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。
|
一个完整可运行的分级权限考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。
|
||||||
|
|
||||||
## 已实现功能
|
## 已实现功能
|
||||||
|
|
||||||
@@ -23,6 +23,16 @@
|
|||||||
|
|
||||||
### 管理后台
|
### 管理后台
|
||||||
|
|
||||||
|
- 超级、校级、班级三级管理员,同一级支持多个账号
|
||||||
|
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
|
||||||
|
- 校级管理员只管理本校考生和报名流程,并提交本校考点、考场档案变更
|
||||||
|
- 班级管理员只读查看本班考生、成绩和报名状态
|
||||||
|
- 考生信息修改、考试报名、考点考场变更使用可配置的多步骤审批流程
|
||||||
|
- 当前处理人可将流程转交给同范围的同级管理员
|
||||||
|
- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号
|
||||||
|
- 按考试、学校筛选,批量为已审核且缺号的报名记录生成报名号
|
||||||
|
- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位区间、类型和状态
|
||||||
|
- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案
|
||||||
- 考务指标与审计日志
|
- 考务指标与审计日志
|
||||||
- 考生资料审核、通过或退回修改
|
- 考生资料审核、通过或退回修改
|
||||||
- 考试报名及科目审核
|
- 考试报名及科目审核
|
||||||
@@ -41,6 +51,8 @@
|
|||||||
- 规范关系模型、外键、唯一约束和业务索引
|
- 规范关系模型、外键、唯一约束和业务索引
|
||||||
- 业务写入与审计日志使用原子事务提交
|
- 业务写入与审计日志使用原子事务提交
|
||||||
- 关键管理操作审计日志
|
- 关键管理操作审计日志
|
||||||
|
- 组织、学校、班级三级数据范围在服务端强制过滤
|
||||||
|
- 审批实例、当前责任人、转交和监督操作全程留痕
|
||||||
- 桌面端与移动端响应式布局
|
- 桌面端与移动端响应式布局
|
||||||
- 零第三方运行时依赖
|
- 零第三方运行时依赖
|
||||||
|
|
||||||
@@ -55,7 +67,7 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容或导入旧版 JSON/单表数据库;更改表结构后请删除本地 SQLite 文件并重新启动。
|
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容旧版 JSON/单表数据库;已有关系型数据库会在启动时自动升级到 v3,补充分级权限、报名号、结构化考场和变更审批结构。
|
||||||
|
|
||||||
## 数据库配置
|
## 数据库配置
|
||||||
|
|
||||||
@@ -78,15 +90,18 @@ npm start
|
|||||||
```sql
|
```sql
|
||||||
CREATE DATABASE exam_information CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
CREATE DATABASE exam_information CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password';
|
CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password';
|
||||||
GRANT SELECT, INSERT, UPDATE, CREATE ON exam_information.* TO 'exam_app'@'%';
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'exam_app'@'%';
|
||||||
```
|
```
|
||||||
|
|
||||||
启动应用时设置连接信息,应用会自动创建以下关系表并初始化演示数据:
|
启动应用时设置连接信息,应用会自动创建以下关系表并初始化演示数据:
|
||||||
|
|
||||||
- `users`、`candidate_profiles`
|
- `schools`、`school_classes`、`users`、`candidate_profiles`
|
||||||
- `exams`、`exam_subjects`
|
- `exams`、`exam_subjects`
|
||||||
- `registrations`、`registration_subjects`、`admit_cards`
|
- `registrations`、`registration_subjects`、`admit_cards`
|
||||||
- `results`、`notices`、`audit_logs`
|
- `results`、`notices`、`audit_logs`
|
||||||
|
- `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms`
|
||||||
|
- `number_rules`、`number_rule_segments`
|
||||||
|
- `workflow_definitions`、`workflow_steps`、`workflow_instances`、`workflow_actions`
|
||||||
- `organization`、`schema_metadata`
|
- `organization`、`schema_metadata`
|
||||||
|
|
||||||
所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。
|
所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。
|
||||||
@@ -109,7 +124,11 @@ npm start
|
|||||||
|
|
||||||
| 角色 | 账号 | 密码 |
|
| 角色 | 账号 | 密码 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 管理员 | `admin` | `Admin123!` |
|
| 超级管理员 | `admin` | `Admin123!` |
|
||||||
|
| 超级管理员(监督演示) | `supervisor` | `Admin123!` |
|
||||||
|
| 校级管理员 | `school_admin` | `School123!` |
|
||||||
|
| 同校校级管理员(转交演示) | `school_admin_2` | `School123!` |
|
||||||
|
| 班级管理员 | `class_admin` | `Class123!` |
|
||||||
| 考生 | `13800138000` | `Candidate123!` |
|
| 考生 | `13800138000` | `Candidate123!` |
|
||||||
|
|
||||||
## 自动化测试
|
## 自动化测试
|
||||||
@@ -118,7 +137,7 @@ npm start
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
测试使用独立临时 SQLite 数据库,覆盖注册、审核、多科目报名、准考证生成与下载、通知发布、成绩发布和权限隔离完整流程。
|
测试使用独立临时 SQLite 数据库,覆盖三级管理员数据范围、两级审批、同级转交、超级管理员监督退回、报名号规则与筛选批量生成、结构化考点考场及变更审批、班级只读、多科目报名、准考证和成绩完整流程。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const state = {
|
|||||||
user: null,
|
user: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||||
|
permissions: [],
|
||||||
|
scopeLabel: '',
|
||||||
pageData: null,
|
pageData: null,
|
||||||
loading: false
|
loading: false
|
||||||
};
|
};
|
||||||
@@ -11,7 +13,8 @@ const modalRoot = document.querySelector('#modalRoot');
|
|||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||||
published: '已发布', draft: '草稿', closed: '已结束',
|
published: '已发布', draft: '草稿', closed: '已结束',
|
||||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费'
|
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||||
|
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||||
};
|
};
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
@@ -125,7 +128,7 @@ 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></div>` : ''}</div></section></main>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginForm() {
|
function loginForm() {
|
||||||
@@ -133,27 +136,35 @@ function loginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerForm() {
|
function registerForm() {
|
||||||
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><input name="school" placeholder="请输入学校名称"></label><label><span>班级</span><input name="grade" placeholder="例如:高三(2)班"></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 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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateNav = [
|
const candidateNav = [
|
||||||
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
|
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
|
||||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell']
|
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell']
|
||||||
];
|
];
|
||||||
const adminNav = [
|
function adminNavForUser() {
|
||||||
['dashboard', '工作台', 'home'], ['candidates', '考生审核', 'users'], ['registrations', '报名审核', 'check'],
|
const level = state.user?.adminLevel || 'super';
|
||||||
['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], ['results', '成绩发布', 'chart']
|
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
||||||
];
|
if (level === 'class') return core;
|
||||||
|
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||||
|
if (level === 'school') return [core[0], core[1], core[2], ...operations, core[3]];
|
||||||
|
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
||||||
|
}
|
||||||
|
|
||||||
function portalShell(role, page, content, title, description) {
|
function portalShell(role, page, content, title, description) {
|
||||||
const nav = role === 'admin' ? adminNav : candidateNav;
|
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
||||||
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
||||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>服务支持</span><strong>0518-8602 3158</strong><small>工作日 08:30—17:30</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><button class="notification-button" data-route="${role}/notices">${icons.bell}<i></i></button><span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? '系统管理员' : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
|
||||||
|
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' && state.user?.adminLevel !== 'class' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function portalHeadingAction(role, page) {
|
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 === '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 '';
|
||||||
}
|
}
|
||||||
@@ -181,7 +192,7 @@ async function renderCandidate(page) {
|
|||||||
state.pageData = data;
|
state.pageData = data;
|
||||||
if (data.profile) state.profile = data.profile;
|
if (data.profile) state.profile = data.profile;
|
||||||
const content = {
|
const content = {
|
||||||
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data.profile), exams: () => candidateExams(data),
|
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
|
||||||
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
|
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
|
||||||
results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices)
|
results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices)
|
||||||
}[page]();
|
}[page]();
|
||||||
@@ -201,8 +212,10 @@ 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(profile) {
|
function candidateProfile(data) {
|
||||||
return `<section class="panel form-panel"><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><input name="school" required value="${h(profile?.school)}"></label><label><span>年级 / 班级</span><input name="grade" value="${h(profile?.grade)}"></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 { profile, schools = [], classes = [], workflow } = data;
|
||||||
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateExams(data) {
|
function candidateExams(data) {
|
||||||
@@ -210,7 +223,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>${h(reg.id)}</dd></div><div><dt>报名时间</dt><dd>${formatDate(reg.createdAt, true)}</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 || '审批通过后生成')}</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) {
|
||||||
@@ -234,17 +247,25 @@ async function renderAdmin(page) {
|
|||||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
|
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
|
||||||
results: ['成绩发布', '录入单科成绩并控制是否对考生公开。']
|
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
||||||
|
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||||
|
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||||
|
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
|
||||||
|
'flow-design': ['流程设计', '配置考生信息、报名审核与考点考场变更的审批步骤。'],
|
||||||
|
'number-rules': ['报名号规则', '设计号码组成,并为缺失报名号的已审核记录批量生成。']
|
||||||
};
|
};
|
||||||
if (!meta[page]) page = 'dashboard';
|
const allowedPages = adminNavForUser().map(item => item[0]);
|
||||||
|
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||||
try {
|
try {
|
||||||
const endpoint = page === 'admit' ? 'registrations' : page;
|
const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page;
|
||||||
const data = await api(`/api/admin/${endpoint}`);
|
const data = await api(`/api/admin/${endpoint}`);
|
||||||
state.pageData = data;
|
state.pageData = data;
|
||||||
const content = {
|
const content = {
|
||||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
||||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data)
|
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data),
|
||||||
|
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data),
|
||||||
|
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data)
|
||||||
}[page]();
|
}[page]();
|
||||||
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
||||||
} catch (error) { renderError(error); }
|
} catch (error) { renderError(error); }
|
||||||
@@ -252,15 +273,18 @@ async function renderAdmin(page) {
|
|||||||
|
|
||||||
function adminDashboard(data) {
|
function adminDashboard(data) {
|
||||||
const m = data.metrics;
|
const m = data.metrics;
|
||||||
return `<div class="admin-metrics"><article><span>${icons.users}</span><div><small>考生总数</small><strong>${m.candidates}</strong><em>${m.pendingCandidates} 人待审核</em></div></article><article><span>${icons.check}</span><div><small>考试报名</small><strong>${m.registrations}</strong><em>${m.pendingRegistrations} 条待审核</em></div></article><article><span>${icons.exam}</span><div><small>已发布考试</small><strong>${m.publishedExams}</strong><em>考试计划正常</em></div></article><article><span>${icons.bell}</span><div><small>已发布通知</small><strong>${m.notices}</strong><em>首页同步展示</em></div></article></div><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>当前待办</h2><span>按优先级排列</span></div><button data-route="admin/candidates"><i class="urgent">${m.pendingCandidates}</i><span><strong>考生资料待审核</strong><small>核验身份、学籍与联系方式</small></span>${icons.arrow}</button><button data-route="admin/registrations"><i>${m.pendingRegistrations}</i><span><strong>考试报名待审核</strong><small>确认选报科目和缴费信息</small></span>${icons.arrow}</button><button data-route="admin/admit"><i>${m.registrations}</i><span><strong>检查准考证生成</strong><small>为已通过报名分配考场座位</small></span>${icons.arrow}</button><button data-route="admin/results"><i>成</i><span><strong>录入与发布成绩</strong><small>仅正式发布后考生可见</small></span>${icons.arrow}</button></section><section class="panel audit-feed"><div class="panel-title"><h2>最近操作</h2><span>系统审计日志</span></div>${data.logs.map(log => `<div><span class="user-avatar">${h((log.actorName || '系').slice(0,1))}</span><p><strong>${h(log.actorName || '系统')} · ${h(log.action)}</strong><small>${h(log.detail)}</small></p><time>${formatDate(log.createdAt,true)}</time></div>`).join('') || '<p class="empty-state">暂无操作记录</p>'}</section></div>`;
|
const canFlow = state.user.adminLevel !== 'class';
|
||||||
|
return `<section class="scope-banner"><span>${statusLabels[state.user.adminLevel]}</span><div><strong>${h(data.scopeLabel)}</strong><small>所有指标均已按当前管理员的数据范围过滤</small></div></section><div class="admin-metrics"><article><span>${icons.users}</span><div><small>范围内考生</small><strong>${m.candidates}</strong><em>${m.pendingCandidates} 人待审核</em></div></article><article><span>${icons.check}</span><div><small>考试报名</small><strong>${m.registrations}</strong><em>${m.pendingRegistrations} 条待审核</em></div></article><article><span>${icons.exam}</span><div><small>待处理流程</small><strong>${m.pendingFlows ?? 0}</strong><em>${canFlow ? '进入流程中心办理' : '班级账号只读'}</em></div></article><article><span>${icons.chart}</span><div><small>已发布考试</small><strong>${m.publishedExams}</strong><em>全平台考试计划</em></div></article></div><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>${canFlow ? '当前工作入口' : '本班查询入口'}</h2><span>${h(data.scopeLabel)}</span></div><button data-route="admin/candidates"><i class="urgent">${m.pendingCandidates}</i><span><strong>${state.user.adminLevel === 'class' ? '查看本班考生' : '考生资料流程'}</strong><small>身份、学籍与联系方式</small></span>${icons.arrow}</button><button data-route="admin/registrations"><i>${m.pendingRegistrations}</i><span><strong>${state.user.adminLevel === 'class' ? '查看报名状态' : '考试报名流程'}</strong><small>考试、科目和报名号</small></span>${icons.arrow}</button>${canFlow ? `<button data-route="admin/flows"><i>${m.pendingFlows ?? 0}</i><span><strong>流程中心</strong><small>处理、转交与监督审批</small></span>${icons.arrow}</button>` : ''}<button data-route="admin/results"><i>成</i><span><strong>${state.user.adminLevel === 'super' ? '录入与发布成绩' : '查看范围内成绩'}</strong><small>成绩可见范围由权限控制</small></span>${icons.arrow}</button></section><section class="panel audit-feed"><div class="panel-title"><h2>最近操作</h2><span>系统审计日志</span></div>${data.logs.map(log => `<div><span class="user-avatar">${h((log.actorName || '系').slice(0,1))}</span><p><strong>${h(log.actorName || '系统')} · ${h(log.action)}</strong><small>${h(log.detail)}</small></p><time>${formatDate(log.createdAt,true)}</time></div>`).join('') || '<p class="empty-state">当前账号暂无操作记录</p>'}</section></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminCandidates(candidates) {
|
function adminCandidates(candidates) {
|
||||||
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 || '未填写')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td>${h(item.phone)}<small>${h(item.email || '')}</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)}">查看审核</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminRegistrations(registrations) {
|
function adminRegistrations(registrations) {
|
||||||
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?.idNumber)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td><div class="table-chips">${reg.subjects.map(subject => `<span>${h(subject.name)}</span>`).join('')}</div></td><td>${formatDate(reg.createdAt,true)}</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)}">审核报名</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminExams(exams) {
|
function adminExams(exams) {
|
||||||
@@ -277,7 +301,47 @@ function adminAdmit(registrations) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminResults(data) {
|
function adminResults(data) {
|
||||||
return `<div class="results-admin-grid"><section class="panel result-entry"><div class="panel-title"><h2>录入单科成绩</h2><span>保存后可立即发布</span></div><form data-form="result-entry"><label><span>报名记录</span><select name="registrationId" data-action="result-registration" required><option value="">请选择考生和考试</option>${data.registrations.map(reg => `<option value="${h(reg.id)}" data-subjects='${h(JSON.stringify(reg.subjects.map(subject => ({id:subject.id,name:subject.name}))))}'>${h(reg.exam.name)} · ${h(reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span>成绩(0—150)</span><input type="number" name="score" min="0" max="150" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将自动计算"></label></div><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section><section class="panel published-results"><div class="panel-title"><h2>最近成绩</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 12).map(result => `<div><span class="user-avatar">${h((result.candidateName || '?').slice(0,1))}</span><p><strong>${h(result.candidateName)} · ${h(result.subjectName)}</strong><small>${h(result.examName)}</small></p><b>${h(result.score)}</b>${badge(result.published ? 'published' : 'draft')}</div>`).join('') || '<p class="empty-state">还没有成绩记录</p>'}</section></div>`;
|
const entry = state.user.adminLevel === 'super' ? `<section class="panel result-entry"><div class="panel-title"><h2>录入单科成绩</h2><span>保存后可立即发布</span></div><form data-form="result-entry"><label><span>报名记录</span><select name="registrationId" data-action="result-registration" required><option value="">请选择考生和考试</option>${data.registrations.map(reg => `<option value="${h(reg.id)}" data-subjects='${h(JSON.stringify(reg.subjects.map(subject => ({id:subject.id,name:subject.name}))))}'>${h(reg.exam.name)} · ${h(reg.registrationNumber || reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span>成绩(0—150)</span><input type="number" name="score" min="0" max="150" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将自动计算"></label></div><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section>` : '';
|
||||||
|
return `<div class="results-admin-grid ${state.user.adminLevel === 'super' ? '' : 'read-only'}">${entry}<section class="panel published-results"><div class="panel-title"><h2>${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 50).map(result => `<div><span class="user-avatar">${h((result.candidateName || '?').slice(0,1))}</span><p><strong>${h(result.candidateName)} · ${h(result.subjectName)}</strong><small>${h(result.examName)}</small></p><b>${h(result.score)}</b>${badge(result.published ? 'published' : 'draft')}</div>`).join('') || '<p class="empty-state">还没有成绩记录</p>'}</section></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminCenters(data) {
|
||||||
|
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
|
||||||
|
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位号</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td class="mono">${h(room.seatStart)}—${h(room.seatEnd)}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||||||
|
const requests = data.changeRequests || [];
|
||||||
|
return `<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberSegmentMeta = {
|
||||||
|
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
|
||||||
|
sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字']
|
||||||
|
};
|
||||||
|
|
||||||
|
function adminNumberRules(data) {
|
||||||
|
const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] };
|
||||||
|
const byType = Object.fromEntries(rule.segments.map(item => [item.type, item]));
|
||||||
|
const types = Object.keys(numberSegmentMeta);
|
||||||
|
const candidates = data.batchCandidates || [];
|
||||||
|
return `<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminFlowDesign(workflows) {
|
||||||
|
const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE' };
|
||||||
|
return `<div class="workflow-design-grid">${workflows.map(workflow => `<section class="panel workflow-designer"><header><div><span>${h(codes[workflow.businessType] || workflow.businessType)}</span><h2>${h(workflow.name)}</h2></div><button class="row-action" data-action="add-workflow-step" data-type="${h(workflow.businessType)}">添加步骤</button></header><form data-form="workflow-design" data-type="${h(workflow.businessType)}"><input name="name" value="${h(workflow.name)}" required><div class="workflow-step-editor" data-workflow-steps>${workflow.steps.map(step => workflowStepEditor(step)).join('')}</div><button class="solid-button" type="submit">保存流程</button></form></section>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowStepEditor(step = {}) {
|
||||||
|
return `<div class="workflow-step-row"><i></i><input name="stepName" value="${h(step.name)}" placeholder="步骤名称" required><select name="stepLevel"><option value="school" ${step.adminLevel === 'school' ? 'selected' : ''}>校级管理员</option><option value="super" ${step.adminLevel === 'super' ? 'selected' : ''}>超级管理员</option></select><button type="button" data-action="remove-workflow-step" aria-label="移除步骤">×</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminFlows(data) {
|
||||||
|
const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' };
|
||||||
|
const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更' };
|
||||||
|
return `<div class="workflow-board">${data.instances.map(instance => { const title = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName; const sub = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; return `<article class="panel workflow-card ${instance.status}"><header><div><span>${h(typeNames[instance.businessType] || instance.businessType)}</span><h2>${h(title)}</h2><p>${h(sub)}</p></div>${badge(instance.status)}</header><div class="workflow-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><div class="workflow-owner"><span>当前责任人</span><strong>${h(instance.assignee?.displayName || '流程已结束')}</strong><small>${h(instance.currentStepDetail?.name || statusLabels[instance.status])}</small></div><footer><span>${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}</span><button class="row-action primary" data-action="open-flow" data-id="${h(instance.id)}">查看与处理</button></footer></article>`; }).join('') || emptyState('暂无审批流程', '考生资料、考试报名或考点档案提交后,流程会显示在这里。')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyState(title, description, route, action) {
|
function emptyState(title, description, route, action) {
|
||||||
@@ -317,6 +381,8 @@ async function refreshSession() {
|
|||||||
const session = await api('/api/auth/me');
|
const session = await api('/api/auth/me');
|
||||||
state.user = session.user;
|
state.user = session.user;
|
||||||
state.profile = session.profile;
|
state.profile = session.profile;
|
||||||
|
state.permissions = session.permissions || [];
|
||||||
|
state.scopeLabel = session.scopeLabel || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', async event => {
|
document.addEventListener('click', async event => {
|
||||||
@@ -343,13 +409,13 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'logout') {
|
if (action === 'logout') {
|
||||||
await api('/api/auth/logout', { method: 'POST' });
|
await api('/api/auth/logout', { method: 'POST' });
|
||||||
state.user = null; state.profile = null; state.pageData = null;
|
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = '';
|
||||||
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
||||||
}
|
}
|
||||||
if (action === 'fill-demo') {
|
if (action === 'fill-demo') {
|
||||||
const form = document.querySelector('[data-form="login"]');
|
const form = document.querySelector('[data-form="login"]');
|
||||||
form.username.value = target.dataset.type === 'admin' ? 'admin' : '13800138000';
|
const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['13800138000', 'Candidate123!'] };
|
||||||
form.password.value = target.dataset.type === 'admin' ? 'Admin123!' : 'Candidate123!';
|
[form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'open-notice') {
|
if (action === 'open-notice') {
|
||||||
@@ -359,9 +425,36 @@ document.addEventListener('click', async event => {
|
|||||||
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
||||||
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-center') return openCenterForm();
|
||||||
|
if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id));
|
||||||
|
if (action === 'add-center-room') {
|
||||||
|
document.querySelector('[data-center-rooms]')?.insertAdjacentHTML('beforeend', centerRoomEditor());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove-center-room') {
|
||||||
|
const list = target.closest('[data-center-rooms]');
|
||||||
|
if (list.children.length <= 1) return toast('至少保留一个考场', '考点档案必须包含结构化考场');
|
||||||
|
target.closest('.center-room-editor').remove(); return;
|
||||||
|
}
|
||||||
|
if (action === 'open-flow') return openFlowDetail(target.dataset.id);
|
||||||
|
if (action === 'add-workflow-step') {
|
||||||
|
const form = document.querySelector(`[data-form="workflow-design"][data-type="${target.dataset.type}"]`);
|
||||||
|
form?.querySelector('[data-workflow-steps]')?.insertAdjacentHTML('beforeend', workflowStepEditor());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove-workflow-step') {
|
||||||
|
const list = target.closest('[data-workflow-steps]');
|
||||||
|
if (list.children.length <= 1) return toast('至少保留一步', '审批流程不能为空');
|
||||||
|
target.closest('.workflow-step-row').remove(); return;
|
||||||
|
}
|
||||||
if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id));
|
if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id));
|
||||||
if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
|
if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
|
||||||
if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
|
if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
|
||||||
|
if (action === 'generate-registration-number') {
|
||||||
|
await api(`/api/admin/registrations/${target.dataset.id}/registration-number`, { method: 'POST' });
|
||||||
|
toast('报名号已生成', '已按当前启用规则写入'); return renderRoute();
|
||||||
|
}
|
||||||
if (action === 'generate-admit') {
|
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);
|
||||||
if (registration.admitCard) return openAdmitPreview(registration);
|
if (registration.admitCard) return openAdmitPreview(registration);
|
||||||
@@ -401,6 +494,19 @@ document.addEventListener('input', event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('change', event => {
|
document.addEventListener('change', event => {
|
||||||
|
if (event.target.matches('[data-action="school-select"]')) {
|
||||||
|
const form = event.target.closest('form');
|
||||||
|
const classSelect = form?.querySelector('select[name="classId"]');
|
||||||
|
if (classSelect) {
|
||||||
|
const classes = state.pageData?.classes || state.publicData.classes || [];
|
||||||
|
classSelect.innerHTML = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-action="admin-level"]')) {
|
||||||
|
const form = event.target.closest('form');
|
||||||
|
form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super');
|
||||||
|
form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class');
|
||||||
|
}
|
||||||
if (event.target.matches('[data-action="result-registration"]')) {
|
if (event.target.matches('[data-action="result-registration"]')) {
|
||||||
const option = event.target.selectedOptions[0];
|
const option = event.target.selectedOptions[0];
|
||||||
const select = document.querySelector('#resultSubject');
|
const select = document.querySelector('#resultSubject');
|
||||||
@@ -441,6 +547,62 @@ document.addEventListener('submit', async event => {
|
|||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body });
|
await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body });
|
||||||
closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute();
|
closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute();
|
||||||
|
} else if (kind === 'admin-form') {
|
||||||
|
const body = formObject(form);
|
||||||
|
await api('/api/admin/admins', { method: 'POST', body });
|
||||||
|
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
|
||||||
|
} else if (kind === 'center-form') {
|
||||||
|
const body = formObject(form);
|
||||||
|
const editing = Boolean(body.id);
|
||||||
|
body.rooms = [...form.querySelectorAll('.center-room-editor')].map(row => ({
|
||||||
|
id: row.querySelector('[name="roomId"]').value || null,
|
||||||
|
code: row.querySelector('[name="roomCode"]').value,
|
||||||
|
name: row.querySelector('[name="roomName"]').value,
|
||||||
|
building: row.querySelector('[name="roomBuilding"]').value,
|
||||||
|
floor: row.querySelector('[name="roomFloor"]').value,
|
||||||
|
capacity: Number(row.querySelector('[name="roomCapacity"]').value),
|
||||||
|
seatStart: Number(row.querySelector('[name="roomSeatStart"]').value),
|
||||||
|
seatEnd: Number(row.querySelector('[name="roomSeatEnd"]').value),
|
||||||
|
roomType: row.querySelector('[name="roomType"]').value,
|
||||||
|
status: row.querySelector('[name="roomStatus"]').value,
|
||||||
|
notes: row.querySelector('[name="roomNotes"]').value
|
||||||
|
}));
|
||||||
|
await api(editing ? `/api/admin/centers/${body.id}` : '/api/admin/centers', { method: editing ? 'PATCH' : 'POST', body });
|
||||||
|
closeModal(); toast(editing ? '考点变更已提交' : '新考点已提交', '审批通过后才会更新正式档案'); renderRoute();
|
||||||
|
} else if (kind === 'number-rule') {
|
||||||
|
const raw = formObject(form);
|
||||||
|
const types = Object.keys(numberSegmentMeta);
|
||||||
|
const segments = types.filter(type => type === 'sequence' || form.querySelector(`[name="include_${type}"]`)?.checked).map(type => ({
|
||||||
|
type, position: Number(raw[`position_${type}`] || 99), value: raw[`value_${type}`] || '', width: Number(raw[`width_${type}`] || 0)
|
||||||
|
})).sort((a, b) => a.position - b.position);
|
||||||
|
await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } });
|
||||||
|
toast('报名号规则已启用', '新通过的报名将按此规则生成'); renderRoute();
|
||||||
|
} else if (kind === 'batch-registration-numbers') {
|
||||||
|
const result = await api('/api/admin/registration-numbers/batch', { method: 'POST', body: formObject(form) });
|
||||||
|
toast('批量生成完成', `已为 ${result.count} 条报名记录写入报名号`); renderRoute();
|
||||||
|
} else if (kind === 'workflow-design') {
|
||||||
|
const names = [...form.querySelectorAll('[name="stepName"]')];
|
||||||
|
const levels = [...form.querySelectorAll('[name="stepLevel"]')];
|
||||||
|
const steps = names.map((input, index) => ({ name: input.value, adminLevel: levels[index].value }));
|
||||||
|
await api(`/api/admin/workflows/${form.dataset.type}`, { method: 'PUT', body: { name: form.name.value, steps } });
|
||||||
|
toast('审批流程已保存', `${steps.length} 个步骤已启用`); renderRoute();
|
||||||
|
} else if (kind === 'flow-process') {
|
||||||
|
const body = formObject(form);
|
||||||
|
const path = body.businessType === 'profile_change'
|
||||||
|
? `/api/admin/candidates/${body.businessId}`
|
||||||
|
: body.businessType === 'registration_review'
|
||||||
|
? `/api/admin/registrations/${body.businessId}`
|
||||||
|
: `/api/admin/center-change-requests/${body.businessId}`;
|
||||||
|
await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote } });
|
||||||
|
closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute();
|
||||||
|
} else if (kind === 'flow-transfer') {
|
||||||
|
const body = formObject(form);
|
||||||
|
await api(`/api/admin/workflow-instances/${body.id}/transfer`, { method: 'PATCH', body });
|
||||||
|
closeModal(); toast('流程已转交', '新责任人已收到待办'); renderRoute();
|
||||||
|
} else if (kind === 'flow-supervise') {
|
||||||
|
const body = formObject(form);
|
||||||
|
await api(`/api/admin/workflow-instances/${body.id}/supervise`, { method: 'PATCH', body });
|
||||||
|
closeModal(); toast('流程已监督调整', '节点与责任人已更新并记录'); renderRoute();
|
||||||
} else if (kind === 'notice-form') {
|
} else if (kind === 'notice-form') {
|
||||||
const body = formObject(form); body.pinned = form.pinned.checked;
|
const body = formObject(form); body.pinned = form.pinned.checked;
|
||||||
await api('/api/admin/notices', { method: 'POST', body });
|
await api('/api/admin/notices', { method: 'POST', body });
|
||||||
@@ -460,14 +622,44 @@ document.addEventListener('submit', async event => {
|
|||||||
finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } }
|
finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openAdminForm() {
|
||||||
|
const { schools = [], classes = [] } = state.pageData;
|
||||||
|
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 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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCenterForm(center = null) {
|
||||||
|
const schools = state.pageData.schools || [];
|
||||||
|
const rooms = center?.rooms?.length ? center.rooms : [{}];
|
||||||
|
setModal(`<div class="modal-head"><div><span>CONTROLLED DOSSIER</span><h2>${center ? '提交考点档案变更' : '提交新考点档案'}</h2><p>表单将进入“考点考场变更审批”,通过前不会改动正式数据。</p></div><button data-action="close-modal">×</button></div><form class="modal-form center-dossier-form" data-form="center-form">${center ? `<input type="hidden" name="id" value="${h(center.id)}">` : ''}<section class="center-form-section"><h3>考点基本档案</h3>${state.user.adminLevel === 'super' ? `<label><span>所属学校 *</span><select name="schoolId" required>${schools.map(item => `<option value="${h(item.id)}" ${center?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label>` : ''}<div class="field-row"><label><span>考点代码 *</span><input name="code" required value="${h(center?.code || '')}" placeholder="如 HZ01-C01"></label><label><span>考点名称 *</span><input name="name" required value="${h(center?.name || '')}"></label></div><label><span>详细地址 *</span><input name="address" required value="${h(center?.address || '')}"></label><div class="field-row"><label><span>考点负责人</span><input name="managerName" value="${h(center?.managerName || '')}"></label><label><span>负责人手机</span><input name="managerPhone" value="${h(center?.managerPhone || '')}"></label></div><div class="field-row"><label><span>值班电话</span><input name="contact" value="${h(center?.contact || '')}"></label><label><span>应急电话</span><input name="emergencyPhone" value="${h(center?.emergencyPhone || '')}"></label></div><div class="field-row"><label><span>开放时间</span><input name="gateOpenTime" type="time" value="${h(center?.gateOpenTime || '')}"></label><label><span>档案状态</span><select name="status"><option value="active" ${center?.status !== 'inactive' ? 'selected' : ''}>启用</option><option value="inactive" ${center?.status === 'inactive' ? 'selected' : ''}>停用</option></select></label></div><label><span>交通与入场提示</span><textarea name="transport" rows="3">${h(center?.transport || '')}</textarea></label><label><span>考务备注</span><textarea name="notes" rows="2">${h(center?.notes || '')}</textarea></label></section><section class="center-form-section rooms-section"><header><div><h3>考场明细</h3><p>每个考场单独维护位置、容量、座位区间与类型。</p></div><button type="button" class="row-action" data-action="add-center-room">添加考场</button></header><div data-center-rooms>${rooms.map(room => centerRoomEditor(room)).join('')}</div></section><div class="approval-callout"><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 openFlowDetail(id) {
|
||||||
|
const instance = state.pageData.instances.find(item => item.id === id);
|
||||||
|
const currentLevel = instance.currentStepDetail?.adminLevel;
|
||||||
|
const available = state.pageData.availableAdmins.filter(item => item.adminLevel === currentLevel && (currentLevel === 'super' || item.schoolId === instance.assignee?.schoolId));
|
||||||
|
const canProcess = instance.status === 'pending' && (state.user.adminLevel === 'super' || instance.assignee?.id === state.user.id);
|
||||||
|
const history = instance.actions.map(action => `<div><i></i><span><strong>${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}</strong><small>${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}</small></span><time>${formatDate(action.createdAt, true)}</time></div>`).join('');
|
||||||
|
const subject = instance.businessType === 'center_change' ? instance.centerName : instance.candidateName;
|
||||||
|
const subjectDetail = instance.businessType === 'center_change' ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`;
|
||||||
|
const change = instance.centerChange;
|
||||||
|
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatStart)}—${h(room.seatEnd)}</small></span>`).join('')}</div></section>` : '';
|
||||||
|
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">确认处理</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
setModal(`<div class="modal-head"><div><span>CANDIDATE REVIEW</span><h2>审核 ${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.idNumber)}</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.email || '未填写')}</dd></div><div><dt>联系地址</dt><dd>${h(item.address || '未填写')}</dd></div></dl></div><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>`);
|
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>'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>审核考试报名</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>${badge(reg.candidate?.status)}</dd></div><div><dt>报名时间</dt><dd>${formatDate(reg.createdAt,true)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div><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>`);
|
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>`}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNoticeForm() {
|
function openNoticeForm() {
|
||||||
|
|||||||
+1204
-32
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hengzhun-exam-system",
|
"name": "hengzhun-exam-system",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+671
-42
@@ -39,25 +39,40 @@ function verifyPassword(password, stored) {
|
|||||||
|
|
||||||
function seedDatabase() {
|
function seedDatabase() {
|
||||||
const adminId = 'usr_admin';
|
const adminId = 'usr_admin';
|
||||||
|
const schoolAdminId = 'usr_school_admin';
|
||||||
|
const schoolAdmin2Id = 'usr_school_admin_2';
|
||||||
const candidateId = 'usr_demo';
|
const candidateId = 'usr_demo';
|
||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
return {
|
return {
|
||||||
meta: { version: 1, createdAt: nowIso() },
|
meta: { version: 3, createdAt: nowIso() },
|
||||||
organization: {
|
organization: {
|
||||||
name: '海州市教育考试中心',
|
name: '海州市教育考试中心',
|
||||||
code: 'HZ-EDU-032',
|
code: 'HZ-EDU-032',
|
||||||
phone: '0518-8602 3158',
|
phone: '0518-8602 3158',
|
||||||
address: '海州市清河区文教路 18 号'
|
address: '海州市清河区文教路 18 号'
|
||||||
},
|
},
|
||||||
|
schools: [
|
||||||
|
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true },
|
||||||
|
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true }
|
||||||
|
],
|
||||||
|
classes: [
|
||||||
|
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
||||||
|
{ id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true },
|
||||||
|
{ id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true }
|
||||||
|
],
|
||||||
users: [
|
users: [
|
||||||
{ id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', displayName: '林老师', createdAt: nowIso() },
|
{ id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() },
|
||||||
{ id: candidateId, username: '13800138000', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', createdAt: nowIso() }
|
{ id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() },
|
||||||
|
{ id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
|
||||||
|
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
|
||||||
|
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
|
||||||
|
{ id: candidateId, username: '13800138000', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, createdAt: nowIso() }
|
||||||
],
|
],
|
||||||
candidateProfiles: [
|
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)班',
|
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
||||||
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
||||||
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'
|
||||||
}
|
}
|
||||||
@@ -95,7 +110,7 @@ function seedDatabase() {
|
|||||||
registrations: [
|
registrations: [
|
||||||
{
|
{
|
||||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||||
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z',
|
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '', numberRuleId: null,
|
||||||
admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' }
|
admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -103,6 +118,42 @@ function seedDatabase() {
|
|||||||
{ id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' },
|
{ id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' },
|
||||||
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
|
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
|
||||||
],
|
],
|
||||||
|
testCenters: [
|
||||||
|
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() },
|
||||||
|
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
||||||
|
],
|
||||||
|
testRooms: [
|
||||||
|
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
|
||||||
|
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'standard', status: 'active', notes: '' },
|
||||||
|
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatStart: 1, seatEnd: 40, roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
|
||||||
|
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' },
|
||||||
|
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatStart: 31, seatEnd: 60, roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
|
||||||
|
],
|
||||||
|
centerChangeRequests: [],
|
||||||
|
centerChangeRooms: [],
|
||||||
|
numberRules: [
|
||||||
|
{ id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [
|
||||||
|
{ id: 'segment_year', position: 1, type: 'year', value: '', width: 4 },
|
||||||
|
{ id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 },
|
||||||
|
{ id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 },
|
||||||
|
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 }
|
||||||
|
] }
|
||||||
|
],
|
||||||
|
workflows: [
|
||||||
|
{ id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' },
|
||||||
|
{ id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' },
|
||||||
|
{ id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' }
|
||||||
|
] }
|
||||||
|
],
|
||||||
|
workflowInstances: [],
|
||||||
|
workflowActions: [],
|
||||||
auditLogs: [
|
auditLogs: [
|
||||||
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
||||||
]
|
]
|
||||||
@@ -152,11 +203,20 @@ async function currentUser(request) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
return db.users.find(user => user.id === session.userId) || null;
|
const user = db.users.find(item => item.id === session.userId) || null;
|
||||||
|
return user?.active === false ? null : user;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeUser(user) {
|
function safeUser(user) {
|
||||||
return { id: user.id, username: user.username, role: user.role, displayName: user.displayName };
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
adminLevel: user.adminLevel || null,
|
||||||
|
schoolId: user.schoolId || null,
|
||||||
|
classId: user.classId || null,
|
||||||
|
displayName: user.displayName
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireUser(request, response, role) {
|
async function requireUser(request, response, role) {
|
||||||
@@ -172,10 +232,193 @@ async function requireUser(request, response, role) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' };
|
||||||
|
|
||||||
|
const permissionsByLevel = {
|
||||||
|
super: ['*'],
|
||||||
|
school: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
|
||||||
|
class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read']
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasPermission(user, permission) {
|
||||||
|
if (user?.role !== 'admin') return false;
|
||||||
|
const permissions = permissionsByLevel[user.adminLevel || 'super'] || [];
|
||||||
|
return permissions.includes('*') || permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePermission(user, response, permission) {
|
||||||
|
if (hasPermission(user, permission)) return true;
|
||||||
|
sendError(response, 403, '当前管理员层级无权执行此操作');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileInScope(user, profile) {
|
||||||
|
if (user.adminLevel === 'super') return true;
|
||||||
|
if (user.adminLevel === 'school') return Boolean(user.schoolId && profile.schoolId === user.schoolId);
|
||||||
|
return Boolean(user.classId && profile.classId === user.classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registrationInScope(db, user, registration) {
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
return Boolean(profile && profileInScope(user, profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminScopeLabel(db, user) {
|
||||||
|
if (user.adminLevel === 'super') return '全部学校与班级';
|
||||||
|
const school = db.schools.find(item => item.id === user.schoolId)?.name || '未绑定学校';
|
||||||
|
if (user.adminLevel === 'school') return school;
|
||||||
|
const schoolClass = db.classes.find(item => item.id === user.classId)?.name || '未绑定班级';
|
||||||
|
return `${school} · ${schoolClass}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminsForStep(db, adminLevel, profile) {
|
||||||
|
return db.users.filter(item => {
|
||||||
|
if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false;
|
||||||
|
if (adminLevel === 'super') return true;
|
||||||
|
if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId);
|
||||||
|
return Boolean(profile?.classId && item.classId === profile.classId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeWorkflow(db, businessType) {
|
||||||
|
return db.workflows.find(item => item.businessType === businessType && item.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkflowSubmission(db, businessType, businessId, profile, actorId = null) {
|
||||||
|
const workflow = activeWorkflow(db, businessType);
|
||||||
|
if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 });
|
||||||
|
const firstStep = workflow.steps[0];
|
||||||
|
const assignee = adminsForStep(db, firstStep.adminLevel, profile)[0];
|
||||||
|
if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 });
|
||||||
|
const instance = {
|
||||||
|
id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1,
|
||||||
|
assigneeId: assignee.id, createdAt: nowIso(), completedAt: null
|
||||||
|
};
|
||||||
|
const action = {
|
||||||
|
id: uid('flow_action'), instanceId: instance.id, actorId, action: 'submit', note: '提交审批',
|
||||||
|
fromAssigneeId: null, toAssigneeId: assignee.id, createdAt: nowIso()
|
||||||
|
};
|
||||||
|
return { workflow, instance, action };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowView(db, instance) {
|
||||||
|
if (!instance) return null;
|
||||||
|
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||||
|
const assignee = db.users.find(item => item.id === instance.assigneeId);
|
||||||
|
const actions = db.workflowActions.filter(item => item.instanceId === instance.id).map(item => ({
|
||||||
|
...item,
|
||||||
|
actorName: db.users.find(user => user.id === item.actorId)?.displayName || '系统',
|
||||||
|
fromAssigneeName: db.users.find(user => user.id === item.fromAssigneeId)?.displayName || '',
|
||||||
|
toAssigneeName: db.users.find(user => user.id === item.toAssigneeId)?.displayName || ''
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...instance,
|
||||||
|
workflowName: workflow?.name || '未命名流程',
|
||||||
|
steps: workflow?.steps || [],
|
||||||
|
currentStepDetail: workflow?.steps.find(step => step.position === instance.currentStep) || null,
|
||||||
|
assignee: assignee ? safeUser(assignee) : null,
|
||||||
|
actions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingWorkflow(db, businessType, businessId) {
|
||||||
|
return db.workflowInstances.find(item => item.businessType === businessType && item.businessId === businessId && item.status === 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
function registrationSequence(db, rule, schoolId, year) {
|
||||||
|
const prefixParts = rule.segments.filter(item => item.type !== 'sequence').map(segment => segment.type === 'year' ? year : segment.type === 'school_code' ? db.schools.find(school => school.id === schoolId)?.code || '' : '').filter(Boolean);
|
||||||
|
const prefix = prefixParts.join(rule.separator);
|
||||||
|
return db.registrations.filter(item => item.registrationNumber && (!prefix || item.registrationNumber.startsWith(prefix))).length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRegistrationNumber(db, registration, profile) {
|
||||||
|
const rule = db.numberRules.find(item => item.active);
|
||||||
|
if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 });
|
||||||
|
const school = db.schools.find(item => item.id === profile.schoolId);
|
||||||
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
|
const year = String(new Date(exam?.examStart || Date.now()).getFullYear());
|
||||||
|
const sequence = registrationSequence(db, rule, profile.schoolId, year);
|
||||||
|
const parts = rule.segments.map(segment => {
|
||||||
|
if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4));
|
||||||
|
if (segment.type === 'school_code') return school?.code || 'NOSCHOOL';
|
||||||
|
if (segment.type === 'gender') return profile.gender === '男' ? 'M' : profile.gender === '女' ? 'F' : 'X';
|
||||||
|
if (segment.type === 'sequence') return String(sequence).padStart(Math.max(1, segment.width || 4), '0');
|
||||||
|
return cleanText(segment.value, 20).toUpperCase();
|
||||||
|
});
|
||||||
|
return { number: parts.join(rule.separator), ruleId: rule.id };
|
||||||
|
}
|
||||||
|
|
||||||
function cleanText(value, max = 200) {
|
function cleanText(value, max = 200) {
|
||||||
return String(value ?? '').trim().slice(0, max);
|
return String(value ?? '').trim().slice(0, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function centerScopeProfile(db, schoolId) {
|
||||||
|
const school = db.schools.find(item => item.id === schoolId);
|
||||||
|
return { schoolId, classId: null, school: school?.name || '', grade: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowScopeProfile(db, instance) {
|
||||||
|
if (instance.businessType === 'profile_change') return db.candidateProfiles.find(item => item.id === instance.businessId) || null;
|
||||||
|
if (instance.businessType === 'registration_review') {
|
||||||
|
const registration = db.registrations.find(item => item.id === instance.businessId);
|
||||||
|
return db.candidateProfiles.find(item => item.userId === registration?.userId) || null;
|
||||||
|
}
|
||||||
|
const change = db.centerChangeRequests.find(item => item.id === instance.businessId);
|
||||||
|
return change ? centerScopeProfile(db, change.schoolId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerChangeView(db, change) {
|
||||||
|
const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id);
|
||||||
|
return {
|
||||||
|
...change,
|
||||||
|
schoolName: db.schools.find(item => item.id === change.schoolId)?.name || '',
|
||||||
|
rooms: db.centerChangeRooms.filter(item => item.requestId === change.id),
|
||||||
|
workflow: workflowView(db, instance)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCenterChange(db, body, schoolId, center = null) {
|
||||||
|
const code = cleanText(body.code, 30).toUpperCase();
|
||||||
|
const name = cleanText(body.name, 100);
|
||||||
|
const address = cleanText(body.address, 200);
|
||||||
|
const rooms = Array.isArray(body.rooms) ? body.rooms : [];
|
||||||
|
if (!code || !name || !address) throw Object.assign(new Error('请填写考点代码、名称和详细地址'), { status: 400 });
|
||||||
|
if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 });
|
||||||
|
const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id)
|
||||||
|
|| db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id);
|
||||||
|
if (duplicateCenter) throw Object.assign(new Error('考点代码已被正式档案或待审批申请占用'), { status: 409 });
|
||||||
|
const roomCodes = new Set();
|
||||||
|
const normalizedRooms = rooms.map((room, index) => {
|
||||||
|
const roomCode = cleanText(room.code, 30).toUpperCase();
|
||||||
|
const roomName = cleanText(room.name, 80);
|
||||||
|
const building = cleanText(room.building, 80);
|
||||||
|
const capacity = Number(room.capacity);
|
||||||
|
const seatStart = Number(room.seatStart);
|
||||||
|
const seatEnd = Number(room.seatEnd);
|
||||||
|
if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1 || !Number.isInteger(seatStart) || !Number.isInteger(seatEnd) || seatStart < 1 || seatEnd < seatStart) {
|
||||||
|
throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋、容量或座位号范围无效`), { status: 400 });
|
||||||
|
}
|
||||||
|
if (seatEnd - seatStart + 1 > capacity) throw Object.assign(new Error(`第 ${index + 1} 个考场的座位号数量不能超过考场容量`), { status: 400 });
|
||||||
|
if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 });
|
||||||
|
roomCodes.add(roomCode);
|
||||||
|
return {
|
||||||
|
id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName,
|
||||||
|
building, floor: cleanText(room.floor, 30), capacity, seatStart, seatEnd,
|
||||||
|
roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard',
|
||||||
|
status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
center: {
|
||||||
|
schoolId, code, name, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50),
|
||||||
|
managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30),
|
||||||
|
gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500),
|
||||||
|
centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000)
|
||||||
|
},
|
||||||
|
rooms: normalizedRooms
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function maskId(value) {
|
function maskId(value) {
|
||||||
const text = String(value || '');
|
const text = String(value || '');
|
||||||
return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text;
|
return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text;
|
||||||
@@ -194,7 +437,9 @@ function publicExam(exam) {
|
|||||||
function examRegistrationView(db, registration) {
|
function examRegistrationView(db, registration) {
|
||||||
const exam = db.exams.find(item => item.id === registration.examId);
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id));
|
const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id));
|
||||||
return { ...registration, exam, subjects };
|
const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending')
|
||||||
|
|| db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0];
|
||||||
|
return { ...registration, exam, subjects, workflow: workflowView(db, instance) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function logAction(db, user, action, detail) {
|
function logAction(db, user, action, detail) {
|
||||||
@@ -220,7 +465,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, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } });
|
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } });
|
||||||
}
|
}
|
||||||
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
||||||
if (noticeMatch) {
|
if (noticeMatch) {
|
||||||
@@ -236,7 +481,7 @@ async function handleAuth(request, response, pathname) {
|
|||||||
if (!user) return sendJson(response, 200, { ok: true, user: null });
|
if (!user) return sendJson(response, 200, { ok: true, user: null });
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null;
|
const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null;
|
||||||
return sendJson(response, 200, { ok: true, user: safeUser(user), profile });
|
return sendJson(response, 200, { ok: true, user: safeUser(user), profile, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/auth/register') {
|
if (request.method === 'POST' && pathname === '/api/auth/register') {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
@@ -248,18 +493,24 @@ async function handleAuth(request, response, pathname) {
|
|||||||
if (!username || !name || !idNumber || !phone) return sendError(response, 400, '请完整填写账号和身份信息');
|
if (!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();
|
||||||
|
const schoolId = cleanText(body.schoolId, 64);
|
||||||
|
const classId = cleanText(body.classId, 64);
|
||||||
|
const school = db.schools.find(item => item.id === schoolId && item.active);
|
||||||
|
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
|
||||||
|
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||||
if (db.users.some(user => user.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该账号已注册');
|
if (db.users.some(user => user.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该账号已注册');
|
||||||
if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册');
|
if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册');
|
||||||
const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() };
|
const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() };
|
||||||
const profile = { id: uid('profile'), userId: user.id, name, idNumber, phone, gender: cleanText(body.gender, 10), email: cleanText(body.email, 80), school: cleanText(body.school, 80), grade: cleanText(body.grade, 50), address: '', emergencyContact: '', emergencyPhone: '', status: 'pending', reviewNote: '', updatedAt: nowIso() };
|
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() };
|
||||||
await database.createCandidate(user, profile);
|
const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
|
||||||
|
await database.createCandidate(user, profile, instance, action);
|
||||||
return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' });
|
return sendJson(response, 201, { ok: true, 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 user = db.users.find(item => item.username.toLowerCase() === cleanText(body.username, 50).toLowerCase());
|
||||||
if (!user || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||||
const token = randomBytes(32).toString('hex');
|
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` });
|
||||||
@@ -283,19 +534,34 @@ async function handleCandidate(request, response, pathname) {
|
|||||||
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
||||||
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
|
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
|
||||||
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
|
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
|
||||||
return sendJson(response, 200, { ok: true, profile, registrations, results, notices });
|
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||||
|
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
|
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
|
||||||
|
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||||
|
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
|
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/candidate/profile') return sendJson(response, 200, { ok: true, profile });
|
|
||||||
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
|
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', 'school', 'grade', 'address', 'emergencyContact', 'emergencyPhone'];
|
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone'];
|
||||||
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
||||||
|
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
||||||
|
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
||||||
|
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||||
|
profile.schoolId = school.id;
|
||||||
|
profile.classId = schoolClass.id;
|
||||||
|
profile.school = school.name;
|
||||||
|
profile.grade = schoolClass.name;
|
||||||
if (!profile.name || !profile.idNumber || !profile.phone || !profile.school) return sendError(response, 400, '姓名、证件号码、手机号和学校为必填项');
|
if (!profile.name || !profile.idNumber || !profile.phone || !profile.school) return sendError(response, 400, '姓名、证件号码、手机号和学校为必填项');
|
||||||
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
|
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
|
||||||
profile.status = 'pending';
|
profile.status = 'pending';
|
||||||
profile.reviewNote = '';
|
profile.reviewNote = '';
|
||||||
profile.updatedAt = nowIso();
|
profile.updatedAt = nowIso();
|
||||||
await database.updateCandidateProfile(profile, profile.name);
|
const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id);
|
||||||
|
const submission = existingWorkflow ? null : createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
|
||||||
|
await database.updateCandidateProfile(profile, profile.name, submission?.instance, submission?.action);
|
||||||
return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' });
|
return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
|
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
|
||||||
@@ -316,8 +582,9 @@ async function handleCandidate(request, response, pathname) {
|
|||||||
if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试');
|
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(), admitCard: null };
|
const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), registrationNumber: '', numberRuleId: null, admitCard: null };
|
||||||
await database.createRegistration(registration);
|
const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id);
|
||||||
|
await database.createRegistration(registration, instance, action);
|
||||||
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
|
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/candidate/results') {
|
if (request.method === 'GET' && pathname === '/api/candidate/results') {
|
||||||
@@ -353,31 +620,344 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
if (!user) return true;
|
if (!user) return true;
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
|
|
||||||
|
if (request.method === 'GET' && pathname === '/api/admin/context') {
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
admin: safeUser(user),
|
||||||
|
adminLevelName: adminLevelNames[user.adminLevel || 'super'],
|
||||||
|
permissions: permissionsByLevel[user.adminLevel || 'super'],
|
||||||
|
scopeLabel: adminScopeLabel(db, user),
|
||||||
|
schools: db.schools,
|
||||||
|
classes: db.classes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/admin/admins' && request.method === 'GET') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const admins = db.users.filter(item => item.role === 'admin').map(item => ({
|
||||||
|
...safeUser(item),
|
||||||
|
active: item.active,
|
||||||
|
levelName: adminLevelNames[item.adminLevel],
|
||||||
|
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||||
|
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
|
||||||
|
}));
|
||||||
|
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const username = cleanText(body.username, 50);
|
||||||
|
const password = String(body.password || '');
|
||||||
|
const displayName = cleanText(body.displayName, 50);
|
||||||
|
const adminLevel = cleanText(body.adminLevel, 20);
|
||||||
|
if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码');
|
||||||
|
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
|
||||||
|
const schoolId = adminLevel === 'super' ? null : cleanText(body.schoolId, 64);
|
||||||
|
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
|
||||||
|
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校');
|
||||||
|
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
|
||||||
|
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
|
||||||
|
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
|
||||||
|
await database.createAdmin(created, log);
|
||||||
|
return sendJson(response, 201, { ok: true, admin: safeUser(created) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/admin/centers' && request.method === 'GET') {
|
||||||
|
if (!requirePermission(user, response, 'centers.read')) return true;
|
||||||
|
const centers = db.testCenters.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId).map(item => ({
|
||||||
|
...item,
|
||||||
|
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||||
|
rooms: db.testRooms.filter(room => room.centerId === item.id),
|
||||||
|
totalCapacity: db.testRooms.filter(room => room.centerId === item.id && room.status === 'active').reduce((sum, room) => sum + Number(room.capacity || 0), 0),
|
||||||
|
pendingChange: db.centerChangeRequests.some(change => change.centerId === item.id && change.status === 'pending')
|
||||||
|
}));
|
||||||
|
const changeRequests = db.centerChangeRequests
|
||||||
|
.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId)
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
.map(item => centerChangeView(db, item));
|
||||||
|
return sendJson(response, 200, { ok: true, centers, changeRequests, schools: user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId) });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/centers' && request.method === 'POST') {
|
||||||
|
if (!requirePermission(user, response, 'centers.write')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId;
|
||||||
|
if (!db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '考点必须归属有效学校');
|
||||||
|
const parsed = parseCenterChange(db, body, schoolId);
|
||||||
|
const change = { id: uid('center_change'), centerId: null, schoolId, requestType: 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
|
||||||
|
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, schoolId), user.id);
|
||||||
|
const log = logAction(db, user, '提交新增考点审批', `${change.name} · ${parsed.rooms.length} 个考场`);
|
||||||
|
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log);
|
||||||
|
return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } });
|
||||||
|
}
|
||||||
|
const centerMatch = pathname.match(/^\/api\/admin\/centers\/([^/]+)$/);
|
||||||
|
if (centerMatch && request.method === 'PATCH') {
|
||||||
|
if (!requirePermission(user, response, 'centers.write')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const center = db.testCenters.find(item => item.id === centerMatch[1]);
|
||||||
|
if (!center) return sendError(response, 404, '考点不存在');
|
||||||
|
if (user.adminLevel !== 'super' && center.schoolId !== user.schoolId) return sendError(response, 403, '只能维护本校考点');
|
||||||
|
if (db.centerChangeRequests.some(item => item.centerId === center.id && item.status === 'pending')) return sendError(response, 409, '该考点已有待审批变更,请处理完成后再提交');
|
||||||
|
const parsed = parseCenterChange(db, body, center.schoolId, center);
|
||||||
|
const change = { id: uid('center_change'), centerId: center.id, schoolId: center.schoolId, requestType: 'update', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null };
|
||||||
|
const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, center.schoolId), user.id);
|
||||||
|
const log = logAction(db, user, '提交考点变更审批', `${change.name} · ${parsed.rooms.length} 个考场`);
|
||||||
|
await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log);
|
||||||
|
return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } });
|
||||||
|
}
|
||||||
|
const centerChangeMatch = pathname.match(/^\/api\/admin\/center-change-requests\/([^/]+)$/);
|
||||||
|
if (centerChangeMatch && request.method === 'PATCH') {
|
||||||
|
if (!requirePermission(user, response, 'centers.write')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效');
|
||||||
|
const change = db.centerChangeRequests.find(item => item.id === centerChangeMatch[1] && item.status === 'pending');
|
||||||
|
if (!change) return sendError(response, 404, '待审批的考点变更不存在');
|
||||||
|
if (user.adminLevel !== 'super' && change.schoolId !== user.schoolId) return sendError(response, 403, '该变更不在你的学校范围内');
|
||||||
|
const instance = pendingWorkflow(db, 'center_change', change.id);
|
||||||
|
const workflow = instance && db.workflows.find(item => item.id === instance.workflowId);
|
||||||
|
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||||
|
if (!instance || !workflow || !step) return sendError(response, 409, '考点变更审批流程状态异常');
|
||||||
|
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||||
|
const note = cleanText(body.reviewNote, 300);
|
||||||
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||||
|
const log = logAction(db, user, body.status === 'approved' ? '审批考点变更' : '退回考点变更', `${change.name} · ${note || '无备注'}`);
|
||||||
|
if (body.status === 'rejected') {
|
||||||
|
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
change.status = 'rejected'; change.reviewNote = note; change.reviewedAt = nowIso();
|
||||||
|
await database.applyCenterChange(change, instance, action, null, [], log);
|
||||||
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
|
const nextAssignee = adminsForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId))[0];
|
||||||
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
|
change.reviewNote = note;
|
||||||
|
await database.processWorkflow(instance, action, change, log);
|
||||||
|
} else {
|
||||||
|
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
change.status = 'approved'; change.reviewNote = note; change.reviewedAt = nowIso();
|
||||||
|
const centerId = change.centerId || uid('center');
|
||||||
|
const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id);
|
||||||
|
const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId }));
|
||||||
|
const center = {
|
||||||
|
id: centerId, schoolId: change.schoolId, code: change.code, name: change.name, address: change.address,
|
||||||
|
contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone,
|
||||||
|
emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport,
|
||||||
|
status: change.centerStatus, notes: change.notes,
|
||||||
|
rooms: rooms.map(room => `${room.building} ${room.name}`).join(';'), updatedAt: nowIso()
|
||||||
|
};
|
||||||
|
await database.applyCenterChange(change, instance, action, center, rooms, log);
|
||||||
|
}
|
||||||
|
return sendJson(response, 200, { ok: true, changeRequest: centerChangeView({ ...db, workflowActions: [...db.workflowActions, action] }, change) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/admin/number-rules' && request.method === 'GET') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const rule = db.numberRules.find(item => item.active) || null;
|
||||||
|
const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id };
|
||||||
|
let preview = '';
|
||||||
|
if (rule) {
|
||||||
|
const sampleRegistration = { examId: db.exams[0]?.id };
|
||||||
|
preview = generateRegistrationNumber(db, sampleRegistration, previewProfile).number;
|
||||||
|
}
|
||||||
|
const batchCandidates = db.registrations.filter(item => item.status === 'approved' && !item.registrationNumber).map(registration => {
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
return { id: registration.id, examId: registration.examId, examName: db.exams.find(item => item.id === registration.examId)?.name || '', schoolId: profile?.schoolId || '', schoolName: profile?.school || '', candidateName: profile?.name || '', createdAt: registration.createdAt };
|
||||||
|
});
|
||||||
|
return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview, batchCandidates, exams: db.exams, schools: db.schools });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/number-rules' && request.method === 'POST') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const allowedTypes = ['year', 'school_code', 'gender', 'sequence', 'literal'];
|
||||||
|
const requested = Array.isArray(body.segments) ? body.segments : [];
|
||||||
|
if (!requested.length || requested.some(item => !allowedTypes.includes(item.type)) || !requested.some(item => item.type === 'sequence')) return sendError(response, 400, '报名号规则至少包含一个流水号段');
|
||||||
|
const existing = db.numberRules.find(item => item.id === body.id);
|
||||||
|
const rule = {
|
||||||
|
id: existing?.id || uid('rule'), name: cleanText(body.name, 80) || '自定义报名号规则', separator: cleanText(body.separator, 3),
|
||||||
|
active: true, createdBy: user.id, updatedAt: nowIso(), segments: requested.map((item, index) => ({
|
||||||
|
id: uid('segment'), position: index + 1, type: item.type, value: cleanText(item.value, 20), width: Math.min(12, Math.max(0, Number(item.width || 0)))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
const log = logAction(db, user, '更新报名号规则', `${rule.name} · ${rule.segments.map(item => item.type).join(' + ')}`);
|
||||||
|
await database.saveNumberRule(rule, !existing, log);
|
||||||
|
return sendJson(response, 200, { ok: true, rule });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/registration-numbers/batch' && request.method === 'POST') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const examId = cleanText(body.examId, 64);
|
||||||
|
const schoolId = cleanText(body.schoolId, 64);
|
||||||
|
const selectedIds = Array.isArray(body.registrationIds) ? new Set(body.registrationIds.map(item => cleanText(item, 64))) : null;
|
||||||
|
const eligible = db.registrations.filter(registration => {
|
||||||
|
if (registration.status !== 'approved' || registration.registrationNumber) return false;
|
||||||
|
if (examId && registration.examId !== examId) return false;
|
||||||
|
if (selectedIds && !selectedIds.has(registration.id)) return false;
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
return Boolean(profile && (!schoolId || profile.schoolId === schoolId));
|
||||||
|
}).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || a.id.localeCompare(b.id)).slice(0, 5000);
|
||||||
|
if (!eligible.length) return sendError(response, 409, '当前筛选条件下没有审核通过且尚未生成报名号的记录');
|
||||||
|
const generated = eligible.map(registration => {
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
const result = generateRegistrationNumber(db, registration, profile);
|
||||||
|
registration.registrationNumber = result.number;
|
||||||
|
registration.numberRuleId = result.ruleId;
|
||||||
|
return registration;
|
||||||
|
});
|
||||||
|
const log = logAction(db, user, '批量生成报名号', `${generated.length} 条 · ${examId || '全部考试'} · ${schoolId || '全部学校'}`);
|
||||||
|
await database.assignRegistrationNumbers(generated, log);
|
||||||
|
return sendJson(response, 200, { ok: true, count: generated.length, registrations: generated.map(item => ({ id: item.id, registrationNumber: item.registrationNumber })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/admin/workflows' && request.method === 'GET') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
return sendJson(response, 200, { ok: true, workflows: db.workflows });
|
||||||
|
}
|
||||||
|
const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change)$/);
|
||||||
|
if (workflowDefinitionMatch && request.method === 'PUT') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const workflow = activeWorkflow(db, workflowDefinitionMatch[1]);
|
||||||
|
if (!workflow) return sendError(response, 404, '审批流程不存在');
|
||||||
|
const steps = Array.isArray(body.steps) ? body.steps : [];
|
||||||
|
if (!steps.length || steps.some(item => !['school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个校级或超级管理员审批步骤');
|
||||||
|
workflow.name = cleanText(body.name, 80) || workflow.name;
|
||||||
|
workflow.updatedBy = user.id;
|
||||||
|
workflow.updatedAt = nowIso();
|
||||||
|
workflow.steps = steps.map((item, index) => ({ id: uid('workflow_step'), position: index + 1, name: cleanText(item.name, 80) || `第 ${index + 1} 步`, adminLevel: item.adminLevel }));
|
||||||
|
const log = logAction(db, user, '修改审批流程', `${workflow.name} · ${workflow.steps.length} 个步骤`);
|
||||||
|
await database.saveWorkflow(workflow, log);
|
||||||
|
return sendJson(response, 200, { ok: true, workflow });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') {
|
||||||
|
if (user.adminLevel === 'class') return sendError(response, 403, '班级管理员只读查看考生、成绩和报名状态');
|
||||||
|
const instances = db.workflowInstances.filter(instance => {
|
||||||
|
if (user.adminLevel === 'super') return true;
|
||||||
|
const profile = workflowScopeProfile(db, instance);
|
||||||
|
return Boolean(profile && profileInScope(user, profile));
|
||||||
|
}).map(instance => {
|
||||||
|
const profile = workflowScopeProfile(db, instance);
|
||||||
|
const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null;
|
||||||
|
const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null;
|
||||||
|
return {
|
||||||
|
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
|
||||||
|
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
|
||||||
|
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser);
|
||||||
|
return sendJson(response, 200, { ok: true, instances, availableAdmins, canSupervise: user.adminLevel === 'super' });
|
||||||
|
}
|
||||||
|
const transferMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/transfer$/);
|
||||||
|
if (transferMatch && request.method === 'PATCH') {
|
||||||
|
const body = await readJson(request);
|
||||||
|
const instance = db.workflowInstances.find(item => item.id === transferMatch[1] && item.status === 'pending');
|
||||||
|
if (!instance) return sendError(response, 404, '待处理流程不存在');
|
||||||
|
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||||
|
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||||
|
if (user.adminLevel !== 'super' && instance.assigneeId !== user.id) return sendError(response, 403, '只有当前处理人可以转交该流程');
|
||||||
|
const target = db.users.find(item => item.id === body.assigneeId && item.role === 'admin' && item.active && item.adminLevel === step?.adminLevel);
|
||||||
|
if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员');
|
||||||
|
const profile = workflowScopeProfile(db, instance);
|
||||||
|
if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员');
|
||||||
|
const previous = instance.assigneeId;
|
||||||
|
instance.assigneeId = target.id;
|
||||||
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() };
|
||||||
|
const log = logAction(db, user, '转交审批流程', `${workflow.name} → ${target.displayName}`);
|
||||||
|
await database.transferWorkflow(instance, action, log);
|
||||||
|
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
|
}
|
||||||
|
const superviseMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/supervise$/);
|
||||||
|
if (superviseMatch && request.method === 'PATCH') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const body = await readJson(request);
|
||||||
|
const instance = db.workflowInstances.find(item => item.id === superviseMatch[1]);
|
||||||
|
if (!instance) return sendError(response, 404, '流程不存在');
|
||||||
|
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||||
|
const requestedStep = Math.min(workflow.steps.length, Math.max(1, Number(body.currentStep || instance.currentStep)));
|
||||||
|
const step = workflow.steps.find(item => item.position === requestedStep);
|
||||||
|
const profile = workflowScopeProfile(db, instance);
|
||||||
|
const eligible = adminsForStep(db, step.adminLevel, profile);
|
||||||
|
const assignee = eligible.find(item => item.id === body.assigneeId) || eligible[0];
|
||||||
|
if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员');
|
||||||
|
const previous = instance.assigneeId;
|
||||||
|
const previousStep = instance.currentStep;
|
||||||
|
instance.status = 'pending'; instance.completedAt = null; instance.currentStep = requestedStep; instance.assigneeId = assignee.id;
|
||||||
|
const note = cleanText(body.note, 300) || `超级管理员将流程调整到第 ${requestedStep} 步`;
|
||||||
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: requestedStep < previousStep ? 'return' : 'supervise', note, fromAssigneeId: previous, toAssigneeId: assignee.id, createdAt: nowIso() };
|
||||||
|
const business = instance.businessType === 'profile_change'
|
||||||
|
? profile
|
||||||
|
: instance.businessType === 'registration_review'
|
||||||
|
? db.registrations.find(item => item.id === instance.businessId)
|
||||||
|
: db.centerChangeRequests.find(item => item.id === instance.businessId);
|
||||||
|
business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null;
|
||||||
|
const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`);
|
||||||
|
await database.processWorkflow(instance, action, business, log);
|
||||||
|
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/dashboard') {
|
if (request.method === 'GET' && pathname === '/api/admin/dashboard') {
|
||||||
const pendingCandidates = db.candidateProfiles.filter(item => item.status === 'pending').length;
|
const profiles = db.candidateProfiles.filter(item => profileInScope(user, item));
|
||||||
const pendingRegistrations = db.registrations.filter(item => item.status === 'pending').length;
|
const registrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||||
return sendJson(response, 200, { ok: true, metrics: { candidates: db.candidateProfiles.length, pendingCandidates, registrations: db.registrations.length, pendingRegistrations, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length }, logs: db.auditLogs.slice(0, 8) });
|
const visibleFlows = db.workflowInstances.filter(instance => {
|
||||||
|
if (user.adminLevel === 'super') return true;
|
||||||
|
if (user.adminLevel === 'class') return false;
|
||||||
|
const business = workflowScopeProfile(db, instance);
|
||||||
|
return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending');
|
||||||
|
});
|
||||||
|
const pendingCandidates = profiles.filter(item => item.status === 'pending').length;
|
||||||
|
const pendingRegistrations = registrations.filter(item => item.status === 'pending').length;
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
admin: safeUser(user),
|
||||||
|
scopeLabel: adminScopeLabel(db, user),
|
||||||
|
permissions: permissionsByLevel[user.adminLevel || 'super'],
|
||||||
|
metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingFlows: visibleFlows.filter(item => item.status === 'pending').length, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length },
|
||||||
|
logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/candidates') {
|
if (request.method === 'GET' && pathname === '/api/admin/candidates') {
|
||||||
const candidates = db.candidateProfiles.map(profile => ({ ...profile, idNumberMasked: maskId(profile.idNumber), username: db.users.find(item => item.id === profile.userId)?.username }));
|
if (!requirePermission(user, response, 'candidates.read')) return true;
|
||||||
|
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
|
||||||
|
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
|
return { ...profile, idNumberMasked: maskId(profile.idNumber), username: db.users.find(item => item.id === profile.userId)?.username, workflow: workflowView(db, instance) };
|
||||||
|
});
|
||||||
return sendJson(response, 200, { ok: true, candidates });
|
return sendJson(response, 200, { ok: true, candidates });
|
||||||
}
|
}
|
||||||
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
|
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && candidateMatch) {
|
if (request.method === 'PATCH' && candidateMatch) {
|
||||||
|
if (!requirePermission(user, response, 'candidates.review')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]);
|
const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]);
|
||||||
if (!profile) return sendError(response, 404, '考生资料不存在');
|
if (!profile) return sendError(response, 404, '考生资料不存在');
|
||||||
|
if (!profileInScope(user, profile) && user.adminLevel !== 'super') return sendError(response, 403, '该考生不在你的数据范围内');
|
||||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||||
profile.status = body.status;
|
const instance = pendingWorkflow(db, 'profile_change', profile.id);
|
||||||
profile.reviewNote = cleanText(body.reviewNote, 300);
|
if (!instance) return sendError(response, 409, '当前没有待处理的考生信息流程');
|
||||||
profile.reviewedAt = nowIso();
|
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||||
profile.reviewerId = user.id;
|
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||||
const log = logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`);
|
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||||
await database.reviewCandidate(profile, log);
|
const note = cleanText(body.reviewNote, 300);
|
||||||
return sendJson(response, 200, { ok: true, profile });
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||||
|
if (body.status === 'rejected') {
|
||||||
|
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
|
||||||
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
|
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
|
||||||
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
|
profile.status = 'pending'; profile.reviewNote = note;
|
||||||
|
} else {
|
||||||
|
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
profile.status = 'approved'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
|
||||||
|
}
|
||||||
|
const log = logAction(db, user, body.status === 'approved' ? '处理考生信息流程' : '退回考生信息', `${profile.name}:${note || '无备注'}`);
|
||||||
|
await database.processWorkflow(instance, action, profile, log);
|
||||||
|
return sendJson(response, 200, { ok: true, profile, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/registrations') {
|
if (request.method === 'GET' && pathname === '/api/admin/registrations') {
|
||||||
const registrations = db.registrations.map(registration => {
|
if (!requirePermission(user, response, 'registrations.read')) return true;
|
||||||
|
const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).map(registration => {
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
|
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
|
||||||
});
|
});
|
||||||
@@ -385,21 +965,57 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
}
|
}
|
||||||
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
|
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && registrationMatch) {
|
if (request.method === 'PATCH' && registrationMatch) {
|
||||||
|
if (!requirePermission(user, response, 'registrations.review')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
|
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
|
||||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||||
|
if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内');
|
||||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||||
registration.status = body.status;
|
|
||||||
registration.reviewNote = cleanText(body.reviewNote, 300);
|
|
||||||
registration.reviewedAt = nowIso();
|
|
||||||
if (body.status === 'approved') registration.paymentStatus = 'paid';
|
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
const log = logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
|
const instance = pendingWorkflow(db, 'registration_review', registration.id);
|
||||||
await database.reviewRegistration(registration, log);
|
if (!instance) return sendError(response, 409, '当前没有待处理的报名审核流程');
|
||||||
return sendJson(response, 200, { ok: true, registration });
|
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||||
|
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||||
|
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||||
|
const note = cleanText(body.reviewNote, 300);
|
||||||
|
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||||
|
if (body.status === 'rejected') {
|
||||||
|
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso();
|
||||||
|
} else if (instance.currentStep < workflow.steps.length) {
|
||||||
|
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||||
|
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
|
||||||
|
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||||
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
|
registration.status = 'pending'; registration.reviewNote = note;
|
||||||
|
} else {
|
||||||
|
const generated = registration.registrationNumber ? null : generateRegistrationNumber(db, registration, profile);
|
||||||
|
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
|
registration.status = 'approved'; registration.paymentStatus = 'paid'; registration.reviewNote = note; registration.reviewedAt = nowIso();
|
||||||
|
if (generated) { registration.registrationNumber = generated.number; registration.numberRuleId = generated.ruleId; }
|
||||||
|
}
|
||||||
|
const log = logAction(db, user, body.status === 'approved' ? '处理报名审核流程' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
|
||||||
|
await database.processWorkflow(instance, action, registration, log);
|
||||||
|
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||||
}
|
}
|
||||||
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
||||||
|
const numberMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/registration-number$/);
|
||||||
|
if (request.method === 'POST' && numberMatch) {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const registration = db.registrations.find(item => item.id === numberMatch[1]);
|
||||||
|
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||||
|
if (!registration.registrationNumber) {
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
const generated = generateRegistrationNumber(db, registration, profile);
|
||||||
|
registration.registrationNumber = generated.number;
|
||||||
|
registration.numberRuleId = generated.ruleId;
|
||||||
|
const log = logAction(db, user, '生成报名号', `${profile?.name || registration.userId} · ${generated.number}`);
|
||||||
|
await database.assignRegistrationNumber(registration, log);
|
||||||
|
}
|
||||||
|
return sendJson(response, 200, { ok: true, registrationNumber: registration.registrationNumber });
|
||||||
|
}
|
||||||
if (request.method === 'POST' && admitMatch) {
|
if (request.method === 'POST' && admitMatch) {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
||||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||||
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
|
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
|
||||||
@@ -419,8 +1035,12 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
}
|
}
|
||||||
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
|
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/exams') return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) });
|
if (request.method === 'GET' && pathname === '/api/admin/exams') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) });
|
||||||
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/exams') {
|
if (request.method === 'POST' && pathname === '/api/admin/exams') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const name = cleanText(body.name, 100);
|
const name = cleanText(body.name, 100);
|
||||||
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
|
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
|
||||||
@@ -434,6 +1054,7 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
}
|
}
|
||||||
const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/);
|
const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && examMatch) {
|
if (request.method === 'PATCH' && examMatch) {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const exam = db.exams.find(item => item.id === examMatch[1]);
|
const exam = db.exams.find(item => item.id === examMatch[1]);
|
||||||
if (!exam) return sendError(response, 404, '考试不存在');
|
if (!exam) return sendError(response, 404, '考试不存在');
|
||||||
@@ -458,8 +1079,12 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
await database.updateExam(exam, log, replaceSubjects);
|
await database.updateExam(exam, log, replaceSubjects);
|
||||||
return sendJson(response, 200, { ok: true, exam });
|
return sendJson(response, 200, { ok: true, exam });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/notices') return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
|
if (request.method === 'GET' && pathname === '/api/admin/notices') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
|
||||||
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const title = cleanText(body.title, 120);
|
const title = cleanText(body.title, 120);
|
||||||
const content = cleanText(body.content, 5000);
|
const content = cleanText(body.content, 5000);
|
||||||
@@ -471,6 +1096,7 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
}
|
}
|
||||||
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && noticeMatch) {
|
if (request.method === 'PATCH' && noticeMatch) {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const notice = db.notices.find(item => item.id === noticeMatch[1]);
|
const notice = db.notices.find(item => item.id === noticeMatch[1]);
|
||||||
if (!notice) return sendError(response, 404, '通知不存在');
|
if (!notice) return sendError(response, 404, '通知不存在');
|
||||||
@@ -485,16 +1111,19 @@ async function handleAdmin(request, response, pathname) {
|
|||||||
return sendJson(response, 200, { ok: true, notice });
|
return sendJson(response, 200, { ok: true, notice });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||||
const results = db.results.map(result => {
|
if (!requirePermission(user, response, 'results.read')) return true;
|
||||||
|
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||||
|
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
|
||||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||||
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
|
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
|
||||||
});
|
});
|
||||||
return sendJson(response, 200, { ok: true, results, registrations: db.registrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) });
|
return sendJson(response, 200, { ok: true, results, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved');
|
const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved');
|
||||||
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
|
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
|
||||||
|
|||||||
+26
File diff suppressed because one or more lines are too long
+101
-7
@@ -51,6 +51,9 @@ function createClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const admin = createClient();
|
const admin = createClient();
|
||||||
|
const schoolAdmin = createClient();
|
||||||
|
const schoolAdmin2 = createClient();
|
||||||
|
const classAdmin = createClient();
|
||||||
const candidate = createClient();
|
const candidate = createClient();
|
||||||
const anonymous = createClient();
|
const anonymous = createClient();
|
||||||
|
|
||||||
@@ -82,7 +85,7 @@ try {
|
|||||||
|
|
||||||
const register = await candidate.request('/api/auth/register', {
|
const register = await candidate.request('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', school: '海州市测试中学', grade: '高三(1)班' }
|
body: { username: 'test_candidate', password: 'Test12345!', name: '测试考生', gender: '男', idNumber: '320101200801019999', phone: '13900009999', schoolId: 'school_hz1', classId: 'class_hz1_302' }
|
||||||
});
|
});
|
||||||
assert.equal(register.response.status, 201, '考生应可自主注册');
|
assert.equal(register.response.status, 201, '考生应可自主注册');
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ try {
|
|||||||
|
|
||||||
const updateProfile = await candidate.request('/api/candidate/profile', {
|
const updateProfile = await candidate.request('/api/candidate/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', phone: '13900009999', email: 'test@example.com', school: '海州市测试中学', grade: '高三(1)班', address: '海州市测试区 1 号', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', address: '海州市测试区 1 号', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
||||||
});
|
});
|
||||||
assert.equal(updateProfile.response.status, 200, '考生应可自主维护完整资料');
|
assert.equal(updateProfile.response.status, 200, '考生应可自主维护完整资料');
|
||||||
assert.equal(updateProfile.data.profile.status, 'pending', '资料修改后应重新进入审核');
|
assert.equal(updateProfile.data.profile.status, 'pending', '资料修改后应重新进入审核');
|
||||||
@@ -103,6 +106,59 @@ try {
|
|||||||
|
|
||||||
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } });
|
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');
|
||||||
|
assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员');
|
||||||
|
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: 'School123!' } })).data.user.adminLevel, 'school');
|
||||||
|
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school');
|
||||||
|
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
||||||
|
|
||||||
|
const adminDirectory = await admin.request('/api/admin/admins');
|
||||||
|
assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员');
|
||||||
|
const schoolCenters = await schoolAdmin.request('/api/admin/centers');
|
||||||
|
assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点');
|
||||||
|
const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: {
|
||||||
|
code: 'HZ01-EAST', name: '海州市第一中学东区考点', address: '海州市测试路 8 号', contact: '0518-12345678',
|
||||||
|
managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案',
|
||||||
|
rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatStart: 1, seatEnd: 30, roomType: 'standard', status: 'active', notes: '' }]
|
||||||
|
} });
|
||||||
|
assert.equal(newCenter.response.status, 202, '新增考点应创建审批申请而不是直接落库');
|
||||||
|
assert.equal(newCenter.data.changeRequest.schoolId, 'school_hz1', '校级管理员新增考点必须自动归属本校');
|
||||||
|
const beforeCenterApproval = await schoolAdmin.request('/api/admin/centers');
|
||||||
|
assert.ok(!beforeCenterApproval.data.centers.some(item => item.code === 'HZ01-EAST'), '考点审批通过前不得进入正式档案');
|
||||||
|
const approveCenter = await admin.request(`/api/admin/center-change-requests/${newCenter.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考务条件核验通过' } });
|
||||||
|
assert.equal(approveCenter.response.status, 200, '超级管理员应可审批考点变更');
|
||||||
|
const afterCenterApproval = await schoolAdmin.request('/api/admin/centers');
|
||||||
|
const createdCenter = afterCenterApproval.data.centers.find(item => item.code === 'HZ01-EAST');
|
||||||
|
assert.equal(createdCenter.rooms.length, 1, '审批通过后应同时写入结构化考场');
|
||||||
|
assert.equal(createdCenter.totalCapacity, 30, '考场容量应汇总到考点档案');
|
||||||
|
const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: {
|
||||||
|
...createdCenter, managerName: '变更后负责人', rooms: [
|
||||||
|
...createdCenter.rooms,
|
||||||
|
{ code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatStart: 31, seatEnd: 55, roomType: 'accessible', status: 'active', notes: '无障碍通道' }
|
||||||
|
]
|
||||||
|
} });
|
||||||
|
assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批');
|
||||||
|
const unchangedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id);
|
||||||
|
assert.equal(unchangedCenter.managerName, '测试负责人', '变更审批通过前正式档案不得改变');
|
||||||
|
await admin.request(`/api/admin/center-change-requests/${centerUpdate.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '同意扩充考场' } });
|
||||||
|
const changedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id);
|
||||||
|
assert.equal(changedCenter.managerName, '变更后负责人', '审批通过后正式考点档案应更新');
|
||||||
|
assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换');
|
||||||
|
assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点');
|
||||||
|
|
||||||
|
const numberRules = await admin.request('/api/admin/number-rules');
|
||||||
|
assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则');
|
||||||
|
const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: {
|
||||||
|
id: numberRules.data.activeRule.id, name: '测试组合规则', separator: '-', segments: [
|
||||||
|
{ type: 'year', width: 4 }, { type: 'school_code' }, { type: 'gender' }, { type: 'sequence', width: 4 }
|
||||||
|
]
|
||||||
|
} });
|
||||||
|
assert.equal(saveNumberRule.response.status, 200, '超级管理员应可自由组合并启用报名号逻辑');
|
||||||
|
assert.ok(numberRules.data.batchCandidates.some(item => item.id === 'reg_demo_2026'), '报名号页面应列出审核通过但缺少号码的记录');
|
||||||
|
const batchNumbers = await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: { examId: 'exam_autumn_2026', schoolId: 'school_hz1' } });
|
||||||
|
assert.equal(batchNumbers.response.status, 200, '超级管理员应可按考试和学校批量生成报名号');
|
||||||
|
assert.equal(batchNumbers.data.count, 1, '批量生成只处理筛选范围内缺失号码的记录');
|
||||||
|
assert.match(batchNumbers.data.registrations[0].registrationNumber, /^2026-HZ01-F-\d{4}$/);
|
||||||
|
assert.equal((await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: { examId: 'exam_autumn_2026', schoolId: 'school_hz1' } })).response.status, 409, '重复批量执行不得覆盖已有报名号');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hour = 60 * 60 * 1000;
|
const hour = 60 * 60 * 1000;
|
||||||
@@ -119,6 +175,8 @@ try {
|
|||||||
assert.equal(createExam.response.status, 201);
|
assert.equal(createExam.response.status, 201);
|
||||||
assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试');
|
assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试');
|
||||||
const exam = createExam.data.exam;
|
const exam = createExam.data.exam;
|
||||||
|
const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } });
|
||||||
|
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
|
||||||
|
|
||||||
const draftResponse = await admin.request('/api/admin/exams', {
|
const draftResponse = await admin.request('/api/admin/exams', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -148,7 +206,16 @@ try {
|
|||||||
const profile = candidates.data.candidates.find(item => item.username === 'test_candidate');
|
const profile = candidates.data.candidates.find(item => item.username === 'test_candidate');
|
||||||
assert.ok(profile, '管理员应能看到新注册考生');
|
assert.ok(profile, '管理员应能看到新注册考生');
|
||||||
assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料');
|
assert.equal(profile.address, '海州市测试区 1 号', '管理员应能审核考生自主填写的完整资料');
|
||||||
const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '自动化测试审核通过' } });
|
const schoolCandidates = await schoolAdmin.request('/api/admin/candidates');
|
||||||
|
assert.ok(schoolCandidates.data.candidates.some(item => item.id === profile.id), '校级管理员应看到本校考生');
|
||||||
|
const classCandidates = await classAdmin.request('/api/admin/candidates');
|
||||||
|
assert.ok(classCandidates.data.candidates.some(item => item.id === profile.id), '班级管理员应看到本班考生');
|
||||||
|
const classCannotReview = await classAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved' } });
|
||||||
|
assert.equal(classCannotReview.response.status, 403, '班级管理员不得审核考生资料');
|
||||||
|
|
||||||
|
const schoolApproveProfile = await schoolAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校学籍复核通过' } });
|
||||||
|
assert.equal(schoolApproveProfile.data.profile.status, 'pending', '校级初审后应进入超级管理员终审');
|
||||||
|
const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审通过' } });
|
||||||
assert.equal(approveProfile.data.profile.status, 'approved');
|
assert.equal(approveProfile.data.profile.status, 'approved');
|
||||||
|
|
||||||
const submitRegistration = await candidate.request('/api/candidate/registrations', {
|
const submitRegistration = await candidate.request('/api/candidate/registrations', {
|
||||||
@@ -162,8 +229,23 @@ try {
|
|||||||
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
||||||
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
||||||
|
|
||||||
const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格核验通过' } });
|
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
||||||
|
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
||||||
|
assert.ok(registrationFlow, '报名应建立可追踪的审批实例');
|
||||||
|
const transfer = await schoolAdmin.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: 'usr_school_admin_2', note: '同级管理员协办' } });
|
||||||
|
assert.equal(transfer.data.workflow.assignee.id, 'usr_school_admin_2', '同级管理员之间应可转交流程');
|
||||||
|
const schoolApproveRegistration = await schoolAdmin2.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } });
|
||||||
|
assert.equal(schoolApproveRegistration.data.registration.status, 'pending', '校级初审后报名仍应待终审');
|
||||||
|
|
||||||
|
const allFlows = await admin.request('/api/admin/workflow-instances');
|
||||||
|
const supervisedFlow = allFlows.data.instances.find(item => item.id === registrationFlow.id);
|
||||||
|
assert.equal(supervisedFlow.currentStep, 2, '超级管理员应看到全部流程及当前节点');
|
||||||
|
const supervisedReturn = await admin.request(`/api/admin/workflow-instances/${registrationFlow.id}/supervise`, { method: 'PATCH', body: { currentStep: 1, assigneeId: 'usr_school_admin', note: '抽查后退回学校复核' } });
|
||||||
|
assert.equal(supervisedReturn.data.workflow.currentStep, 1, '超级管理员应可监督并退回流程节点');
|
||||||
|
await schoolAdmin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校再次复核通过' } });
|
||||||
|
const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } });
|
||||||
assert.equal(approveRegistration.data.registration.status, 'approved');
|
assert.equal(approveRegistration.data.registration.status, 'approved');
|
||||||
|
assert.match(approveRegistration.data.registration.registrationNumber, /^2026-HZ01-M-\d{4}$/, '终审通过后应按年份、学校、性别和流水号生成报名号');
|
||||||
|
|
||||||
const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
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, '管理员应能生成准考证号');
|
||||||
@@ -190,6 +272,7 @@ try {
|
|||||||
const futureExam = futureExamResponse.data.exam;
|
const futureExam = futureExamResponse.data.exam;
|
||||||
const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } });
|
const 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 admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } });
|
||||||
await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } });
|
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`);
|
||||||
@@ -204,14 +287,25 @@ try {
|
|||||||
assert.equal(publishResult.response.status, 200);
|
assert.equal(publishResult.response.status, 200);
|
||||||
const results = await candidate.request('/api/candidate/results');
|
const results = await candidate.request('/api/candidate/results');
|
||||||
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
||||||
|
const classResults = await classAdmin.request('/api/admin/results');
|
||||||
|
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
||||||
|
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
||||||
|
|
||||||
|
const workflowDefinitions = await admin.request('/api/admin/workflows');
|
||||||
|
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
||||||
|
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批');
|
||||||
|
const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } });
|
||||||
|
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
|
||||||
|
|
||||||
console.log('✓ 公开首页与通知读取');
|
console.log('✓ 公开首页与通知读取');
|
||||||
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('✓ 候选人与管理员角色权限隔离');
|
|
||||||
} finally {
|
} finally {
|
||||||
server.kill('SIGTERM');
|
server.kill('SIGTERM');
|
||||||
await new Promise(resolveWait => server.once('exit', resolveWait));
|
await new Promise(resolveWait => server.once('exit', resolveWait));
|
||||||
|
|||||||
Reference in New Issue
Block a user