Refactor exam information workflow
This commit is contained in:
@@ -150,13 +150,36 @@ npm test
|
||||
|
||||
## 项目结构
|
||||
|
||||
项目采用模块化单体架构:仍由一个 Node.js 进程部署,但 HTTP、权限、业务路由、数据库适配和前端页面按职责分开。
|
||||
|
||||
```text
|
||||
index.html 页面入口
|
||||
styles.css 公共首页、考生端、管理端响应式样式
|
||||
app.js 前端路由、状态和业务交互
|
||||
server.mjs HTTP 服务、认证、权限与全部业务 API
|
||||
database.mjs 分表结构、SQLite / MySQL 适配与事务仓储
|
||||
app.js 前端路由、事件与表单控制器
|
||||
server.mjs HTTP 服务启动、模块装配与静态文件服务
|
||||
database.mjs 数据仓储与数据库模块装配
|
||||
excel.mjs Excel 模板、导入解析与导出工作簿
|
||||
|
||||
src/data/seed.mjs 演示数据
|
||||
src/http/responses.mjs JSON、文件与请求体处理
|
||||
src/security/session.mjs Cookie 会话与当前用户
|
||||
src/security/authorization.mjs 管理层级、权限和数据范围
|
||||
src/routes/public.routes.mjs 公开 API
|
||||
src/routes/auth.routes.mjs 登录、注册与改密 API
|
||||
src/routes/candidate.routes.mjs 考生业务 API
|
||||
src/routes/admin.routes.mjs 管理业务 API
|
||||
|
||||
src/database/schema.mjs SQLite / MySQL 关系模型
|
||||
src/database/sqlite-adapter.mjs SQLite 初始化、迁移与事务适配
|
||||
src/database/mysql-adapter.mjs MySQL 初始化、迁移与事务适配
|
||||
|
||||
src/client/state.mjs 前端共享状态
|
||||
src/client/api.mjs 浏览器 API 请求封装
|
||||
src/client/ui.mjs 格式化、图标与通用 UI 工具
|
||||
src/client/public-views.mjs 公共首页与登录注册视图
|
||||
src/client/candidate-views.mjs 考生中心视图
|
||||
src/client/admin-views.mjs 管理后台视图
|
||||
|
||||
tests/system.test.mjs 端到端系统测试
|
||||
data/exam.sqlite 本地运行后生成的 SQLite 数据库
|
||||
.env.example 开发与生产环境变量模板
|
||||
|
||||
@@ -1,75 +1,12 @@
|
||||
const state = {
|
||||
user: null,
|
||||
profile: null,
|
||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||
permissions: [],
|
||||
scopeLabel: '',
|
||||
pageData: null,
|
||||
loading: false
|
||||
};
|
||||
import { api } from './src/client/api.mjs';
|
||||
import { createAdminViews } from './src/client/admin-views.mjs';
|
||||
import { createCandidateViews } from './src/client/candidate-views.mjs';
|
||||
import { createPublicViews } from './src/client/public-views.mjs';
|
||||
import { state } from './src/client/state.mjs';
|
||||
import { badge, dateRange, formatDate, h, icons, money, statusLabels } from './src/client/ui.mjs';
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
const modalRoot = document.querySelector('#modalRoot');
|
||||
const statusLabels = {
|
||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||
published: '已发布', draft: '草稿', closed: '已结束',
|
||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
home: '<svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
|
||||
user: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4.5 21a7.5 7.5 0 0 1 15 0"/></svg>',
|
||||
exam: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>',
|
||||
ticket: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 0 0 4v6h18v-6a2 2 0 0 0 0-4V5H3zM8 5v12"/></svg>',
|
||||
chart: '<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>',
|
||||
bell: '<svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 22h4"/></svg>',
|
||||
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 4a4 4 0 0 1 0 8M18 15a6 6 0 0 1 4 6"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>',
|
||||
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
logout: '<svg viewBox="0 0 24 24"><path d="M14 8V4H4v16h10v-4M10 12h11M18 9l3 3-3 3"/></svg>',
|
||||
menu: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m16 16 5 5"/></svg>',
|
||||
arrow: '<svg viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>'
|
||||
};
|
||||
|
||||
function h(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
}
|
||||
|
||||
function formatDate(value, withTime = false) {
|
||||
if (!value) return '待定';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return h(value);
|
||||
return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
|
||||
}
|
||||
|
||||
function dateRange(start, end) {
|
||||
return `${formatDate(start)} — ${formatDate(end)}`;
|
||||
}
|
||||
|
||||
function badge(status) {
|
||||
return `<span class="status status-${h(status)}">${h(statusLabels[status] || status)}</span>`;
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
return `¥${Number(value || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
...options,
|
||||
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
|
||||
});
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||
if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试');
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(title, message = '') {
|
||||
const element = document.querySelector('#toast');
|
||||
@@ -89,314 +26,6 @@ function closeModal() {
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
|
||||
function brand() {
|
||||
return `<a class="brand" href="#home" data-route="home"><span class="brand-symbol"><i></i><i></i><i></i></span><span><strong>衡准</strong><small>EXAM SERVICE</small></span></a>`;
|
||||
}
|
||||
|
||||
function publicHeader() {
|
||||
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
const { notices, exams, stats, organization } = state.publicData;
|
||||
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
|
||||
const topNotice = notices[0];
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main">
|
||||
<section class="hero">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">HAIZHOU EXAMINATION SERVICE</p><h1>一个报名号,<br><em>贯穿每一次考试。</em></h1><p class="hero-lead">使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
|
||||
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
|
||||
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
|
||||
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
|
||||
</main><footer class="public-footer"><div>${brand()}<p>${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}</p></div><span>本平台展示数据仅用于系统演示</span></footer>`;
|
||||
}
|
||||
|
||||
function renderHeroTicket(exam) {
|
||||
const status = exam.registrationState;
|
||||
return `<article class="hero-ticket"><div class="ticket-main"><header><span>${badge(status)}</span><small>${h(exam.code)}</small></header><p>UPCOMING EXAM</p><h2>${h(exam.name)}</h2><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(exam.location)}</dd></div></dl><div class="subject-chips">${exam.subjects.slice(0, 5).map(subject => `<span>${h(subject.name)}</span>`).join('')}${exam.subjects.length > 5 ? `<span>+${exam.subjects.length - 5}</span>` : ''}</div></div><div class="ticket-stub"><span>报名人数</span><strong>${h(exam.registrationCount || 0)}</strong><i></i><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${status === 'open' ? '立即报名' : '查看详情'}</button></div></article>`;
|
||||
}
|
||||
|
||||
function renderNoticeRow(notice) {
|
||||
return `<button class="notice-row" data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
|
||||
}
|
||||
|
||||
function renderPublicExam(exam) {
|
||||
return `<article class="public-exam-card"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</header><h3>${h(exam.name)}</h3><p>${h(exam.description)}</p><div class="exam-meta"><span><b>报名</b>${dateRange(exam.registrationStart, exam.registrationEnd)}</span><span><b>考试</b>${dateRange(exam.examStart, exam.examEnd)}</span></div><footer><span>${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名</span><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow}</button></footer></article>`;
|
||||
}
|
||||
|
||||
function renderAuth(kind) {
|
||||
const login = kind === 'login';
|
||||
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}${login ? `<div class="demo-accounts"><strong>演示账号</strong><button data-action="fill-demo" data-type="candidate">考生:2026-HZ01-F-0001 / Candidate123!</button><button data-action="fill-demo" data-type="admin">超级管理员:admin / Admin123!</button><button data-action="fill-demo" data-type="school">校级管理员:school_admin / School123!</button><button data-action="fill-demo" data-type="class">班级管理员:class_admin / Class123!</button></div>` : ''}</div></section></main>`;
|
||||
}
|
||||
|
||||
function loginForm() {
|
||||
return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
|
||||
function registerForm() {
|
||||
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" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
|
||||
const candidateNav = [
|
||||
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
|
||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell']
|
||||
];
|
||||
function adminNavForUser() {
|
||||
const level = state.user?.adminLevel || 'super';
|
||||
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
||||
if (level === 'class') return core;
|
||||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
|
||||
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
||||
}
|
||||
|
||||
function portalShell(role, page, content, title, description) {
|
||||
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
||||
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
||||
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) {
|
||||
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 === '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 === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
|
||||
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function loadingPanel() {
|
||||
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
|
||||
}
|
||||
|
||||
function onboardingShell(stage, content) {
|
||||
const passwordDone = stage !== 'password';
|
||||
return `<main class="onboarding-page"><aside class="onboarding-identity">${brand()}<span>固定报名号</span><strong>${h(state.user.candidateNumber)}</strong><p>这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。</p><div class="onboarding-steps"><div class="${stage === 'password' ? 'current' : 'done'}"><i>${passwordDone ? '✓' : '1'}</i><span><b>修改初始密码</b><small>设置仅本人知道的新密码</small></span></div><div class="${stage === 'profile' ? 'current' : passwordDone ? '' : ''}"><i>2</i><span><b>补全个人信息</b><small>实名、籍贯、住址和学籍信息</small></span></div><div><i>3</i><span><b>等待资料审核</b><small>审核通过后开始考试报名</small></span></div></div><button data-action="logout">退出当前账户</button></aside><section class="onboarding-work"><div class="onboarding-work-head"><span>FIRST SIGN-IN</span><h1>${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}</h1><p>${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}</p></div>${content}</section></main>`;
|
||||
}
|
||||
|
||||
function passwordOnboardingForm() {
|
||||
return `<section class="panel password-onboarding"><div class="password-rule"><b>新密码要求</b><span>至少 8 位,且不能与初始密码相同。</span></div><form class="stack-form" data-form="candidate-password"><label><span>当前初始密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>设置新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码并继续 ${icons.arrow}</button></form></section>`;
|
||||
}
|
||||
|
||||
async function renderCandidate(page) {
|
||||
if (state.user?.role !== 'candidate') return navigate('login');
|
||||
if (state.user.mustChangePassword) {
|
||||
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
|
||||
return;
|
||||
}
|
||||
if (!state.profile?.profileCompleted) {
|
||||
try {
|
||||
const data = await api('/api/candidate/profile');
|
||||
state.pageData = data; state.profile = data.profile;
|
||||
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
|
||||
} catch (error) { renderError(error); }
|
||||
return;
|
||||
}
|
||||
const meta = {
|
||||
dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'],
|
||||
profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'],
|
||||
exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'],
|
||||
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
|
||||
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
|
||||
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
|
||||
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。']
|
||||
};
|
||||
if (!meta[page]) page = 'dashboard';
|
||||
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations';
|
||||
const data = page === 'notices' ? { notices: state.publicData.notices } : await api(`/api/candidate/${endpoint}`);
|
||||
state.pageData = data;
|
||||
if (data.profile) state.profile = data.profile;
|
||||
const content = {
|
||||
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
|
||||
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
|
||||
results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
function candidateDashboard(data) {
|
||||
const registration = data.registrations[0];
|
||||
const steps = [
|
||||
['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'],
|
||||
['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'],
|
||||
['考试报名', Boolean(registration), registration ? '已报名' : '未报名'],
|
||||
['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'],
|
||||
['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length} 科` : '待发布']
|
||||
];
|
||||
return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
|
||||
}
|
||||
|
||||
function candidateProfile(data, onboarding = false) {
|
||||
const { profile, schools = [], classes = [], workflow } = data;
|
||||
const step = workflow?.currentStepDetail;
|
||||
const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber;
|
||||
return `<section class="panel form-panel ${onboarding ? 'onboarding-profile' : ''}">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<form class="profile-form" data-form="candidate-profile"><div class="form-section-title"><span>01</span><div><h2>身份信息</h2><p>姓名和证件号码须与有效证件完全一致。</p></div></div><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(idNumber)}"></label><label><span>出生日期</span><input name="birthDate" type="date" value="${h(profile?.birthDate)}"></label><label><span>籍贯 *</span><input name="nativePlace" required value="${h(profile?.nativePlace)}" placeholder="例如:江苏海州"></label><label><span>民族</span><input name="ethnicity" value="${h(profile?.ethnicity)}" placeholder="例如:汉族"></label></div><div class="form-section-title"><span>02</span><div><h2>学校与班级</h2><p>学校和班级决定资料审批范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div><div class="form-section-title"><span>03</span><div><h2>家庭与联系信息</h2><p>用于考试通知、身份复核和紧急联系。</p></div></div><div class="form-grid"><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label><label><span>电子邮箱 *</span><input name="email" type="email" required value="${h(profile?.email)}"></label><label class="wide-field"><span>家庭住址 *</span><input name="address" required value="${h(profile?.address)}" placeholder="请填写省、市、区及详细门牌"></label><label><span>邮政编码</span><input name="postalCode" value="${h(profile?.postalCode)}"></label><label><span>监护人姓名</span><input name="guardianName" value="${h(profile?.guardianName)}"></label><label><span>监护人电话</span><input name="guardianPhone" value="${h(profile?.guardianPhone)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}</p><button class="solid-button" type="submit">${onboarding ? '提交个人信息' : '保存并提交审批'}</button></div></form></section>`;
|
||||
}
|
||||
|
||||
function candidateExams(data) {
|
||||
return `<div class="exam-application-list">${data.exams.map(exam => `<article class="apply-card ${exam.registration ? 'registered' : ''}"><header><div><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</div><small>${exam.registrationCount || 0} 人已报名</small></header><div class="apply-card-main"><div class="apply-copy"><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名期限</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点安排</dt><dd>${h(exam.location)}</dd></div></dl></div><form class="subject-selector" data-form="exam-registration"><input type="hidden" name="examId" value="${h(exam.id)}"><div class="subject-title"><strong>选择报考科目</strong><span>可多选</span></div><div class="subject-options">${exam.subjects.map(subject => `<label><input type="checkbox" name="subjectIds" value="${h(subject.id)}" ${exam.registration?.subjectIds.includes(subject.id) ? 'checked disabled' : ''}><span><i>${h(subject.name.slice(0, 1))}</i><b>${h(subject.name)}</b><small>${h(subject.date)} · ${h(subject.start)}</small><em>${money(subject.fee)}</em></span></label>`).join('') || '<p class="empty-state">科目安排尚未发布</p>'}</div>${exam.registration ? `<div class="registered-banner">${icons.check}<span>已提交报名 · ${exam.registration.subjectIds.length} 个科目</span>${badge(exam.registration.status)}</div>` : `<div class="subject-total"><span>已选 <b data-subject-count>0</b> 科</span><strong data-subject-fee>合计 ¥0.00</strong></div><button class="solid-button" type="submit" ${exam.registrationState !== 'open' || data.profileStatus !== 'approved' || !exam.subjects.length ? 'disabled' : ''}>${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]}</button>`}</form></div></article>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function candidateRegistrations(registrations) {
|
||||
return registrations.length ? `<div class="registration-cards">${registrations.map(reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.status)}</header><div class="registration-info"><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || state.user.candidateNumber)}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`).join('')}</div>` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
|
||||
}
|
||||
|
||||
function candidateAdmit(registrations) {
|
||||
const cards = registrations.filter(reg => reg.admitCard);
|
||||
return cards.length ? `<div class="admit-list">${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场 / 座位</dt><dd>${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`; }).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态');
|
||||
}
|
||||
|
||||
function candidateResults(results) {
|
||||
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
||||
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
|
||||
return `<div class="result-groups">${Object.entries(grouped).map(([examName, items]) => `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>发布时间 ${formatDate(items[0].publishedAt, true)}</small></header><div class="score-grid">${items.map(item => `<article><span>${h(item.subjectName)}</span><strong>${h(item.score)}</strong><em>${h(item.grade)}</em><small>满分 150</small></article>`).join('')}</div><footer><p>成绩仅供查询,如对成绩有异议,请在通知规定时间内申请复核。</p><strong>已发布 ${items.length} 科</strong></footer></section>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function candidateNotices(notices) {
|
||||
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
|
||||
}
|
||||
|
||||
async function renderAdmin(page) {
|
||||
if (state.user?.role !== 'admin') return navigate('login');
|
||||
const meta = {
|
||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
|
||||
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||
organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'],
|
||||
'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'],
|
||||
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
|
||||
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
|
||||
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。']
|
||||
};
|
||||
const allowedPages = adminNavForUser().map(item => item[0]);
|
||||
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||
const data = await api(`/api/admin/${endpoint}`);
|
||||
state.pageData = data;
|
||||
const content = {
|
||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data),
|
||||
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
function adminDashboard(data) {
|
||||
const m = data.metrics;
|
||||
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 excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
|
||||
return `<div class="excel-toolbar"><span><strong>${h(label)} Excel</strong><small>使用系统模板可获得逐行校验</small></span><div>${template ? `<button class="row-action" data-action="excel-download" data-resource="${h(resource)}" data-template="1">下载模板</button>` : ''}<button class="row-action" data-action="excel-download" data-resource="${h(resource)}">导出当前数据</button>${importable ? `<button class="row-action primary" data-action="excel-import" data-resource="${h(resource)}">导入 Excel</button><input type="file" accept=".xlsx" hidden data-excel-file="${h(resource)}">` : ''}</div></div>`;
|
||||
}
|
||||
|
||||
function adminSchoolOrganization(data) {
|
||||
const classes = data.classes || [];
|
||||
const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0);
|
||||
return `<section class="school-org-banner"><div><span>SCHOOL ORGANIZATION</span><h2>${h(data.school?.name)}</h2><p>班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。</p></div><dl><div><dt>班级</dt><dd>${classes.length}</dd></div><div><dt>班级管理员</dt><dd>${activeAdmins}</dd></div><div><dt>在册考生</dt><dd>${classes.reduce((sum, item) => sum + item.candidateCount, 0)}</dd></div></dl></section>${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}<section class="org-class-grid">${classes.map(item => `<article class="panel org-class-card ${item.active ? '' : 'inactive'}"><header><div><span>${h(item.grade)}</span><h2>${h(item.name)}</h2></div>${badge(item.active ? 'approved' : 'closed')}</header><div class="org-class-metrics"><span><strong>${item.candidateCount}</strong><small>在册考生</small></span><span><strong>${item.admins.length}</strong><small>管理员</small></span></div><section><div class="org-admin-head"><strong>班级管理员</strong><button class="row-action" data-action="new-class-admin" data-class-id="${h(item.id)}">添加管理员</button></div>${item.admins.map(admin => `<button class="org-admin-row" data-action="edit-class-admin" data-id="${h(admin.id)}" data-class-id="${h(item.id)}"><span class="user-avatar">${h(admin.displayName.slice(0,1))}</span><span><strong>${h(admin.displayName)}</strong><small class="mono">${h(admin.username)}</small></span>${badge(admin.active ? 'approved' : 'closed')}</button>`).join('') || '<p class="org-empty">尚未配置班级管理员</p>'}</section><footer><button class="row-action" data-action="edit-school-class" data-id="${h(item.id)}">编辑班级</button><button class="row-action" data-action="toggle-school-class" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用班级' : '重新启用'}</button></footer></article>`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}</section>`;
|
||||
}
|
||||
|
||||
function adminCandidates(candidates) {
|
||||
const readOnly = state.user.adminLevel === 'class';
|
||||
return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索报名号、姓名、证件号或学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 班级</th><th>账户进度</th><th>更新时间</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminRegistrations(registrations) {
|
||||
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>各次考试保持一致</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) {
|
||||
return `<div class="admin-exam-grid">${exams.map(exam => `<article class="admin-exam-card ${exam.status}${exam.status === 'draft' ? ' editable' : ''}" ${exam.status === 'draft' ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.registrationCount} 人报名 · ${exam.subjects.length} 科</span><div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></div></footer></article>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function adminNotices(notices) {
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminAdmit(registrations) {
|
||||
const approved = registrations.filter(reg => reg.status === 'approved');
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号"></label><p>仅已通过报名审核的考生可生成准考证。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>报考科目</th><th>准考证号</th><th>考点 / 考场</th><th>下载时间</th><th>操作</th></tr></thead><tbody>${approved.map(reg => `<tr><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?.phone)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td>${reg.subjects.map(subject => h(subject.name)).join('、')}</td><td class="mono">${h(reg.admitCard?.number || '尚未生成')}</td><td>${reg.admitCard ? `${h(reg.admitCard.testCenter)}<small>${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号</small>` : '—'}</td><td>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</td><td><button class="row-action ${reg.admitCard ? '' : 'primary'}" data-action="generate-admit" data-id="${h(reg.id)}">${reg.admitCard ? '重新查看' : '生成准考证'}</button></td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminResults(data) {
|
||||
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 `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}<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 registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel"><div class="data-toolbar"><p>管理员层级决定可见范围和可执行操作;同一级可创建多名账号。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th></tr></thead><tbody>${data.admins.map(item => `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminCenters(data) {
|
||||
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>${h(room.seatPlan || '按现场座次表编排')}</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 `${excelToolbar('centers', { label: '考点考场档案' })}<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 adminAccountBatches(data) {
|
||||
const classes = data.classes || [];
|
||||
const batches = data.batches || [];
|
||||
const form = `<section class="panel batch-quota-panel"><div class="batch-quota-head"><div><span>SCHOOL ACCOUNT REQUEST</span><h2>按班级申领报名号</h2><p>只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。</p></div><div><small>单批上限</small><strong>500</strong><span>个账户</span></div></div><form data-form="candidate-account-batch"><div class="quota-grid">${classes.map(item => `<label><span><strong>${h(item.name)}</strong><small>${h(item.grade)}</small></span><span class="quota-input"><input type="number" min="0" max="200" value="0" data-class-id="${h(item.id)}"><em>人</em></span></label>`).join('')}</div><div class="batch-submit-bar"><p><strong>审批通过后生成</strong><span>固定报名号、随机初始密码、待补录考生账户</span></p><button class="solid-button" type="submit">提交批量申领</button></div></form></section>`;
|
||||
const ledger = batches.map(batch => {
|
||||
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="excel-download" data-resource="account_results" data-batch-id="${h(batch.id)}">导出 Excel</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
|
||||
return `<article class="panel account-batch-card ${h(batch.status)}"><header><div><span class="mono">${h(batch.id)}</span><h2>${h(batch.schoolName)} · ${batch.totalCount} 个报名号</h2><p>${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交</p></div>${badge(batch.status)}</header><div class="batch-quota-summary">${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><em>${item.count} 人</em></span>`).join('')}</div><div class="batch-flow-line"><span>当前进度</span><strong>${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}</strong><small>${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}</small></div>${batch.reviewNote ? `<div class="batch-review-note"><strong>审批意见</strong><span>${h(batch.reviewNote)}</span></div>` : ''}${resultRows}</article>`;
|
||||
}).join('');
|
||||
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
|
||||
}
|
||||
|
||||
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);
|
||||
return `<section class="account-number-principle"><span>ONE CANDIDATE · ONE NUMBER</span><strong>超级管理员只设计号码规则</strong><p>校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。</p></section><div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>账户报名号组成</h2><p>规则用于最终审批后的批量建号;流水号为必选字段。</p></div><span>当前规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存审批建号规则</button></form></section><aside class="rule-preview"><span>审批后账户样例</span><strong>${h(data.preview || '2026-HZ01-X-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>批量申领阶段尚无性别资料,因此性别段使用 X;考生补录资料后号码保持不变。</small></aside></div>`;
|
||||
}
|
||||
|
||||
function adminFlowDesign(workflows) {
|
||||
const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH' };
|
||||
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: '考点考场变更', candidate_account_batch: '批量报名号申领' };
|
||||
return `<div class="workflow-board">${data.instances.map(instance => {
|
||||
const isCenter = instance.businessType === 'center_change';
|
||||
const isBatch = instance.businessType === 'candidate_account_batch';
|
||||
const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName;
|
||||
const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : `${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) {
|
||||
return `<section class="panel empty-panel"><span>${icons.ticket}</span><h2>${h(title)}</h2><p>${h(description)}</p>${route ? `<button class="solid-button" data-route="${h(route)}">${h(action)}</button>` : ''}</section>`;
|
||||
}
|
||||
@@ -405,6 +34,11 @@ function renderError(error) {
|
||||
app.innerHTML = `<section class="fatal-error"><span>!</span><h1>页面暂时无法加载</h1><p>${h(error.message)}</p><button class="solid-button" data-action="retry">重新加载</button></section>`;
|
||||
}
|
||||
|
||||
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, statusLabels, icons, api, renderError, emptyState };
|
||||
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
|
||||
const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand });
|
||||
const { renderAdmin } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser });
|
||||
|
||||
function navigate(route) {
|
||||
location.hash = route;
|
||||
if (location.hash.slice(1) === route) renderRoute();
|
||||
|
||||
+17
-1310
File diff suppressed because it is too large
Load Diff
+77
-1096
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
export function createAdminViews(context) {
|
||||
const {
|
||||
state,
|
||||
app,
|
||||
h,
|
||||
formatDate,
|
||||
dateRange,
|
||||
badge,
|
||||
money,
|
||||
statusLabels,
|
||||
icons,
|
||||
api,
|
||||
renderError,
|
||||
emptyState,
|
||||
brand,
|
||||
portalShell,
|
||||
loadingPanel,
|
||||
adminNavForUser
|
||||
} = context;
|
||||
|
||||
async function renderAdmin(page) {
|
||||
if (state.user?.role !== 'admin') return navigate('login');
|
||||
const meta = {
|
||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
|
||||
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||
organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'],
|
||||
'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'],
|
||||
flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'],
|
||||
'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'],
|
||||
'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。']
|
||||
};
|
||||
const allowedPages = adminNavForUser().map(item => item[0]);
|
||||
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||
const data = await api(`/api/admin/${endpoint}`);
|
||||
state.pageData = data;
|
||||
const content = {
|
||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data),
|
||||
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('admin', page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
function adminDashboard(data) {
|
||||
const m = data.metrics;
|
||||
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 excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
|
||||
return `<div class="excel-toolbar"><span><strong>${h(label)} Excel</strong><small>使用系统模板可获得逐行校验</small></span><div>${template ? `<button class="row-action" data-action="excel-download" data-resource="${h(resource)}" data-template="1">下载模板</button>` : ''}<button class="row-action" data-action="excel-download" data-resource="${h(resource)}">导出当前数据</button>${importable ? `<button class="row-action primary" data-action="excel-import" data-resource="${h(resource)}">导入 Excel</button><input type="file" accept=".xlsx" hidden data-excel-file="${h(resource)}">` : ''}</div></div>`;
|
||||
}
|
||||
|
||||
function adminSchoolOrganization(data) {
|
||||
const classes = data.classes || [];
|
||||
const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0);
|
||||
return `<section class="school-org-banner"><div><span>SCHOOL ORGANIZATION</span><h2>${h(data.school?.name)}</h2><p>班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。</p></div><dl><div><dt>班级</dt><dd>${classes.length}</dd></div><div><dt>班级管理员</dt><dd>${activeAdmins}</dd></div><div><dt>在册考生</dt><dd>${classes.reduce((sum, item) => sum + item.candidateCount, 0)}</dd></div></dl></section>${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}<section class="org-class-grid">${classes.map(item => `<article class="panel org-class-card ${item.active ? '' : 'inactive'}"><header><div><span>${h(item.grade)}</span><h2>${h(item.name)}</h2></div>${badge(item.active ? 'approved' : 'closed')}</header><div class="org-class-metrics"><span><strong>${item.candidateCount}</strong><small>在册考生</small></span><span><strong>${item.admins.length}</strong><small>管理员</small></span></div><section><div class="org-admin-head"><strong>班级管理员</strong><button class="row-action" data-action="new-class-admin" data-class-id="${h(item.id)}">添加管理员</button></div>${item.admins.map(admin => `<button class="org-admin-row" data-action="edit-class-admin" data-id="${h(admin.id)}" data-class-id="${h(item.id)}"><span class="user-avatar">${h(admin.displayName.slice(0,1))}</span><span><strong>${h(admin.displayName)}</strong><small class="mono">${h(admin.username)}</small></span>${badge(admin.active ? 'approved' : 'closed')}</button>`).join('') || '<p class="org-empty">尚未配置班级管理员</p>'}</section><footer><button class="row-action" data-action="edit-school-class" data-id="${h(item.id)}">编辑班级</button><button class="row-action" data-action="toggle-school-class" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用班级' : '重新启用'}</button></footer></article>`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}</section>`;
|
||||
}
|
||||
|
||||
function adminCandidates(candidates) {
|
||||
const readOnly = state.user.adminLevel === 'class';
|
||||
return `${excelToolbar('candidates', { importable: !readOnly, label: '考生资料' })}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="candidateTable" placeholder="搜索报名号、姓名、证件号或学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="candidateTable" data-status="all">全部</button><button data-action="status-filter" data-target="candidateTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="candidateTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="candidateTable" data-status="rejected">需修改</button></div></div><div class="table-scroll"><table id="candidateTable"><thead><tr><th>报名号 / 考生</th><th>证件号码</th><th>学校 / 班级</th><th>账户进度</th><th>更新时间</th><th>资料状态</th><th>操作</th></tr></thead><tbody>${candidates.map(item => `<tr data-status="${h(item.status)}"><td><div class="person-cell"><span>${h(item.name.slice(0,1))}</span><div><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber || '待分配')}</small></div></div></td><td class="mono">${h(item.idNumberMasked)}</td><td><strong>${h(item.school || '未填写')}</strong><small>${h(item.grade || '')}</small></td><td><strong>${item.mustChangePassword ? '待首次改密' : item.profileCompleted ? h(item.workflow?.currentStepDetail?.name || '资料已提交') : '待补全资料'}</strong><small>${h(item.workflow?.assignee?.displayName || '')}</small></td><td>${formatDate(item.updatedAt,true)}</td><td>${item.profileCompleted ? badge(item.status) : '<span class="onboarding-badge">未完成</span>'}</td><td><button class="row-action" data-action="review-candidate" data-id="${h(item.id)}" ${item.profileCompleted ? '' : 'disabled'}>${item.profileCompleted ? (readOnly ? '查看' : '查看流程') : '等待考生'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminRegistrations(registrations) {
|
||||
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>各次考试保持一致</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) {
|
||||
return `<div class="admin-exam-grid">${exams.map(exam => `<article class="admin-exam-card ${exam.status}${exam.status === 'draft' ? ' editable' : ''}" ${exam.status === 'draft' ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.registrationCount} 人报名 · ${exam.subjects.length} 科</span><div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></div></footer></article>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function adminNotices(notices) {
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminAdmit(registrations) {
|
||||
const approved = registrations.filter(reg => reg.status === 'approved');
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号"></label><p>仅已通过报名审核的考生可生成准考证。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>报考科目</th><th>准考证号</th><th>考点 / 考场</th><th>下载时间</th><th>操作</th></tr></thead><tbody>${approved.map(reg => `<tr><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?.phone)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td>${reg.subjects.map(subject => h(subject.name)).join('、')}</td><td class="mono">${h(reg.admitCard?.number || '尚未生成')}</td><td>${reg.admitCard ? `${h(reg.admitCard.testCenter)}<small>${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号</small>` : '—'}</td><td>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</td><td><button class="row-action ${reg.admitCard ? '' : 'primary'}" data-action="generate-admit" data-id="${h(reg.id)}">${reg.admitCard ? '重新查看' : '生成准考证'}</button></td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminResults(data) {
|
||||
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 `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}<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 registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel"><div class="data-toolbar"><p>管理员层级决定可见范围和可执行操作;同一级可创建多名账号。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th></tr></thead><tbody>${data.admins.map(item => `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminCenters(data) {
|
||||
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>${h(room.seatPlan || '按现场座次表编排')}</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 `${excelToolbar('centers', { label: '考点考场档案' })}<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 adminAccountBatches(data) {
|
||||
const classes = data.classes || [];
|
||||
const batches = data.batches || [];
|
||||
const form = `<section class="panel batch-quota-panel"><div class="batch-quota-head"><div><span>SCHOOL ACCOUNT REQUEST</span><h2>按班级申领报名号</h2><p>只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。</p></div><div><small>单批上限</small><strong>500</strong><span>个账户</span></div></div><form data-form="candidate-account-batch"><div class="quota-grid">${classes.map(item => `<label><span><strong>${h(item.name)}</strong><small>${h(item.grade)}</small></span><span class="quota-input"><input type="number" min="0" max="200" value="0" data-class-id="${h(item.id)}"><em>人</em></span></label>`).join('')}</div><div class="batch-submit-bar"><p><strong>审批通过后生成</strong><span>固定报名号、随机初始密码、待补录考生账户</span></p><button class="solid-button" type="submit">提交批量申领</button></div></form></section>`;
|
||||
const ledger = batches.map(batch => {
|
||||
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="excel-download" data-resource="account_results" data-batch-id="${h(batch.id)}">导出 Excel</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
|
||||
return `<article class="panel account-batch-card ${h(batch.status)}"><header><div><span class="mono">${h(batch.id)}</span><h2>${h(batch.schoolName)} · ${batch.totalCount} 个报名号</h2><p>${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交</p></div>${badge(batch.status)}</header><div class="batch-quota-summary">${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><em>${item.count} 人</em></span>`).join('')}</div><div class="batch-flow-line"><span>当前进度</span><strong>${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}</strong><small>${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}</small></div>${batch.reviewNote ? `<div class="batch-review-note"><strong>审批意见</strong><span>${h(batch.reviewNote)}</span></div>` : ''}${resultRows}</article>`;
|
||||
}).join('');
|
||||
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
|
||||
}
|
||||
|
||||
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);
|
||||
return `<section class="account-number-principle"><span>ONE CANDIDATE · ONE NUMBER</span><strong>超级管理员只设计号码规则</strong><p>校级管理员按班级提交申领,流程最终批准后系统才创建长期考生账户。</p></section><div class="number-rule-layout"><section class="panel rule-builder"><div class="panel-title"><div><h2>账户报名号组成</h2><p>规则用于最终审批后的批量建号;流水号为必选字段。</p></div><span>当前规则</span></div><form data-form="number-rule"><input type="hidden" name="id" value="${h(rule.id)}"><div class="field-row"><label><span>规则名称</span><input name="name" required value="${h(rule.name)}"></label><label><span>字段分隔符</span><input name="separator" maxlength="3" value="${h(rule.separator)}" placeholder="留空表示直接拼接"></label></div><div class="segment-builder">${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return `<label class="segment-option ${checked ? 'selected' : ''}"><input type="checkbox" name="include_${type}" ${checked ? 'checked' : ''} ${type === 'sequence' ? 'disabled' : ''}><span class="segment-order"><small>顺序</small><input type="number" name="position_${type}" min="1" max="9" value="${segment?.position || index + 1}"></span><span class="segment-copy"><strong>${numberSegmentMeta[type][0]}</strong><small>${numberSegmentMeta[type][1]}</small></span>${type === 'literal' ? `<input class="segment-value" name="value_${type}" value="${h(segment?.value)}" placeholder="例如 HZ">` : ''}${['year','sequence'].includes(type) ? `<input class="segment-width" type="number" name="width_${type}" min="1" max="12" value="${segment?.width || 4}" title="位数">` : ''}</label>`; }).join('')}</div><button class="solid-button" type="submit">保存审批建号规则</button></form></section><aside class="rule-preview"><span>审批后账户样例</span><strong>${h(data.preview || '2026-HZ01-X-0001')}</strong><p>${rule.segments.map(item => numberSegmentMeta[item.type]?.[0] || item.type).join(' + ')}</p><small>批量申领阶段尚无性别资料,因此性别段使用 X;考生补录资料后号码保持不变。</small></aside></div>`;
|
||||
}
|
||||
|
||||
function adminFlowDesign(workflows) {
|
||||
const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH' };
|
||||
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: '考点考场变更', candidate_account_batch: '批量报名号申领' };
|
||||
return `<div class="workflow-board">${data.instances.map(instance => {
|
||||
const isCenter = instance.businessType === 'center_change';
|
||||
const isBatch = instance.businessType === 'candidate_account_batch';
|
||||
const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : instance.candidateName;
|
||||
const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : `${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>`;
|
||||
}
|
||||
|
||||
return { renderAdmin };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export async function api(path, options = {}) {
|
||||
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
|
||||
...options,
|
||||
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
|
||||
});
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||
if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
export function createCandidateViews(context) {
|
||||
const {
|
||||
state,
|
||||
app,
|
||||
h,
|
||||
formatDate,
|
||||
dateRange,
|
||||
badge,
|
||||
money,
|
||||
statusLabels,
|
||||
icons,
|
||||
api,
|
||||
renderError,
|
||||
emptyState,
|
||||
brand
|
||||
} = context;
|
||||
|
||||
const candidateNav = [
|
||||
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
|
||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell']
|
||||
];
|
||||
function adminNavForUser() {
|
||||
const level = state.user?.adminLevel || 'super';
|
||||
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
||||
if (level === 'class') return core;
|
||||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
|
||||
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
||||
}
|
||||
|
||||
function portalShell(role, page, content, title, description) {
|
||||
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
||||
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
||||
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) {
|
||||
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 === '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 === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
|
||||
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function loadingPanel() {
|
||||
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
|
||||
}
|
||||
|
||||
function onboardingShell(stage, content) {
|
||||
const passwordDone = stage !== 'password';
|
||||
return `<main class="onboarding-page"><aside class="onboarding-identity">${brand()}<span>固定报名号</span><strong>${h(state.user.candidateNumber)}</strong><p>这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。</p><div class="onboarding-steps"><div class="${stage === 'password' ? 'current' : 'done'}"><i>${passwordDone ? '✓' : '1'}</i><span><b>修改初始密码</b><small>设置仅本人知道的新密码</small></span></div><div class="${stage === 'profile' ? 'current' : passwordDone ? '' : ''}"><i>2</i><span><b>补全个人信息</b><small>实名、籍贯、住址和学籍信息</small></span></div><div><i>3</i><span><b>等待资料审核</b><small>审核通过后开始考试报名</small></span></div></div><button data-action="logout">退出当前账户</button></aside><section class="onboarding-work"><div class="onboarding-work-head"><span>FIRST SIGN-IN</span><h1>${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}</h1><p>${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}</p></div>${content}</section></main>`;
|
||||
}
|
||||
|
||||
function passwordOnboardingForm() {
|
||||
return `<section class="panel password-onboarding"><div class="password-rule"><b>新密码要求</b><span>至少 8 位,且不能与初始密码相同。</span></div><form class="stack-form" data-form="candidate-password"><label><span>当前初始密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>设置新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码并继续 ${icons.arrow}</button></form></section>`;
|
||||
}
|
||||
|
||||
async function renderCandidate(page) {
|
||||
if (state.user?.role !== 'candidate') return navigate('login');
|
||||
if (state.user.mustChangePassword) {
|
||||
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
|
||||
return;
|
||||
}
|
||||
if (!state.profile?.profileCompleted) {
|
||||
try {
|
||||
const data = await api('/api/candidate/profile');
|
||||
state.pageData = data; state.profile = data.profile;
|
||||
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
|
||||
} catch (error) { renderError(error); }
|
||||
return;
|
||||
}
|
||||
const meta = {
|
||||
dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'],
|
||||
profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'],
|
||||
exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'],
|
||||
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
|
||||
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
|
||||
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
|
||||
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。']
|
||||
};
|
||||
if (!meta[page]) page = 'dashboard';
|
||||
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations';
|
||||
const data = page === 'notices' ? { notices: state.publicData.notices } : await api(`/api/candidate/${endpoint}`);
|
||||
state.pageData = data;
|
||||
if (data.profile) state.profile = data.profile;
|
||||
const content = {
|
||||
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
|
||||
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
|
||||
results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
function candidateDashboard(data) {
|
||||
const registration = data.registrations[0];
|
||||
const steps = [
|
||||
['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'],
|
||||
['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'],
|
||||
['考试报名', Boolean(registration), registration ? '已报名' : '未报名'],
|
||||
['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'],
|
||||
['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length} 科` : '待发布']
|
||||
];
|
||||
return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
|
||||
}
|
||||
|
||||
function candidateProfile(data, onboarding = false) {
|
||||
const { profile, schools = [], classes = [], workflow } = data;
|
||||
const step = workflow?.currentStepDetail;
|
||||
const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber;
|
||||
return `<section class="panel form-panel ${onboarding ? 'onboarding-profile' : ''}">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<form class="profile-form" data-form="candidate-profile"><div class="form-section-title"><span>01</span><div><h2>身份信息</h2><p>姓名和证件号码须与有效证件完全一致。</p></div></div><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(idNumber)}"></label><label><span>出生日期</span><input name="birthDate" type="date" value="${h(profile?.birthDate)}"></label><label><span>籍贯 *</span><input name="nativePlace" required value="${h(profile?.nativePlace)}" placeholder="例如:江苏海州"></label><label><span>民族</span><input name="ethnicity" value="${h(profile?.ethnicity)}" placeholder="例如:汉族"></label></div><div class="form-section-title"><span>02</span><div><h2>学校与班级</h2><p>学校和班级决定资料审批范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div><div class="form-section-title"><span>03</span><div><h2>家庭与联系信息</h2><p>用于考试通知、身份复核和紧急联系。</p></div></div><div class="form-grid"><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label><label><span>电子邮箱 *</span><input name="email" type="email" required value="${h(profile?.email)}"></label><label class="wide-field"><span>家庭住址 *</span><input name="address" required value="${h(profile?.address)}" placeholder="请填写省、市、区及详细门牌"></label><label><span>邮政编码</span><input name="postalCode" value="${h(profile?.postalCode)}"></label><label><span>监护人姓名</span><input name="guardianName" value="${h(profile?.guardianName)}"></label><label><span>监护人电话</span><input name="guardianPhone" value="${h(profile?.guardianPhone)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}</p><button class="solid-button" type="submit">${onboarding ? '提交个人信息' : '保存并提交审批'}</button></div></form></section>`;
|
||||
}
|
||||
|
||||
function candidateExams(data) {
|
||||
return `<div class="exam-application-list">${data.exams.map(exam => `<article class="apply-card ${exam.registration ? 'registered' : ''}"><header><div><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</div><small>${exam.registrationCount || 0} 人已报名</small></header><div class="apply-card-main"><div class="apply-copy"><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名期限</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点安排</dt><dd>${h(exam.location)}</dd></div></dl></div><form class="subject-selector" data-form="exam-registration"><input type="hidden" name="examId" value="${h(exam.id)}"><div class="subject-title"><strong>选择报考科目</strong><span>可多选</span></div><div class="subject-options">${exam.subjects.map(subject => `<label><input type="checkbox" name="subjectIds" value="${h(subject.id)}" ${exam.registration?.subjectIds.includes(subject.id) ? 'checked disabled' : ''}><span><i>${h(subject.name.slice(0, 1))}</i><b>${h(subject.name)}</b><small>${h(subject.date)} · ${h(subject.start)}</small><em>${money(subject.fee)}</em></span></label>`).join('') || '<p class="empty-state">科目安排尚未发布</p>'}</div>${exam.registration ? `<div class="registered-banner">${icons.check}<span>已提交报名 · ${exam.registration.subjectIds.length} 个科目</span>${badge(exam.registration.status)}</div>` : `<div class="subject-total"><span>已选 <b data-subject-count>0</b> 科</span><strong data-subject-fee>合计 ¥0.00</strong></div><button class="solid-button" type="submit" ${exam.registrationState !== 'open' || data.profileStatus !== 'approved' || !exam.subjects.length ? 'disabled' : ''}>${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]}</button>`}</form></div></article>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function candidateRegistrations(registrations) {
|
||||
return registrations.length ? `<div class="registration-cards">${registrations.map(reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.status)}</header><div class="registration-info"><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || state.user.candidateNumber)}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`).join('')}</div>` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
|
||||
}
|
||||
|
||||
function candidateAdmit(registrations) {
|
||||
const cards = registrations.filter(reg => reg.admitCard);
|
||||
return cards.length ? `<div class="admit-list">${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场 / 座位</dt><dd>${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`; }).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态');
|
||||
}
|
||||
|
||||
function candidateResults(results) {
|
||||
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
||||
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
|
||||
return `<div class="result-groups">${Object.entries(grouped).map(([examName, items]) => `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>发布时间 ${formatDate(items[0].publishedAt, true)}</small></header><div class="score-grid">${items.map(item => `<article><span>${h(item.subjectName)}</span><strong>${h(item.score)}</strong><em>${h(item.grade)}</em><small>满分 150</small></article>`).join('')}</div><footer><p>成绩仅供查询,如对成绩有异议,请在通知规定时间内申请复核。</p><strong>已发布 ${items.length} 科</strong></footer></section>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function candidateNotices(notices) {
|
||||
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
|
||||
}
|
||||
|
||||
return { adminNavForUser, portalShell, loadingPanel, renderCandidate };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export function createPublicViews(context) {
|
||||
const {
|
||||
state,
|
||||
app,
|
||||
h,
|
||||
formatDate,
|
||||
dateRange,
|
||||
badge,
|
||||
money,
|
||||
statusLabels,
|
||||
icons,
|
||||
api,
|
||||
renderError,
|
||||
emptyState
|
||||
} = context;
|
||||
|
||||
function brand() {
|
||||
return `<a class="brand" href="#home" data-route="home"><span class="brand-symbol"><i></i><i></i><i></i></span><span><strong>衡准</strong><small>EXAM SERVICE</small></span></a>`;
|
||||
}
|
||||
|
||||
function publicHeader() {
|
||||
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
const { notices, exams, stats, organization } = state.publicData;
|
||||
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
|
||||
const topNotice = notices[0];
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main">
|
||||
<section class="hero">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">HAIZHOU EXAMINATION SERVICE</p><h1>一个报名号,<br><em>贯穿每一次考试。</em></h1><p class="hero-lead">使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
|
||||
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
|
||||
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
|
||||
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
|
||||
</main><footer class="public-footer"><div>${brand()}<p>${h(organization.name || '海州市教育考试中心')} · ${h(organization.phone || '')}</p></div><span>本平台展示数据仅用于系统演示</span></footer>`;
|
||||
}
|
||||
|
||||
function renderHeroTicket(exam) {
|
||||
const status = exam.registrationState;
|
||||
return `<article class="hero-ticket"><div class="ticket-main"><header><span>${badge(status)}</span><small>${h(exam.code)}</small></header><p>UPCOMING EXAM</p><h2>${h(exam.name)}</h2><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(exam.location)}</dd></div></dl><div class="subject-chips">${exam.subjects.slice(0, 5).map(subject => `<span>${h(subject.name)}</span>`).join('')}${exam.subjects.length > 5 ? `<span>+${exam.subjects.length - 5}</span>` : ''}</div></div><div class="ticket-stub"><span>报名人数</span><strong>${h(exam.registrationCount || 0)}</strong><i></i><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${status === 'open' ? '立即报名' : '查看详情'}</button></div></article>`;
|
||||
}
|
||||
|
||||
function renderNoticeRow(notice) {
|
||||
return `<button class="notice-row" data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
|
||||
}
|
||||
|
||||
function renderPublicExam(exam) {
|
||||
return `<article class="public-exam-card"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</header><h3>${h(exam.name)}</h3><p>${h(exam.description)}</p><div class="exam-meta"><span><b>报名</b>${dateRange(exam.registrationStart, exam.registrationEnd)}</span><span><b>考试</b>${dateRange(exam.examStart, exam.examEnd)}</span></div><footer><span>${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名</span><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow}</button></footer></article>`;
|
||||
}
|
||||
|
||||
function renderAuth(kind) {
|
||||
const login = kind === 'login';
|
||||
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}${login ? `<div class="demo-accounts"><strong>演示账号</strong><button data-action="fill-demo" data-type="candidate">考生:2026-HZ01-F-0001 / Candidate123!</button><button data-action="fill-demo" data-type="admin">超级管理员:admin / Admin123!</button><button data-action="fill-demo" data-type="school">校级管理员:school_admin / School123!</button><button data-action="fill-demo" data-type="class">班级管理员:class_admin / Class123!</button></div>` : ''}</div></section></main>`;
|
||||
}
|
||||
|
||||
function loginForm() {
|
||||
return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
|
||||
function registerForm() {
|
||||
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" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
|
||||
return { brand, renderHome, renderAuth };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const state = {
|
||||
user: null,
|
||||
profile: null,
|
||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||
permissions: [],
|
||||
scopeLabel: '',
|
||||
pageData: null,
|
||||
loading: false
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
export const statusLabels = {
|
||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||
published: '已发布', draft: '草稿', closed: '已结束',
|
||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||
};
|
||||
|
||||
export const icons = {
|
||||
home: '<svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
|
||||
user: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4.5 21a7.5 7.5 0 0 1 15 0"/></svg>',
|
||||
exam: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>',
|
||||
ticket: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 0 0 4v6h18v-6a2 2 0 0 0 0-4V5H3zM8 5v12"/></svg>',
|
||||
chart: '<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>',
|
||||
bell: '<svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 22h4"/></svg>',
|
||||
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 4a4 4 0 0 1 0 8M18 15a6 6 0 0 1 4 6"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>',
|
||||
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
logout: '<svg viewBox="0 0 24 24"><path d="M14 8V4H4v16h10v-4M10 12h11M18 9l3 3-3 3"/></svg>',
|
||||
menu: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m16 16 5 5"/></svg>',
|
||||
arrow: '<svg viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>'
|
||||
};
|
||||
|
||||
export function h(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
}
|
||||
|
||||
export function formatDate(value, withTime = false) {
|
||||
if (!value) return '待定';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return h(value);
|
||||
return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
|
||||
}
|
||||
|
||||
export function dateRange(start, end) {
|
||||
return `${formatDate(start)} — ${formatDate(end)}`;
|
||||
}
|
||||
|
||||
export function badge(status) {
|
||||
return `<span class="status status-${h(status)}">${h(statusLabels[status] || status)}</span>`;
|
||||
}
|
||||
|
||||
export function money(value) {
|
||||
return `¥${Number(value || 0).toFixed(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const adminId = 'usr_admin';
|
||||
const schoolAdminId = 'usr_school_admin';
|
||||
const schoolAdmin2Id = 'usr_school_admin_2';
|
||||
const candidateId = 'usr_demo';
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
return {
|
||||
meta: { version: 6, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: {
|
||||
name: '海州市教育考试中心',
|
||||
code: 'HZ-EDU-032',
|
||||
phone: '0518-8602 3158',
|
||||
address: '海州市清河区文教路 18 号'
|
||||
},
|
||||
schools: [
|
||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '海州市清河区学府路 8 号', active: true },
|
||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '海州市滨河区育才路 16 号', active: true }
|
||||
],
|
||||
classes: [
|
||||
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
||||
{ id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true },
|
||||
{ id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true }
|
||||
],
|
||||
users: [
|
||||
{ id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() },
|
||||
{ id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
|
||||
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
|
||||
{ id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() }
|
||||
],
|
||||
candidateProfiles: [
|
||||
{
|
||||
id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821',
|
||||
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
||||
address: '海州市清河区', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
|
||||
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
{ id: 'notice_1', title: '2026 年秋季统一考试报名安排', summary: '报名时间为 7 月 1 日至 7 月 31 日,请考生完成实名认证后选报科目。', content: '2026 年秋季统一考试报名现已开放。考生须在规定时间内登录平台,核对个人信息并选择报考科目。逾期不再补报。', category: '报名通知', pinned: true, status: 'published', publishAt: '2026-07-01T01:00:00.000Z', author: '考试中心' },
|
||||
{ id: 'notice_2', title: '准考证下载与考场规则说明', summary: '准考证开放下载后,请使用 A4 纸打印并妥善保管。', content: '准考证下载时间为 7 月 20 日至 8 月 16 日。考生须携带身份证和纸质准考证入场,开考 15 分钟后不得进入考点。', category: '考试须知', pinned: false, status: 'published', publishAt: '2026-07-15T02:30:00.000Z', author: '考试中心' },
|
||||
{ id: 'notice_3', title: '市第三中学考点交通提示', summary: '考试期间考点周边实行临时交通管制,请提前规划路线。', content: '建议考生至少提前 50 分钟到达考点。考点不提供停车位,请优先选择公共交通出行。', category: '考点公告', pinned: false, status: 'published', publishAt: '2026-07-18T06:00:00.000Z', author: '考务组' }
|
||||
],
|
||||
exams: [
|
||||
{
|
||||
id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。',
|
||||
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
|
||||
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
|
||||
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
|
||||
location: '海州市各指定考点', status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
|
||||
subjects: [
|
||||
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30 },
|
||||
{ id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30 },
|
||||
{ id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 },
|
||||
{ id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 },
|
||||
{ id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30 },
|
||||
{ id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25 },
|
||||
{ id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
|
||||
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
|
||||
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
|
||||
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
|
||||
location: '考点待公布', status: 'draft', createdAt: nowIso(), subjects: []
|
||||
}
|
||||
],
|
||||
registrations: [
|
||||
{
|
||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
|
||||
admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' }
|
||||
}
|
||||
],
|
||||
results: [
|
||||
{ id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' },
|
||||
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
|
||||
],
|
||||
testCenters: [
|
||||
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', address: '海州市清河区学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() },
|
||||
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', address: '海州市滨河区育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
||||
],
|
||||
testRooms: [
|
||||
{ id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' },
|
||||
{ id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' },
|
||||
{ id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' }
|
||||
],
|
||||
centerChangeRequests: [],
|
||||
centerChangeRooms: [],
|
||||
candidateAccountBatches: [],
|
||||
candidateAccountBatchItems: [],
|
||||
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' }
|
||||
] },
|
||||
{ id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||
{ id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' }
|
||||
] }
|
||||
],
|
||||
workflowInstances: [],
|
||||
workflowActions: [],
|
||||
auditLogs: [
|
||||
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
export function createMysqlAdapter(context) {
|
||||
const {
|
||||
mkdir,
|
||||
dirname,
|
||||
sqliteSchema,
|
||||
mysqlSchema,
|
||||
optional,
|
||||
buildSeedOperations,
|
||||
stateFromRows,
|
||||
readSqliteRows,
|
||||
readMysqlRows,
|
||||
createRepository
|
||||
} = context;
|
||||
|
||||
async function createMysqlStore({ seed }) {
|
||||
const { default: mysql } = await import('mysql2/promise');
|
||||
const connectionUrl = process.env.DATABASE_URL;
|
||||
const database = process.env.MYSQL_DATABASE;
|
||||
|
||||
if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) {
|
||||
throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE');
|
||||
}
|
||||
|
||||
const pool = connectionUrl
|
||||
? mysql.createPool(connectionUrl)
|
||||
: mysql.createPool({
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10),
|
||||
charset: 'utf8mb4',
|
||||
timezone: 'Z',
|
||||
enableKeepAlive: true
|
||||
});
|
||||
|
||||
for (const statement of mysqlSchema) await pool.execute(statement);
|
||||
const mysqlColumnMigrations = [
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL",
|
||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL',
|
||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL',
|
||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE',
|
||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL',
|
||||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE',
|
||||
'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS native_place VARCHAR(160) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS birth_date VARCHAR(20) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS ethnicity VARCHAR(60) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL',
|
||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE',
|
||||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL',
|
||||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL',
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL',
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL',
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL',
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL',
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL',
|
||||
"ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'",
|
||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL',
|
||||
'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
||||
'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL',
|
||||
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
|
||||
];
|
||||
for (const statement of mysqlColumnMigrations) await pool.execute(statement);
|
||||
const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'");
|
||||
if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number');
|
||||
const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
|
||||
if (existing.length) {
|
||||
const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
for (const school of extension.schools) await connection.execute(
|
||||
'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
|
||||
[school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1]
|
||||
);
|
||||
for (const schoolClass of extension.classes) await connection.execute(
|
||||
'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)',
|
||||
[schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1]
|
||||
);
|
||||
await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'");
|
||||
for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute(
|
||||
`INSERT IGNORE INTO users (
|
||||
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
|
||||
) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`,
|
||||
[user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt]
|
||||
);
|
||||
for (const profile of extension.candidateProfiles) await connection.execute(
|
||||
`UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?)
|
||||
WHERE school = ? AND grade = ?`,
|
||||
[optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade]
|
||||
);
|
||||
const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1');
|
||||
if (!centerRows.length) for (const center of extension.testCenters) await connection.execute(
|
||||
'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt]
|
||||
);
|
||||
const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1');
|
||||
if (!ruleRows.length) for (const rule of extension.numberRules) {
|
||||
await connection.execute(
|
||||
'INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt]
|
||||
);
|
||||
for (const [index, segment] of rule.segments.entries()) await connection.execute(
|
||||
'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)]
|
||||
);
|
||||
}
|
||||
const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1');
|
||||
if (!workflowRows.length) for (const workflow of extension.workflows) {
|
||||
await connection.execute(
|
||||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt]
|
||||
);
|
||||
for (const [index, step] of workflow.steps.entries()) await connection.execute(
|
||||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)',
|
||||
[step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel]
|
||||
);
|
||||
}
|
||||
await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1');
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 3) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
for (const center of extension.testCenters) await connection.execute(
|
||||
`UPDATE test_centers SET
|
||||
code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?),
|
||||
manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?),
|
||||
gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?),
|
||||
status = COALESCE(status, 'active'), notes = COALESCE(notes, ?)
|
||||
WHERE id = ?`,
|
||||
[center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
|
||||
optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id]
|
||||
);
|
||||
await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''");
|
||||
const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1');
|
||||
if (!roomRows.length) for (const room of extension.testRooms) await connection.execute(
|
||||
`INSERT INTO test_rooms (
|
||||
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
|
||||
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)]
|
||||
);
|
||||
const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change');
|
||||
const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1");
|
||||
if (centerWorkflow && !centerWorkflowRows.length) {
|
||||
await connection.execute(
|
||||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt]
|
||||
);
|
||||
for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute(
|
||||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)',
|
||||
[step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel]
|
||||
);
|
||||
}
|
||||
await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1');
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 4) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
for (const user of extension.users.filter(item => item.role === 'candidate')) await connection.execute(
|
||||
`UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?),
|
||||
must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`,
|
||||
[optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id]
|
||||
);
|
||||
await connection.execute(`UPDATE users SET candidate_number = COALESCE(
|
||||
(SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1),
|
||||
CONCAT('CAND-', RIGHT(id, 10))
|
||||
) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`);
|
||||
for (const profile of extension.candidateProfiles) await connection.execute(
|
||||
`UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?),
|
||||
ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?),
|
||||
guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`,
|
||||
[optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
|
||||
optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id]
|
||||
);
|
||||
await connection.execute(`UPDATE registrations JOIN users ON users.id = registrations.user_id
|
||||
SET registrations.registration_number = users.candidate_number
|
||||
WHERE registrations.registration_number IS NULL OR registrations.registration_number = ''`);
|
||||
await connection.execute('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1');
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 5) {
|
||||
const extension = seed();
|
||||
const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch');
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1");
|
||||
if (batchWorkflow && !batchWorkflowRows.length) {
|
||||
await connection.execute(
|
||||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt]
|
||||
);
|
||||
for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute(
|
||||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)',
|
||||
[step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel]
|
||||
);
|
||||
}
|
||||
await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1');
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 6) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1');
|
||||
}
|
||||
}
|
||||
if (!existing.length) {
|
||||
const initialState = seed();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 6, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'");
|
||||
if (!centerCodeIndexes.length) {
|
||||
await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)');
|
||||
}
|
||||
const [candidateNumberIndexes] = await pool.execute("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'");
|
||||
if (!candidateNumberIndexes.length) {
|
||||
await pool.execute('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)');
|
||||
}
|
||||
|
||||
const transaction = async operations => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
for (const item of operations) await connection.execute(item.sql, item.params);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
const read = async () => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const state = stateFromRows(await readMysqlRows(connection));
|
||||
await connection.commit();
|
||||
return state;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
return createRepository({
|
||||
client: 'mysql',
|
||||
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
|
||||
read,
|
||||
transaction,
|
||||
close: async () => pool.end()
|
||||
});
|
||||
}
|
||||
|
||||
return createMysqlStore;
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
export const sqliteSchema = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_metadata (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
app_version INTEGER NOT NULL DEFAULT 1,
|
||||
self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organization (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
address TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schools (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
address TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS school_classes (
|
||||
id TEXT PRIMARY KEY,
|
||||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
grade TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
UNIQUE (school_id, name)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
candidate_number TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')),
|
||||
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candidate_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
gender TEXT,
|
||||
id_number TEXT NOT NULL UNIQUE,
|
||||
phone TEXT NOT NULL,
|
||||
email TEXT,
|
||||
school TEXT,
|
||||
grade TEXT,
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
address TEXT,
|
||||
emergency_contact TEXT,
|
||||
emergency_phone TEXT,
|
||||
native_place TEXT,
|
||||
birth_date TEXT,
|
||||
ethnicity TEXT,
|
||||
postal_code TEXT,
|
||||
guardian_name TEXT,
|
||||
guardian_phone TEXT,
|
||||
profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
review_note TEXT,
|
||||
reviewed_at TEXT,
|
||||
reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notices (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
|
||||
status TEXT NOT NULL CHECK (status IN ('draft', 'published')),
|
||||
publish_at TEXT,
|
||||
created_at TEXT,
|
||||
author TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exams (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
registration_start TEXT NOT NULL,
|
||||
registration_end TEXT NOT NULL,
|
||||
exam_start TEXT NOT NULL,
|
||||
exam_end TEXT NOT NULL,
|
||||
admit_download_start TEXT NOT NULL,
|
||||
admit_download_end TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exam_subjects (
|
||||
id TEXT PRIMARY KEY,
|
||||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
subject_date TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
fee REAL NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL,
|
||||
UNIQUE (exam_id, position)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')),
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
review_note TEXT,
|
||||
registration_number TEXT,
|
||||
number_rule_id TEXT,
|
||||
UNIQUE (user_id, exam_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_subjects (
|
||||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (registration_id, subject_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admit_cards (
|
||||
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
card_number TEXT NOT NULL UNIQUE,
|
||||
test_center TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
seat TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id TEXT PRIMARY KEY,
|
||||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||
score REAL NOT NULL CHECK (score >= 0 AND score <= 150),
|
||||
grade TEXT NOT NULL,
|
||||
published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)),
|
||||
updated_at TEXT,
|
||||
published_at TEXT,
|
||||
UNIQUE (registration_id, subject_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS test_centers (
|
||||
id TEXT PRIMARY KEY,
|
||||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
contact TEXT,
|
||||
manager_name TEXT,
|
||||
manager_phone TEXT,
|
||||
emergency_phone TEXT,
|
||||
gate_open_time TEXT,
|
||||
transport TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
notes TEXT,
|
||||
rooms TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (school_id, name)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS test_rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
building TEXT NOT NULL,
|
||||
floor TEXT,
|
||||
capacity INTEGER NOT NULL CHECK (capacity > 0),
|
||||
seat_plan TEXT,
|
||||
seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0),
|
||||
seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start),
|
||||
room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
notes TEXT,
|
||||
UNIQUE (center_id, code)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS center_change_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
|
||||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||
request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
contact TEXT,
|
||||
manager_name TEXT,
|
||||
manager_phone TEXT,
|
||||
emergency_phone TEXT,
|
||||
gate_open_time TEXT,
|
||||
transport TEXT,
|
||||
center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')),
|
||||
notes TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
review_note TEXT,
|
||||
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS center_change_rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE,
|
||||
room_id TEXT,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
building TEXT NOT NULL,
|
||||
floor TEXT,
|
||||
capacity INTEGER NOT NULL CHECK (capacity > 0),
|
||||
seat_plan TEXT,
|
||||
seat_start INTEGER NOT NULL DEFAULT 1,
|
||||
seat_end INTEGER NOT NULL,
|
||||
room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'inactive')),
|
||||
notes TEXT,
|
||||
UNIQUE (request_id, code)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS number_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
separator TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)),
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS number_rule_segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')),
|
||||
value TEXT,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (rule_id, position)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candidate_account_batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
review_note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candidate_account_batch_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE,
|
||||
class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT,
|
||||
position INTEGER NOT NULL,
|
||||
candidate_number TEXT UNIQUE,
|
||||
initial_password TEXT,
|
||||
user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT,
|
||||
UNIQUE (batch_id, position)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||||
id TEXT PRIMARY KEY,
|
||||
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (business_type, active)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')),
|
||||
UNIQUE (workflow_id, position)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT,
|
||||
business_type TEXT NOT NULL,
|
||||
business_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
current_step INTEGER NOT NULL DEFAULT 1,
|
||||
assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')),
|
||||
note TEXT,
|
||||
from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end);
|
||||
CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code);
|
||||
CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
|
||||
`;
|
||||
|
||||
export const mysqlSchema = [
|
||||
`CREATE TABLE IF NOT EXISTS schema_metadata (
|
||||
id TINYINT UNSIGNED NOT NULL,
|
||||
schema_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
app_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS organization (
|
||||
id TINYINT UNSIGNED NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
code VARCHAR(60) NOT NULL,
|
||||
phone VARCHAR(60) NOT NULL,
|
||||
address VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_organization_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS schools (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
address VARCHAR(255) NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_schools_name (name),
|
||||
UNIQUE KEY uq_schools_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS school_classes (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
school_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
grade VARCHAR(60) NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_classes_school_name (school_id, name),
|
||||
CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
candidate_number VARCHAR(120) NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin', 'candidate') NOT NULL,
|
||||
admin_level ENUM('super', 'school', 'class') NULL,
|
||||
school_id VARCHAR(64) NULL,
|
||||
class_id VARCHAR(64) NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_username (username),
|
||||
UNIQUE KEY uq_users_candidate_number (candidate_number),
|
||||
KEY idx_users_admin_scope (role, admin_level, school_id, class_id),
|
||||
CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS candidate_profiles (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
gender VARCHAR(20) NULL,
|
||||
id_number VARCHAR(60) NOT NULL,
|
||||
phone VARCHAR(60) NOT NULL,
|
||||
email VARCHAR(160) NULL,
|
||||
school VARCHAR(160) NULL,
|
||||
grade VARCHAR(100) NULL,
|
||||
school_id VARCHAR(64) NULL,
|
||||
class_id VARCHAR(64) NULL,
|
||||
address VARCHAR(255) NULL,
|
||||
emergency_contact VARCHAR(100) NULL,
|
||||
emergency_phone VARCHAR(60) NULL,
|
||||
native_place VARCHAR(160) NULL,
|
||||
birth_date VARCHAR(20) NULL,
|
||||
ethnicity VARCHAR(60) NULL,
|
||||
postal_code VARCHAR(20) NULL,
|
||||
guardian_name VARCHAR(100) NULL,
|
||||
guardian_phone VARCHAR(60) NULL,
|
||||
profile_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
review_note VARCHAR(500) NULL,
|
||||
reviewed_at VARCHAR(35) NULL,
|
||||
reviewer_id VARCHAR(64) NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_profiles_user (user_id),
|
||||
UNIQUE KEY uq_profiles_id_number (id_number),
|
||||
KEY idx_profiles_status (status),
|
||||
CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS notices (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(240) NOT NULL,
|
||||
summary VARCHAR(500) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category VARCHAR(60) NOT NULL,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status ENUM('draft', 'published') NOT NULL,
|
||||
publish_at VARCHAR(35) NULL,
|
||||
created_at VARCHAR(35) NULL,
|
||||
author VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_notices_status_publish (status, publish_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS exams (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
code VARCHAR(60) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
registration_start VARCHAR(35) NOT NULL,
|
||||
registration_end VARCHAR(35) NOT NULL,
|
||||
exam_start VARCHAR(35) NOT NULL,
|
||||
exam_end VARCHAR(35) NOT NULL,
|
||||
admit_download_start VARCHAR(35) NOT NULL,
|
||||
admit_download_end VARCHAR(35) NOT NULL,
|
||||
location VARCHAR(200) NOT NULL,
|
||||
status ENUM('draft', 'published', 'closed') NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_exams_code (code),
|
||||
KEY idx_exams_status_registration (status, registration_start, registration_end)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS exam_subjects (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
subject_date VARCHAR(35) NOT NULL,
|
||||
start_time VARCHAR(20) NOT NULL,
|
||||
end_time VARCHAR(20) NOT NULL,
|
||||
fee DOUBLE NOT NULL DEFAULT 0,
|
||||
position INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_subjects_exam_position (exam_id, position),
|
||||
KEY idx_subjects_exam (exam_id, position),
|
||||
CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS registrations (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
reviewed_at VARCHAR(35) NULL,
|
||||
review_note VARCHAR(500) NULL,
|
||||
registration_number VARCHAR(120) NULL,
|
||||
number_rule_id VARCHAR(64) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_registrations_user_exam (user_id, exam_id),
|
||||
KEY idx_registrations_status (status),
|
||||
KEY idx_registrations_exam (exam_id),
|
||||
CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS registration_subjects (
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
subject_id VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (registration_id, subject_id),
|
||||
CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admit_cards (
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
card_number VARCHAR(100) NOT NULL,
|
||||
test_center VARCHAR(200) NOT NULL,
|
||||
room VARCHAR(100) NOT NULL,
|
||||
seat VARCHAR(30) NOT NULL,
|
||||
generated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (registration_id),
|
||||
UNIQUE KEY uq_admit_cards_number (card_number),
|
||||
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS results (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
subject_id VARCHAR(64) NOT NULL,
|
||||
score DOUBLE NOT NULL,
|
||||
grade VARCHAR(20) NOT NULL,
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at VARCHAR(35) NULL,
|
||||
published_at VARCHAR(35) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_results_registration_subject (registration_id, subject_id),
|
||||
KEY idx_results_registration (registration_id, published),
|
||||
CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150),
|
||||
CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS test_centers (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
school_id VARCHAR(64) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
address VARCHAR(255) NOT NULL,
|
||||
contact VARCHAR(100) NULL,
|
||||
manager_name VARCHAR(100) NULL,
|
||||
manager_phone VARCHAR(60) NULL,
|
||||
emergency_phone VARCHAR(60) NULL,
|
||||
gate_open_time VARCHAR(40) NULL,
|
||||
transport VARCHAR(500) NULL,
|
||||
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
|
||||
notes VARCHAR(1000) NULL,
|
||||
rooms TEXT NOT NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_centers_code (code),
|
||||
UNIQUE KEY uq_centers_school_name (school_id, name),
|
||||
CONSTRAINT fk_centers_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS test_rooms (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
center_id VARCHAR(64) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
building VARCHAR(120) NOT NULL,
|
||||
floor VARCHAR(40) NULL,
|
||||
capacity INT UNSIGNED NOT NULL,
|
||||
seat_plan VARCHAR(500) NULL,
|
||||
seat_start INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
seat_end INT UNSIGNED NOT NULL,
|
||||
room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL,
|
||||
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
|
||||
notes VARCHAR(500) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_rooms_center_code (center_id, code),
|
||||
KEY idx_rooms_center (center_id, status, code),
|
||||
CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS center_change_requests (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
center_id VARCHAR(64) NULL,
|
||||
school_id VARCHAR(64) NOT NULL,
|
||||
request_type ENUM('create', 'update') NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
address VARCHAR(255) NOT NULL,
|
||||
contact VARCHAR(100) NULL,
|
||||
manager_name VARCHAR(100) NULL,
|
||||
manager_phone VARCHAR(60) NULL,
|
||||
emergency_phone VARCHAR(60) NULL,
|
||||
gate_open_time VARCHAR(40) NULL,
|
||||
transport VARCHAR(500) NULL,
|
||||
center_status ENUM('active', 'inactive') NOT NULL,
|
||||
notes VARCHAR(1000) NULL,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
review_note VARCHAR(500) NULL,
|
||||
requested_by VARCHAR(64) NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
reviewed_at VARCHAR(35) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_center_changes_school (school_id, status, created_at),
|
||||
CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_center_change_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS center_change_rooms (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
request_id VARCHAR(64) NOT NULL,
|
||||
room_id VARCHAR(64) NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
building VARCHAR(120) NOT NULL,
|
||||
floor VARCHAR(40) NULL,
|
||||
capacity INT UNSIGNED NOT NULL,
|
||||
seat_plan VARCHAR(500) NULL,
|
||||
seat_start INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
seat_end INT UNSIGNED NOT NULL,
|
||||
room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL,
|
||||
status ENUM('active', 'inactive') NOT NULL,
|
||||
notes VARCHAR(500) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_center_change_rooms_code (request_id, code),
|
||||
CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS number_rules (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
separator VARCHAR(10) NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by VARCHAR(64) NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS number_rule_segments (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
rule_id VARCHAR(64) NOT NULL,
|
||||
position INT UNSIGNED NOT NULL,
|
||||
type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL,
|
||||
value VARCHAR(60) NULL,
|
||||
width INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_rule_segments_position (rule_id, position),
|
||||
CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS candidate_account_batches (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
school_id VARCHAR(64) NOT NULL,
|
||||
requested_by VARCHAR(64) NULL,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
review_note VARCHAR(500) NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
reviewed_at VARCHAR(35) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_account_batches_school (school_id, status, created_at),
|
||||
CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_batches_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS candidate_account_batch_items (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
batch_id VARCHAR(64) NOT NULL,
|
||||
class_id VARCHAR(64) NOT NULL,
|
||||
position INT UNSIGNED NOT NULL,
|
||||
candidate_number VARCHAR(120) NULL,
|
||||
initial_password VARCHAR(120) NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
created_at VARCHAR(35) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_account_batch_position (batch_id, position),
|
||||
UNIQUE KEY uq_account_batch_number (candidate_number),
|
||||
UNIQUE KEY uq_account_batch_user (user_id),
|
||||
KEY idx_account_batch_items (batch_id, class_id, position),
|
||||
CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id),
|
||||
CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_by VARCHAR(64) NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_workflow_type_active (business_type, active),
|
||||
CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
workflow_id VARCHAR(64) NOT NULL,
|
||||
position INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
admin_level ENUM('school', 'super') NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_workflow_steps_position (workflow_id, position),
|
||||
CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS workflow_instances (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
workflow_id VARCHAR(64) NOT NULL,
|
||||
business_type VARCHAR(40) NOT NULL,
|
||||
business_id VARCHAR(64) NOT NULL,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
current_step INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
assignee_id VARCHAR(64) NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
completed_at VARCHAR(35) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_workflow_inbox (status, assignee_id, business_type),
|
||||
CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id),
|
||||
CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS workflow_actions (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
instance_id VARCHAR(64) NOT NULL,
|
||||
actor_id VARCHAR(64) NULL,
|
||||
action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL,
|
||||
note VARCHAR(500) NULL,
|
||||
from_assignee_id VARCHAR(64) NULL,
|
||||
to_assignee_id VARCHAR(64) NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_workflow_actions_instance (instance_id, created_at),
|
||||
CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
actor_id VARCHAR(64) NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
detail VARCHAR(1000) NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_audit_created (created_at),
|
||||
CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`
|
||||
];
|
||||
@@ -0,0 +1,288 @@
|
||||
export function createSqliteAdapter(context) {
|
||||
const {
|
||||
mkdir,
|
||||
dirname,
|
||||
sqliteSchema,
|
||||
mysqlSchema,
|
||||
optional,
|
||||
buildSeedOperations,
|
||||
stateFromRows,
|
||||
readSqliteRows,
|
||||
readMysqlRows,
|
||||
createRepository
|
||||
} = context;
|
||||
|
||||
async function createSqliteStore({ path, seed }) {
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
|
||||
const connection = new DatabaseSync(path, { timeout: 5000 });
|
||||
connection.exec('PRAGMA journal_mode = WAL;');
|
||||
connection.exec('PRAGMA synchronous = NORMAL;');
|
||||
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
const ensureColumns = (table, columns) => {
|
||||
if (!tableExists(table)) return;
|
||||
const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name));
|
||||
for (const [name, definition] of columns) {
|
||||
if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
};
|
||||
ensureColumns('users', [
|
||||
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
|
||||
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0']
|
||||
]);
|
||||
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
|
||||
ensureColumns('candidate_profiles', [
|
||||
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
|
||||
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0']
|
||||
]);
|
||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||||
ensureColumns('test_centers', [
|
||||
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
||||
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT']
|
||||
]);
|
||||
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
|
||||
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
|
||||
if (tableExists('workflow_definitions')) {
|
||||
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
|
||||
if (!definitionSql.includes('candidate_account_batch')) {
|
||||
connection.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE workflow_definitions_v5 (
|
||||
id TEXT PRIMARY KEY,
|
||||
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (business_type, active)
|
||||
) STRICT;
|
||||
INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at)
|
||||
SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions;
|
||||
DROP TABLE workflow_definitions;
|
||||
ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
if (tableExists('registrations')) {
|
||||
const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || '';
|
||||
if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) {
|
||||
connection.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE registrations_v4 (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')),
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
review_note TEXT,
|
||||
registration_number TEXT,
|
||||
number_rule_id TEXT,
|
||||
UNIQUE (user_id, exam_id)
|
||||
) STRICT;
|
||||
INSERT INTO registrations_v4 (
|
||||
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id
|
||||
) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations;
|
||||
DROP TABLE registrations;
|
||||
ALTER TABLE registrations_v4 RENAME TO registrations;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
connection.exec(sqliteSchema);
|
||||
connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)');
|
||||
|
||||
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const school of extension.schools) connection.prepare(
|
||||
'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1);
|
||||
for (const schoolClass of extension.classes) connection.prepare(
|
||||
'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1);
|
||||
connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run();
|
||||
for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare(
|
||||
`INSERT OR IGNORE INTO users (
|
||||
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
|
||||
) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`
|
||||
).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt);
|
||||
for (const profile of extension.candidateProfiles) connection.prepare(
|
||||
`UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?)
|
||||
WHERE school = ? AND grade = ?`
|
||||
).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade);
|
||||
if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) {
|
||||
for (const center of extension.testCenters) connection.prepare(
|
||||
'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt);
|
||||
}
|
||||
if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) {
|
||||
for (const rule of extension.numberRules) {
|
||||
connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt);
|
||||
rule.segments.forEach((segment, index) => connection.prepare(
|
||||
'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)));
|
||||
}
|
||||
}
|
||||
if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) {
|
||||
for (const workflow of extension.workflows) {
|
||||
connection.prepare(
|
||||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt);
|
||||
workflow.steps.forEach((step, index) => connection.prepare(
|
||||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel));
|
||||
}
|
||||
}
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run();
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
connection.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 3) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const center of extension.testCenters) connection.prepare(
|
||||
`UPDATE test_centers SET
|
||||
code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?),
|
||||
manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?),
|
||||
gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?),
|
||||
status = COALESCE(status, 'active'), notes = COALESCE(notes, ?)
|
||||
WHERE id = ?`
|
||||
).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
|
||||
optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id);
|
||||
connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run();
|
||||
if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) {
|
||||
for (const room of extension.testRooms) connection.prepare(
|
||||
`INSERT INTO test_rooms (
|
||||
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
|
||||
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes));
|
||||
}
|
||||
const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change');
|
||||
if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) {
|
||||
connection.prepare(
|
||||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt);
|
||||
for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare(
|
||||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
|
||||
}
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run();
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
connection.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 4) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare(
|
||||
`UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?),
|
||||
must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`
|
||||
).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id);
|
||||
connection.prepare(`UPDATE users SET candidate_number = COALESCE(
|
||||
(SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1),
|
||||
'CAND-' || substr(id, -10)
|
||||
) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run();
|
||||
for (const profile of extension.candidateProfiles) connection.prepare(
|
||||
`UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?),
|
||||
ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?),
|
||||
guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`
|
||||
).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
|
||||
optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id);
|
||||
connection.prepare(`UPDATE registrations SET registration_number = (
|
||||
SELECT candidate_number FROM users WHERE users.id = registrations.user_id
|
||||
) WHERE registration_number IS NULL OR registration_number = ''`).run();
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1').run();
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
connection.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 5) {
|
||||
const extension = seed();
|
||||
const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch');
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) {
|
||||
connection.prepare(
|
||||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt);
|
||||
for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare(
|
||||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
|
||||
}
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run();
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
connection.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run();
|
||||
}
|
||||
|
||||
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||||
const initialState = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 6, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
connection.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const transaction = async operations => {
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const item of operations) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return createRepository({
|
||||
client: 'sqlite',
|
||||
location: path,
|
||||
read: async () => stateFromRows(readSqliteRows(connection)),
|
||||
transaction,
|
||||
close: async () => connection.close()
|
||||
});
|
||||
}
|
||||
|
||||
return createSqliteStore;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export function sendJson(response, status, payload, headers = {}) {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function sendError(response, status, message, details) {
|
||||
sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) });
|
||||
}
|
||||
|
||||
export async function readJson(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 });
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) return {};
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
throw Object.assign(new Error('请求数据格式不正确'), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 });
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 });
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export function sendWorkbook(response, buffer, filename) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
'Content-Length': buffer.length,
|
||||
'Cache-Control': 'no-store'
|
||||
});
|
||||
response.end(buffer);
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
export function createAdminRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
readDb,
|
||||
sendJson,
|
||||
sendError,
|
||||
readJson,
|
||||
readBodyBuffer,
|
||||
sendWorkbook,
|
||||
currentUser,
|
||||
safeUser,
|
||||
requireUser,
|
||||
hasPermission,
|
||||
requirePermission,
|
||||
profileInScope,
|
||||
registrationInScope,
|
||||
adminScopeLabel,
|
||||
adminsForStep,
|
||||
activeWorkflow,
|
||||
createWorkflowSubmission,
|
||||
workflowView,
|
||||
pendingWorkflow,
|
||||
candidateSequence,
|
||||
generateCandidateNumber,
|
||||
cleanText,
|
||||
centerScopeProfile,
|
||||
workflowScopeProfile,
|
||||
candidateAccountBatchView,
|
||||
centerChangeView,
|
||||
parseCenterChange,
|
||||
maskId,
|
||||
publicExam,
|
||||
examRegistrationView,
|
||||
logAction,
|
||||
excelResourceNames,
|
||||
excelRowsForResource,
|
||||
importExcelResource,
|
||||
admitCardHtml,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
randomBytes,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames,
|
||||
permissionsByLevel
|
||||
} = context;
|
||||
|
||||
async function handleAdmin(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admin/')) return false;
|
||||
const user = await requireUser(request, response, 'admin');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/admin/context') {
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
admin: safeUser(user),
|
||||
adminLevelName: adminLevelNames[user.adminLevel || 'super'],
|
||||
permissions: permissionsByLevel[user.adminLevel || 'super'],
|
||||
scopeLabel: adminScopeLabel(db, user),
|
||||
schools: db.schools,
|
||||
classes: db.classes
|
||||
});
|
||||
}
|
||||
|
||||
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|centers|results)$/);
|
||||
if (excelMatch && request.method === 'GET') {
|
||||
const resource = excelMatch[1];
|
||||
if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在');
|
||||
if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据');
|
||||
if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场');
|
||||
if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料');
|
||||
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
|
||||
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||||
const template = requestUrl.searchParams.get('template') === '1';
|
||||
const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
||||
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
|
||||
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
||||
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
if (excelMatch && request.method === 'POST') {
|
||||
const resource = excelMatch[1];
|
||||
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出');
|
||||
const rows = await parseWorkbook(resource, await readBodyBuffer(request));
|
||||
const result = await importExcelResource(db, user, resource, rows);
|
||||
return sendJson(response, 200, { ok: true, ...result });
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/school-organization' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织');
|
||||
const school = db.schools.find(item => item.id === user.schoolId);
|
||||
const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({
|
||||
...item,
|
||||
candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length,
|
||||
admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active }))
|
||||
}));
|
||||
return sendJson(response, 200, { ok: true, school, classes });
|
||||
}
|
||||
if (pathname === '/api/admin/classes' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
|
||||
const body = await readJson(request);
|
||||
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
|
||||
const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false };
|
||||
await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`));
|
||||
return sendJson(response, 201, { ok: true, schoolClass });
|
||||
}
|
||||
const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/);
|
||||
if (classMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级');
|
||||
const body = await readJson(request);
|
||||
const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 404, '班级不存在');
|
||||
const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级');
|
||||
Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) });
|
||||
await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`));
|
||||
return sendJson(response, 200, { ok: true, schoolClass });
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/admins' && request.method === 'GET') {
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员');
|
||||
const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).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, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
||||
}
|
||||
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
||||
const body = await readJson(request);
|
||||
const username = cleanText(body.username, 50);
|
||||
const password = String(body.password || '');
|
||||
const displayName = cleanText(body.displayName, 50);
|
||||
const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20);
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员');
|
||||
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 : user.adminLevel === 'school' ? user.schoolId : 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) });
|
||||
}
|
||||
const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/);
|
||||
if (adminMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员');
|
||||
const body = await readJson(request);
|
||||
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId);
|
||||
if (!target) return sendError(response, 404, '班级管理员不存在');
|
||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 400, '请选择本校有效班级');
|
||||
const password = String(body.password || '');
|
||||
if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位');
|
||||
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) });
|
||||
if (password) target.passwordHash = hashPassword(password);
|
||||
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`));
|
||||
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
|
||||
}
|
||||
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const enabled = Boolean(body.enabled);
|
||||
const log = logAction(db, user, enabled ? '开启自主注册' : '关闭自主注册', enabled ? '考生可从公开入口申请报名号' : '仅允许使用学校下发的报名号登录');
|
||||
await database.updateRegistrationSetting(enabled, log);
|
||||
return sendJson(response, 200, { ok: true, enabled });
|
||||
}
|
||||
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'GET') {
|
||||
if (!requirePermission(user, response, 'candidates.write')) return true;
|
||||
if (!['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '只有校级管理员可以申领批量报名号');
|
||||
const batches = db.candidateAccountBatches
|
||||
.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
.map(item => candidateAccountBatchView(db, item));
|
||||
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
|
||||
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) });
|
||||
}
|
||||
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
|
||||
const body = await readJson(request);
|
||||
const requestedQuotas = Array.isArray(body.quotas) ? body.quotas : [];
|
||||
const quotas = requestedQuotas.map(item => ({ classId: cleanText(item.classId, 64), count: Number(item.count) })).filter(item => item.count > 0);
|
||||
if (!quotas.length) return sendError(response, 400, '请至少为一个班级填写申领数量');
|
||||
if (new Set(quotas.map(item => item.classId)).size !== quotas.length) return sendError(response, 400, '同一班级只能填写一次申领数量');
|
||||
if (quotas.some(item => !Number.isInteger(item.count) || item.count < 1 || item.count > 200)) return sendError(response, 400, '每个班级一次可申领 1—200 个报名号');
|
||||
if (quotas.some(item => !db.classes.some(schoolClass => schoolClass.id === item.classId && schoolClass.schoolId === user.schoolId && schoolClass.active))) return sendError(response, 400, '只能为本校有效班级申领报名号');
|
||||
const totalCount = quotas.reduce((sum, item) => sum + item.count, 0);
|
||||
if (totalCount > 500) return sendError(response, 400, '单个批次最多申领 500 个报名号');
|
||||
const batch = { id: uid('account_batch'), schoolId: user.schoolId, requestedBy: user.id, status: 'pending', reviewNote: '', createdAt: nowIso(), reviewedAt: null };
|
||||
const items = [];
|
||||
let position = 1;
|
||||
for (const quota of quotas) for (let index = 0; index < quota.count; index += 1) {
|
||||
items.push({ id: uid('account_batch_item'), batchId: batch.id, classId: quota.classId, position, candidateNumber: '', initialPassword: '', userId: null, createdAt: null });
|
||||
position += 1;
|
||||
}
|
||||
const { instance, action } = createWorkflowSubmission(db, 'candidate_account_batch', batch.id, centerScopeProfile(db, user.schoolId), user.id);
|
||||
const quotaSummary = quotas.map(item => `${db.classes.find(entry => entry.id === item.classId)?.name} ${item.count} 人`).join(';');
|
||||
const log = logAction(db, user, '提交批量报名号申领', `${totalCount} 个账户 · ${quotaSummary}`);
|
||||
await database.createCandidateAccountBatch(batch, items, instance, action, log);
|
||||
const fresh = await readDb();
|
||||
return sendJson(response, 202, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) });
|
||||
}
|
||||
const accountBatchMatch = pathname.match(/^\/api\/admin\/candidate-account-batches\/([^/]+)$/);
|
||||
if (accountBatchMatch && request.method === 'PATCH') {
|
||||
if (!requirePermission(user, response, 'candidates.write')) return true;
|
||||
const body = await readJson(request);
|
||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效');
|
||||
const batch = db.candidateAccountBatches.find(item => item.id === accountBatchMatch[1] && item.status === 'pending');
|
||||
if (!batch) return sendError(response, 404, '待审批的批量报名号申请不存在');
|
||||
const instance = pendingWorkflow(db, 'candidate_account_batch', batch.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' ? '审批批量报名号申领' : '退回批量报名号申领', `${db.schools.find(item => item.id === batch.schoolId)?.name} · ${note || '无备注'}`);
|
||||
if (body.status === 'rejected') {
|
||||
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||
batch.status = 'rejected'; batch.reviewNote = note; batch.reviewedAt = nowIso();
|
||||
await database.processWorkflow(instance, action, batch, 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, batch.schoolId))[0];
|
||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||
batch.reviewNote = note;
|
||||
await database.processWorkflow(instance, action, batch, log);
|
||||
} else {
|
||||
const batchItems = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position);
|
||||
if (!batchItems.length || batchItems.some(item => item.userId || item.candidateNumber)) return sendError(response, 409, '批次明细异常或已经生成过账号');
|
||||
const generationDb = { ...db, users: [...db.users] };
|
||||
const users = [];
|
||||
const profiles = [];
|
||||
for (const [index, item] of batchItems.entries()) {
|
||||
const schoolClass = db.classes.find(entry => entry.id === item.classId && entry.schoolId === batch.schoolId);
|
||||
if (!schoolClass) return sendError(response, 409, '批次包含无效班级,无法生成账号');
|
||||
const generated = generateCandidateNumber(generationDb, { schoolId: batch.schoolId, classId: item.classId, gender: '' });
|
||||
const userId = uid('usr');
|
||||
const initialPassword = `Init-${randomBytes(6).toString('base64url')}`;
|
||||
const displayName = `待补录考生 ${String(index + 1).padStart(3, '0')}`;
|
||||
const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName, schoolId: batch.schoolId, classId: item.classId, active: true, mustChangePassword: true, createdAt: nowIso() };
|
||||
const profile = { id: uid('profile'), userId, name: displayName, gender: '', idNumber: `PENDING-${userId}`, phone: '', email: '', school: db.schools.find(entry => entry.id === batch.schoolId)?.name || '', grade: schoolClass.name, schoolId: batch.schoolId, classId: item.classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() };
|
||||
item.candidateNumber = generated.number; item.initialPassword = initialPassword; item.userId = userId; item.createdAt = nowIso();
|
||||
users.push(candidateUser); profiles.push(profile); generationDb.users.push(candidateUser);
|
||||
}
|
||||
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||
batch.status = 'approved'; batch.reviewNote = note; batch.reviewedAt = nowIso();
|
||||
await database.completeCandidateAccountBatch(batch, batchItems, users, profiles, instance, action, log);
|
||||
}
|
||||
const fresh = await readDb();
|
||||
return sendJson(response, 200, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) });
|
||||
}
|
||||
|
||||
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) preview = generateCandidateNumber(db, previewProfile).number;
|
||||
return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview });
|
||||
}
|
||||
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/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|candidate_account_batch)$/);
|
||||
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, '流程至少需要一个校级或超级管理员审批步骤');
|
||||
if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') 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;
|
||||
const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.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,
|
||||
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
|
||||
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0
|
||||
};
|
||||
});
|
||||
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, '流程不存在');
|
||||
if (instance.businessType === 'candidate_account_batch' && db.candidateAccountBatchItems.some(item => item.batchId === instance.businessId && item.userId)) return sendError(response, 409, '已生成账号的批次不可重新打开,避免重复建号');
|
||||
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)
|
||||
: instance.businessType === 'center_change'
|
||||
? db.centerChangeRequests.find(item => item.id === instance.businessId)
|
||||
: db.candidateAccountBatches.find(item => item.id === instance.businessId);
|
||||
business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null;
|
||||
const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`);
|
||||
await database.processWorkflow(instance, action, business, log);
|
||||
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/admin/dashboard') {
|
||||
const profiles = db.candidateProfiles.filter(item => profileInScope(user, item));
|
||||
const registrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
const visibleFlows = db.workflowInstances.filter(instance => {
|
||||
if (user.adminLevel === 'super') return true;
|
||||
if (user.adminLevel === 'class') return false;
|
||||
const business = workflowScopeProfile(db, instance);
|
||||
return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending');
|
||||
});
|
||||
const pendingCandidates = profiles.filter(item => item.status === 'pending').length;
|
||||
const pendingRegistrations = registrations.filter(item => item.status === 'pending').length;
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
admin: safeUser(user),
|
||||
scopeLabel: adminScopeLabel(db, user),
|
||||
permissions: permissionsByLevel[user.adminLevel || 'super'],
|
||||
metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingFlows: visibleFlows.filter(item => item.status === 'pending').length, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length },
|
||||
logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8)
|
||||
});
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/candidates') {
|
||||
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];
|
||||
const account = db.users.find(item => item.id === profile.userId);
|
||||
return { ...profile, idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber), username: account?.username, candidateNumber: account?.candidateNumber || '', mustChangePassword: Boolean(account?.mustChangePassword), workflow: workflowView(db, instance) };
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) });
|
||||
}
|
||||
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && candidateMatch) {
|
||||
if (!requirePermission(user, response, 'candidates.review')) return true;
|
||||
const body = await readJson(request);
|
||||
const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]);
|
||||
if (!profile) return sendError(response, 404, '考生资料不存在');
|
||||
if (!profileInScope(user, profile) && user.adminLevel !== 'super') return sendError(response, 403, '该考生不在你的数据范围内');
|
||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||
const instance = pendingWorkflow(db, 'profile_change', profile.id);
|
||||
if (!instance) return sendError(response, 409, '当前没有待处理的考生信息流程');
|
||||
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||
const note = cleanText(body.reviewNote, 300);
|
||||
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||
if (body.status === 'rejected') {
|
||||
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||
profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
|
||||
} else if (instance.currentStep < workflow.steps.length) {
|
||||
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
|
||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||
profile.status = 'pending'; profile.reviewNote = note;
|
||||
} else {
|
||||
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||
profile.status = 'approved'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id;
|
||||
}
|
||||
const log = logAction(db, user, body.status === 'approved' ? '处理考生信息流程' : '退回考生信息', `${profile.name}:${note || '无备注'}`);
|
||||
await database.processWorkflow(instance, action, profile, log);
|
||||
return sendJson(response, 200, { ok: true, profile, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/registrations') {
|
||||
if (!requirePermission(user, response, 'registrations.read')) return true;
|
||||
const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).map(registration => {
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, registrations });
|
||||
}
|
||||
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && registrationMatch) {
|
||||
if (!requirePermission(user, response, 'registrations.review')) return true;
|
||||
const body = await readJson(request);
|
||||
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
|
||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||
if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内');
|
||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
const instance = pendingWorkflow(db, 'registration_review', registration.id);
|
||||
if (!instance) return sendError(response, 409, '当前没有待处理的报名审核流程');
|
||||
const workflow = db.workflows.find(item => item.id === instance.workflowId);
|
||||
const step = workflow?.steps.find(item => item.position === instance.currentStep);
|
||||
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||
const note = cleanText(body.reviewNote, 300);
|
||||
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||
if (body.status === 'rejected') {
|
||||
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||
registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso();
|
||||
} else if (instance.currentStep < workflow.steps.length) {
|
||||
const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1);
|
||||
const nextAssignee = adminsForStep(db, nextStep.adminLevel, profile)[0];
|
||||
if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`);
|
||||
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||
registration.status = 'pending'; registration.reviewNote = note;
|
||||
} else {
|
||||
const account = db.users.find(item => item.id === registration.userId);
|
||||
if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配');
|
||||
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||
registration.status = 'approved'; registration.paymentStatus = 'paid'; registration.reviewNote = note; registration.reviewedAt = nowIso();
|
||||
registration.registrationNumber = account.candidateNumber;
|
||||
registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId;
|
||||
}
|
||||
const log = logAction(db, user, body.status === 'approved' ? '处理报名审核流程' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`);
|
||||
await database.processWorkflow(instance, action, registration, log);
|
||||
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||
}
|
||||
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
||||
if (request.method === 'POST' && admitMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
|
||||
if (!registration.admitCard) {
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0');
|
||||
registration.admitCard = {
|
||||
number: `${exam.code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-12) || String(new Date().getFullYear())}-${sequence}`,
|
||||
testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80),
|
||||
room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`,
|
||||
seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'),
|
||||
generatedAt: nowIso()
|
||||
};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
const log = logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`);
|
||||
await database.createAdmitCard(registration.id, registration.admitCard, log);
|
||||
}
|
||||
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/exams') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/exams') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const name = cleanText(body.name, 100);
|
||||
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
|
||||
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/);
|
||||
const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
|
||||
if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目');
|
||||
const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
|
||||
const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`);
|
||||
await database.createExam(exam, log);
|
||||
return sendJson(response, 201, { ok: true, exam });
|
||||
}
|
||||
const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && examMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === examMatch[1]);
|
||||
if (!exam) return sendError(response, 404, '考试不存在');
|
||||
const originalStatus = exam.status;
|
||||
const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'];
|
||||
const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null;
|
||||
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
|
||||
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
|
||||
detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); });
|
||||
let replaceSubjects = false;
|
||||
if (body.subjects != null) {
|
||||
if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目');
|
||||
const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/);
|
||||
const names = subjectNames.map(item => cleanText(typeof item === 'string' ? item : item.name, 30)).filter(Boolean);
|
||||
if (!names.length) return sendError(response, 400, '请至少添加一个考试科目');
|
||||
exam.subjects = names.map((name, index) => ({ id: uid('sub'), name, date: String(exam.examStart).slice(0, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
|
||||
replaceSubjects = true;
|
||||
}
|
||||
if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
|
||||
if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布');
|
||||
const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`);
|
||||
await database.updateExam(exam, log, replaceSubjects);
|
||||
return sendJson(response, 200, { ok: true, exam });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/notices') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const title = cleanText(body.title, 120);
|
||||
const content = cleanText(body.content, 5000);
|
||||
if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空');
|
||||
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
|
||||
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
||||
await database.createNotice(notice, log);
|
||||
return sendJson(response, 201, { ok: true, notice });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && noticeMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const notice = db.notices.find(item => item.id === noticeMatch[1]);
|
||||
if (!notice) return sendError(response, 404, '通知不存在');
|
||||
['title', 'summary', 'content', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], field === 'content' ? 5000 : 260); });
|
||||
if (body.pinned != null) notice.pinned = Boolean(body.pinned);
|
||||
if (body.status && ['draft', 'published'].includes(body.status)) {
|
||||
notice.status = body.status;
|
||||
if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso();
|
||||
}
|
||||
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||
await database.updateNotice(notice, log);
|
||||
return sendJson(response, 200, { ok: true, notice });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||
if (!requirePermission(user, response, 'results.read')) return true;
|
||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, results, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved');
|
||||
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目');
|
||||
const score = Number(body.score);
|
||||
if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间');
|
||||
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId);
|
||||
const isNew = !result;
|
||||
if (!result) {
|
||||
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
|
||||
db.results.push(result);
|
||||
}
|
||||
Object.assign(result, { score, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
const subject = exam.subjects.find(item => item.id === body.subjectId);
|
||||
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
|
||||
await database.saveResult(result, isNew, log);
|
||||
return sendJson(response, 200, { ok: true, result });
|
||||
}
|
||||
return sendError(response, 404, '管理功能接口不存在');
|
||||
}
|
||||
|
||||
return handleAdmin;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export function createAuthRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
readDb,
|
||||
sendJson,
|
||||
sendError,
|
||||
readJson,
|
||||
readBodyBuffer,
|
||||
sendWorkbook,
|
||||
currentUser,
|
||||
parseCookies,
|
||||
safeUser,
|
||||
requireUser,
|
||||
hasPermission,
|
||||
requirePermission,
|
||||
profileInScope,
|
||||
registrationInScope,
|
||||
adminScopeLabel,
|
||||
adminsForStep,
|
||||
activeWorkflow,
|
||||
createWorkflowSubmission,
|
||||
workflowView,
|
||||
pendingWorkflow,
|
||||
candidateSequence,
|
||||
generateCandidateNumber,
|
||||
cleanText,
|
||||
centerScopeProfile,
|
||||
workflowScopeProfile,
|
||||
candidateAccountBatchView,
|
||||
centerChangeView,
|
||||
parseCenterChange,
|
||||
maskId,
|
||||
publicExam,
|
||||
examRegistrationView,
|
||||
logAction,
|
||||
excelResourceNames,
|
||||
excelRowsForResource,
|
||||
importExcelResource,
|
||||
admitCardHtml,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
randomBytes,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames,
|
||||
permissionsByLevel
|
||||
} = context;
|
||||
|
||||
async function handleAuth(request, response, pathname) {
|
||||
if (request.method === 'GET' && pathname === '/api/auth/me') {
|
||||
const user = await currentUser(request);
|
||||
if (!user) return sendJson(response, 200, { ok: true, user: null });
|
||||
const db = await readDb();
|
||||
const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null;
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user), profile, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/register') {
|
||||
const body = await readJson(request);
|
||||
const password = String(body.password || '');
|
||||
const name = cleanText(body.name, 30);
|
||||
const gender = cleanText(body.gender, 10);
|
||||
if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别');
|
||||
if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位');
|
||||
const db = await readDb();
|
||||
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
|
||||
const schoolId = cleanText(body.schoolId, 64);
|
||||
const classId = cleanText(body.classId, 64);
|
||||
const school = db.schools.find(item => item.id === schoolId && item.active);
|
||||
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
|
||||
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||
const draftProfile = { schoolId, classId, gender };
|
||||
const generated = generateCandidateNumber(db, draftProfile);
|
||||
const userId = uid('usr');
|
||||
const user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() };
|
||||
const profile = { id: uid('profile'), userId, name, idNumber: `PENDING-${userId}`, phone: '', gender, email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() };
|
||||
await database.createCandidate(user, profile, null, null);
|
||||
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login') {
|
||||
const body = await readJson(request);
|
||||
const db = await readDb();
|
||||
const account = cleanText(body.username, 120).toLowerCase();
|
||||
const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account);
|
||||
if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||
const token = randomBytes(32).toString('hex');
|
||||
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/change-password') {
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
const body = await readJson(request);
|
||||
const currentPassword = String(body.currentPassword || '');
|
||||
const newPassword = String(body.newPassword || '');
|
||||
if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位');
|
||||
if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与初始密码相同');
|
||||
user.passwordHash = hashPassword(newPassword);
|
||||
user.mustChangePassword = false;
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '修改登录密码', user.role === 'candidate' ? `报名号 ${user.candidateNumber}` : user.username);
|
||||
await database.changePassword(user, log);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/logout') {
|
||||
const token = parseCookies(request).hz_session;
|
||||
if (token) sessions.delete(token);
|
||||
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return handleAuth;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export function createCandidateRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
readDb,
|
||||
sendJson,
|
||||
sendError,
|
||||
readJson,
|
||||
readBodyBuffer,
|
||||
sendWorkbook,
|
||||
currentUser,
|
||||
safeUser,
|
||||
requireUser,
|
||||
hasPermission,
|
||||
requirePermission,
|
||||
profileInScope,
|
||||
registrationInScope,
|
||||
adminScopeLabel,
|
||||
adminsForStep,
|
||||
activeWorkflow,
|
||||
createWorkflowSubmission,
|
||||
workflowView,
|
||||
pendingWorkflow,
|
||||
candidateSequence,
|
||||
generateCandidateNumber,
|
||||
cleanText,
|
||||
centerScopeProfile,
|
||||
workflowScopeProfile,
|
||||
candidateAccountBatchView,
|
||||
centerChangeView,
|
||||
parseCenterChange,
|
||||
maskId,
|
||||
publicExam,
|
||||
examRegistrationView,
|
||||
logAction,
|
||||
excelResourceNames,
|
||||
excelRowsForResource,
|
||||
importExcelResource,
|
||||
admitCardHtml,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames
|
||||
} = context;
|
||||
|
||||
async function handleCandidate(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/candidate/')) return false;
|
||||
const user = await requireUser(request, response, 'candidate');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
const profile = db.candidateProfiles.find(item => item.userId === user.id);
|
||||
if (user.mustChangePassword) return sendError(response, 428, '首次登录必须先修改初始密码');
|
||||
const profileRoute = pathname === '/api/candidate/profile';
|
||||
if (!profile.profileCompleted && !profileRoute) return sendError(response, 428, '请先补全个人信息并提交审核');
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
|
||||
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
||||
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 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 === 'PUT' && pathname === '/api/candidate/profile') {
|
||||
const body = await readJson(request);
|
||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
|
||||
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
||||
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 || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) return sendError(response, 400, '请完整填写姓名、性别、证件号码、籍贯、家庭住址、手机号、邮箱、学校和班级');
|
||||
if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用');
|
||||
profile.status = 'pending';
|
||||
profile.profileCompleted = true;
|
||||
profile.reviewNote = '';
|
||||
profile.updatedAt = nowIso();
|
||||
const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id);
|
||||
const submission = existingWorkflow ? null : createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id);
|
||||
await database.updateCandidateProfile(profile, profile.name, submission?.instance, submission?.action);
|
||||
return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
|
||||
const registrations = db.registrations.filter(item => item.userId === user.id);
|
||||
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null }));
|
||||
return sendJson(response, 200, { ok: true, profileStatus: profile.status, exams });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/registrations') {
|
||||
return sendJson(response, 200, { ok: true, registrations: db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/candidate/registrations') {
|
||||
if (profile.status !== 'approved') return sendError(response, 403, '个人资料审核通过后才能报名考试');
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === body.examId && item.status === 'published');
|
||||
if (!exam) return sendError(response, 404, '考试不存在或尚未发布');
|
||||
const state = publicExam(exam).registrationState;
|
||||
if (state !== 'open') return sendError(response, 400, state === 'upcoming' ? '报名尚未开始' : '报名已经截止');
|
||||
if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试');
|
||||
const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])];
|
||||
if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目');
|
||||
const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || null, admitCard: null };
|
||||
const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id);
|
||||
await database.createRegistration(registration, instance, action);
|
||||
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/results') {
|
||||
const registrations = db.registrations.filter(item => item.userId === user.id);
|
||||
const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => {
|
||||
const registration = registrations.find(reg => reg.id === result.registrationId);
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const subject = exam.subjects.find(item => item.id === result.subjectId);
|
||||
return { ...result, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId };
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, results });
|
||||
}
|
||||
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
|
||||
if (request.method === 'GET' && admitMatch) {
|
||||
const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id);
|
||||
if (!registration || !registration.admitCard) return sendError(response, 404, '准考证尚未生成');
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const now = Date.now();
|
||||
if (now < new Date(exam.admitDownloadStart).getTime()) return sendError(response, 403, '准考证下载尚未开放');
|
||||
if (now > new Date(exam.admitDownloadEnd).getTime()) return sendError(response, 403, '准考证下载时间已结束');
|
||||
const html = admitCardHtml(db, user, profile, registration);
|
||||
const filename = encodeURIComponent(`${exam.name}-${profile.name}-准考证.html`);
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' });
|
||||
response.end(html);
|
||||
return true;
|
||||
}
|
||||
return sendError(response, 404, '考生功能接口不存在');
|
||||
}
|
||||
|
||||
return handleCandidate;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export function createPublicRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
readDb,
|
||||
sendJson,
|
||||
sendError,
|
||||
readJson,
|
||||
readBodyBuffer,
|
||||
sendWorkbook,
|
||||
currentUser,
|
||||
safeUser,
|
||||
requireUser,
|
||||
hasPermission,
|
||||
requirePermission,
|
||||
profileInScope,
|
||||
registrationInScope,
|
||||
adminScopeLabel,
|
||||
adminsForStep,
|
||||
activeWorkflow,
|
||||
createWorkflowSubmission,
|
||||
workflowView,
|
||||
pendingWorkflow,
|
||||
candidateSequence,
|
||||
generateCandidateNumber,
|
||||
cleanText,
|
||||
centerScopeProfile,
|
||||
workflowScopeProfile,
|
||||
candidateAccountBatchView,
|
||||
centerChangeView,
|
||||
parseCenterChange,
|
||||
maskId,
|
||||
publicExam,
|
||||
examRegistrationView,
|
||||
logAction,
|
||||
excelResourceNames,
|
||||
excelRowsForResource,
|
||||
importExcelResource,
|
||||
admitCardHtml,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames
|
||||
} = context;
|
||||
|
||||
async function handlePublic(pathname, response) {
|
||||
const db = await readDb();
|
||||
if (pathname === '/api/public/home') {
|
||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
||||
if (noticeMatch) {
|
||||
const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
||||
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return handlePublic;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' };
|
||||
|
||||
export const permissionsByLevel = {
|
||||
super: ['*'],
|
||||
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
|
||||
class: ['dashboard.read', 'candidates.read', 'registrations.read', 'results.read']
|
||||
};
|
||||
|
||||
export function hasPermission(user, permission) {
|
||||
if (user?.role !== 'admin') return false;
|
||||
const permissions = permissionsByLevel[user.adminLevel || 'super'] || [];
|
||||
return permissions.includes('*') || permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function createPermissionGuard(sendError) {
|
||||
return function requirePermission(user, response, permission) {
|
||||
if (hasPermission(user, permission)) return true;
|
||||
sendError(response, 403, '当前管理员层级无权执行此操作');
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function registrationInScope(db, user, registration) {
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
return Boolean(profile && profileInScope(user, profile));
|
||||
}
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
function parseCookies(request) {
|
||||
return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => {
|
||||
const index = part.indexOf('=');
|
||||
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
|
||||
}));
|
||||
}
|
||||
|
||||
async function currentUser(request) {
|
||||
const token = parseCookies(request).hz_session;
|
||||
const session = token && sessions.get(token);
|
||||
if (!session || session.expiresAt < Date.now()) {
|
||||
if (token) sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
const db = await readDb();
|
||||
const user = db.users.find(item => item.id === session.userId) || null;
|
||||
return user?.active === false ? null : user;
|
||||
}
|
||||
|
||||
function safeUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
adminLevel: user.adminLevel || null,
|
||||
schoolId: user.schoolId || null,
|
||||
classId: user.classId || null,
|
||||
displayName: user.displayName,
|
||||
candidateNumber: user.candidateNumber || null,
|
||||
mustChangePassword: Boolean(user.mustChangePassword)
|
||||
};
|
||||
}
|
||||
|
||||
async function requireUser(request, response, role) {
|
||||
const user = await currentUser(request);
|
||||
if (!user) {
|
||||
sendError(response, 401, '请先登录');
|
||||
return null;
|
||||
}
|
||||
if (role && user.role !== role) {
|
||||
sendError(response, 403, '当前账号无权执行此操作');
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
return { parseCookies, currentUser, safeUser, requireUser };
|
||||
}
|
||||
+12
-1
@@ -67,7 +67,18 @@ try {
|
||||
|
||||
const sqliteFile = await readFile(testDb);
|
||||
assert.equal(sqliteFile.subarray(0, 16).toString(), 'SQLite format 3\0', '测试持久化文件必须是真实 SQLite 数据库');
|
||||
const appSource = await readFile(resolve(root, 'app.js'), 'utf8');
|
||||
const clientSources = await Promise.all([
|
||||
'app.js',
|
||||
'src/client/admin-views.mjs',
|
||||
'src/client/candidate-views.mjs',
|
||||
'src/client/public-views.mjs'
|
||||
].map(file => readFile(resolve(root, file), 'utf8')));
|
||||
const appSource = clientSources.join('\n');
|
||||
for (const modulePath of ['/src/client/api.mjs', '/src/client/ui.mjs', '/src/client/public-views.mjs', '/src/client/candidate-views.mjs', '/src/client/admin-views.mjs']) {
|
||||
const moduleResponse = await anonymous.request(modulePath);
|
||||
assert.equal(moduleResponse.response.status, 200, `${modulePath} 应作为浏览器模块提供`);
|
||||
assert.match(moduleResponse.response.headers.get('content-type') || '', /text\/javascript/, `${modulePath} 应返回 JavaScript MIME 类型`);
|
||||
}
|
||||
assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件');
|
||||
assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭');
|
||||
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
||||
|
||||
Reference in New Issue
Block a user