This commit is contained in:
2026-07-20 18:04:10 +08:00 Unverified
parent a30477bb58
commit 67863e7ebf
16 changed files with 211 additions and 57 deletions
+6 -4
View File
@@ -20,7 +20,7 @@
- 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号 - 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号
- 资料审核状态与管理员审核意见 - 资料审核状态与管理员审核意见
- 查看开放考试并自主选择多个报考科目 - 查看开放考试并自主选择多个报考科目
- 查看报名、缴费和审核状态 - 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
- 准考证生成状态、开放时间与下载 - 准考证生成状态、开放时间与下载
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论 - 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
- 通知公告中心 - 通知公告中心
@@ -30,7 +30,9 @@
- 超级、校级、班级三级管理员,同一级支持多个账号 - 超级、校级、班级三级管理员,同一级支持多个账号
- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 - 超级管理员管理全局事务,并可监督、修改、退回全部审批流程
- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 - 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更
- 班级管理员可查看并审批本班考生、报名与成绩复议流程,成绩录入仍仅限超级管理员 - 班级管理员可查看并审批本班考生、报名与成绩复议流程,并在线下收款后确认考生已缴费;成绩录入仍仅限超级管理员
- 报名终审与缴费确认相互独立,系统不接入支付 SDK;仅考生所属班级负责人可确认缴费,并记录确认人和时间
- 超级、校级、班级管理员均可按各自数据范围查看及导出 Excel 缴费名单
- 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 - 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程
- 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分 - 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分
- 当前处理人可将流程转交给同范围的同级管理员 - 当前处理人可将流程转交给同范围的同级管理员
@@ -85,7 +87,7 @@ npm start
打开 <http://127.0.0.1:4173>。 打开 <http://127.0.0.1:4173>。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v14v14 增加考试不可逆归档与成绩永久锁定。开发阶段可直接运行 `npm run reset-db` 重建完整数据库,不要求兼容旧数据。 本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v15v15 将线下缴费确认从报名审核中拆分,并增加缴费确认人、确认时间及名单导出。开发阶段可直接运行 `npm run reset-db` 重建完整数据库,不要求兼容旧数据;低于 v15 的开发库会提示重建,不执行兼容迁移
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs` 需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`
@@ -160,7 +162,7 @@ npm start
npm test npm test
``` ```
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、班级负责人缴费确认、三级缴费名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。
## 项目结构 ## 项目结构
+7 -1
View File
@@ -1,5 +1,5 @@
import { api } from './src/client/api.mjs'; import { api } from './src/client/api.mjs';
import { createAdminViews } from './src/client/admin-views.mjs'; import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-views.mjs'; import { createCandidateViews } from './src/client/candidate-views.mjs';
import { createPublicViews } from './src/client/public-views.mjs'; import { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs'; import { state } from './src/client/state.mjs';
@@ -182,6 +182,12 @@ document.addEventListener('click', async event => {
toast(archived ? '账户已归档' : '账户已恢复', `${data.scopeLabel} · ${data.count} 个账户状态已更新`); return renderRoute(); toast(archived ? '账户已归档' : '账户已恢复', `${data.scopeLabel} · ${data.count} 个账户状态已更新`); return renderRoute();
} }
if (action === 'review-registration') return openRegistrationReview(target.dataset.id); if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
if (action === 'confirm-payment') {
if (!window.confirm(`确认已收到 ${target.dataset.name} 的“${target.dataset.exam}”考试费用吗?`)) return;
await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH' });
toast('缴费已确认', '考生端状态、确认人和确认时间已同步记录');
return renderRoute();
}
if (action === 'excel-download') { if (action === 'excel-download') {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (target.dataset.template === '1') query.set('template', '1'); if (target.dataset.template === '1') query.set('template', '1');
+23 -9
View File
@@ -57,7 +57,7 @@ function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value; const nullable = value => value == null || value === '' ? null : value;
add( add(
'UPDATE schema_metadata SET schema_version = 14, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', 'UPDATE schema_metadata SET schema_version = 15, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString() state.meta?.createdAt || new Date().toISOString()
); );
@@ -147,11 +147,12 @@ function buildSeedOperations(state) {
for (const registration of state.registrations) { for (const registration of state.registrations) {
add( add(
`INSERT INTO registrations ( `INSERT INTO registrations (
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
registration_number, number_rule_id registration_number, number_rule_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, registration.userId, registration.examId, registration.status, registration.id, registration.userId, registration.examId, registration.status,
registration.paymentStatus, registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote), registration.paymentStatus, nullable(registration.paidAt), nullable(registration.paidBy), registration.createdAt,
nullable(registration.reviewedAt), nullable(registration.reviewNote),
nullable(registration.registrationNumber), nullable(registration.numberRuleId) nullable(registration.registrationNumber), nullable(registration.numberRuleId)
); );
for (const subjectId of registration.subjectIds) { for (const subjectId of registration.subjectIds) {
@@ -546,6 +547,8 @@ function stateFromRows(rows) {
subjectIds: registrationSubjects.get(row.id) || [], subjectIds: registrationSubjects.get(row.id) || [],
status: row.status, status: row.status,
paymentStatus: row.payment_status, paymentStatus: row.payment_status,
paidAt: row.paid_at || null,
paidBy: row.paid_by || null,
createdAt: row.created_at, createdAt: row.created_at,
reviewedAt: row.reviewed_at, reviewedAt: row.reviewed_at,
reviewNote: row.review_note || '', reviewNote: row.review_note || '',
@@ -957,11 +960,12 @@ function createRepository({ client, location, read, transaction, close }) {
async createRegistration(registration, instance, action) { async createRegistration(registration, instance, action) {
const operations = [operation( const operations = [operation(
`INSERT INTO registrations ( `INSERT INTO registrations (
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
registration_number, number_rule_id registration_number, number_rule_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, registration.userId, registration.examId, registration.status, registration.id, registration.userId, registration.examId, registration.status,
registration.paymentStatus, registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote), registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.createdAt,
optional(registration.reviewedAt), optional(registration.reviewNote),
optional(registration.registrationNumber), optional(registration.numberRuleId) optional(registration.registrationNumber), optional(registration.numberRuleId)
)]; )];
for (const subjectId of registration.subjectIds) { for (const subjectId of registration.subjectIds) {
@@ -992,6 +996,15 @@ function createRepository({ client, location, read, transaction, close }) {
auditOperation(log) auditOperation(log)
]); ]);
}, },
async updateRegistrationPayment(registration, log) {
await transaction([
operation(
'UPDATE registrations SET payment_status = ?, paid_at = ?, paid_by = ? WHERE id = ?',
registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.id
),
auditOperation(log)
]);
},
async processWorkflow(instance, action, business, log) { async processWorkflow(instance, action, business, log) {
const operations = [ const operations = [
operation( operation(
@@ -1012,9 +1025,10 @@ function createRepository({ client, location, read, transaction, close }) {
} else if (instance.businessType === 'registration_review') { } else if (instance.businessType === 'registration_review') {
operations.push(operation( operations.push(operation(
`UPDATE registrations SET `UPDATE registrations SET
status = ?, payment_status = ?, reviewed_at = ?, review_note = ?, status = ?, payment_status = ?, paid_at = ?, paid_by = ?, reviewed_at = ?, review_note = ?,
registration_number = ?, number_rule_id = ? WHERE id = ?`, registration_number = ?, number_rule_id = ? WHERE id = ?`,
business.status, business.paymentStatus, optional(business.reviewedAt), optional(business.reviewNote), business.status, business.paymentStatus, optional(business.paidAt), optional(business.paidBy),
optional(business.reviewedAt), optional(business.reviewNote),
optional(business.registrationNumber), optional(business.numberRuleId), business.id optional(business.registrationNumber), optional(business.numberRuleId), business.id
)); ));
} else if (instance.businessType === 'center_change') { } else if (instance.businessType === 'center_change') {
+13
View File
@@ -47,6 +47,19 @@ const resourceSpecs = {
], ],
validations: { gender: ['男', '女'] } validations: { gender: ['男', '女'] }
}, },
payments: {
title: '考试缴费名单', sheet: '缴费名单',
columns: [
['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''],
['schoolName', '学校', 24, ''], ['className', '班级', 20, ''],
['candidateNumber', '报名号', 26, ''], ['candidateName', '考生姓名', 16, ''],
['subjectNames', '报考科目', 34, ''], ['amountDue', '应缴金额(元)', 18, ''],
['paymentStatus', '缴费状态', 14, ''], ['paidAt', '确认时间', 24, ''],
['paidByName', '确认人', 16, '']
],
numberColumns: ['amountDue'],
numberFormats: { amountDue: '0.00' }
},
centers: { centers: {
title: '考点考场档案', sheet: '考点考场', title: '考点考场档案', sheet: '考点考场',
columns: [ columns: [
+25 -2
View File
@@ -397,7 +397,14 @@ function examRegistrationView(db, registration) {
const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)); const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id));
const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending') const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending')
|| db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0]; || db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0];
return { ...registration, exam, subjects, workflow: workflowView(db, instance) }; return {
...registration,
exam,
subjects,
amountDue: Number(subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)),
paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || '',
workflow: workflowView(db, instance)
};
} }
function logAction(db, user, action, detail) { function logAction(db, user, action, detail) {
@@ -409,7 +416,7 @@ function logAction(db, user, action, detail) {
const excelResourceNames = { const excelResourceNames = {
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账', account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账',
admit_cards: '准考证信息台账' admit_cards: '准考证信息台账'
}; };
@@ -474,6 +481,22 @@ function excelRowsForResource(db, user, resource, searchParams) {
address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade, address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade,
ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone
})); }));
if (resource === 'payments') return db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item)).map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
const account = db.users.find(item => item.id === registration.userId) || {};
const exam = db.exams.find(item => item.id === registration.examId) || { subjects: [] };
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
return {
examCode: exam.code || '', examName: exam.name || '',
schoolName: db.schools.find(item => item.id === profile.schoolId)?.name || profile.school || '',
className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade || '',
candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '',
subjectNames: subjects.map(subject => subject.name).join('、'),
amountDue: Number(subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)),
paymentStatus: registration.paymentStatus === 'paid' ? '已缴费' : '待缴费', paidAt: registration.paidAt || '',
paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || ''
};
});
if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => { if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => {
const rooms = db.testRooms.filter(room => room.centerId === center.id); const rooms = db.testRooms.filter(room => room.centerId === center.id);
return (rooms.length ? rooms : [{}]).map(room => ({ return (rooms.length ? rooms : [{}]).map(room => ({
+18 -7
View File
@@ -1,5 +1,10 @@
import { formatRegionAddress } from './region-select.mjs'; import { formatRegionAddress } from './region-select.mjs';
export const numberSegmentMeta = {
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字']
};
export function createAdminViews(context) { export function createAdminViews(context) {
const { const {
state, state,
@@ -27,6 +32,7 @@ export function createAdminViews(context) {
const meta = { const meta = {
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', state.user.adminLevel === 'class' ? '考生线下缴费后,由班级负责人确认并记录办理时间。' : '查看并导出当前管理范围内的考试缴费名单。'],
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'], results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'],
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
@@ -46,7 +52,7 @@ export function createAdminViews(context) {
const data = page === 'security' ? {} : await api(`/api/admin/${endpoint}`); const data = page === 'security' ? {} : await api(`/api/admin/${endpoint}`);
state.pageData = data; state.pageData = data;
const content = { const content = {
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data), exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(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), security: () => accountSecurity() 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), security: () => accountSecurity()
@@ -58,7 +64,7 @@ export function createAdminViews(context) {
function adminDashboard(data) { function adminDashboard(data) {
const m = data.metrics; const m = data.metrics;
const canFlow = true; const canFlow = true;
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>`; 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.ticket}</span><div><small>待确认缴费</small><strong>${m.pendingPayments ?? 0}</strong><em>${state.user.adminLevel === 'class' ? '由本班负责人办理' : '按管理范围统计'}</em></div></article><article><span>${icons.exam}</span><div><small>待处理流程</small><strong>${m.pendingFlows ?? 0}</strong><em>进入流程中心办理</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><button data-route="admin/payments"><i>${m.pendingPayments ?? 0}</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 = '数据' } = {}) { function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) {
@@ -91,6 +97,16 @@ export function createAdminViews(context) {
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>${table(current, 'registrationTable')}</section>${archived.length ? `<details class="candidate-archive-fold admin-registration-archive"><summary><span><strong>归档考试报名记录</strong><small>${archived.length} 条 · 流程与报名信息已冻结</small></span><b>${archived.length}</b></summary>${table(archived, 'archivedRegistrationTable')}</details>` : ''}`; 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>${table(current, 'registrationTable')}</section>${archived.length ? `<details class="candidate-archive-fold admin-registration-archive"><summary><span><strong>归档考试报名记录</strong><small>${archived.length} 条 · 流程与报名信息已冻结</small></span><b>${archived.length}</b></summary>${table(archived, 'archivedRegistrationTable')}</details>` : ''}`;
} }
function adminPayments(data) {
const registrations = data.registrations || [];
const paid = registrations.filter(item => item.paymentStatus === 'paid');
const unpaid = registrations.filter(item => item.paymentStatus === 'unpaid');
const totalDue = registrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0);
const totalPaid = paid.reduce((sum, item) => sum + Number(item.amountDue || 0), 0);
const rows = registrations.map(item => `<tr data-status="${h(item.paymentStatus)}"><td><div class="person-cell"><span>${h((item.candidate?.name || '?').slice(0, 1))}</span><div><strong>${h(item.candidate?.name || '未知考生')}</strong><small class="mono">${h(item.registrationNumber || '')}</small></div></div></td><td><strong>${h(item.schoolName)}</strong><small>${h(item.className)}</small></td><td><strong>${h(item.exam?.name || '')}</strong><small>${item.subjects.map(subject => h(subject.name)).join('、')}</small></td><td><strong>${money(item.amountDue || 0)}</strong></td><td>${badge(item.paymentStatus)}</td><td>${item.paidAt ? `<strong>${formatDate(item.paidAt, true)}</strong><small>${h(item.paidByName || '班级负责人')}</small>` : '<span>—</span>'}</td><td>${data.canConfirmPayment && item.paymentStatus === 'unpaid' && !item.exam?.archivedAt ? `<button class="row-action primary" data-action="confirm-payment" data-id="${h(item.id)}" data-name="${h(item.candidate?.name || '')}" data-exam="${h(item.exam?.name || '')}">确认已缴费</button>` : item.exam?.archivedAt ? '<span class="archive-readonly-label">只读封存</span>' : '<span>—</span>'}</td></tr>`).join('');
return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}<section class="center-summary payment-summary"><div><span>报名人数</span><strong>${registrations.length}</strong></div><div><span>待确认</span><strong>${unpaid.length}</strong></div><div><span>已缴费</span><strong>${paid.length}</strong></div><div><span>应缴合计</span><strong>${money(totalDue)}</strong></div><div><span>已缴合计</span><strong>${money(totalPaid)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="paymentTable" placeholder="搜索考生、报名号、考试、学校或班级"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="paymentTable" data-status="all">全部</button><button data-action="status-filter" data-target="paymentTable" data-status="unpaid">待缴费</button><button data-action="status-filter" data-target="paymentTable" data-status="paid">已缴费</button></div></div><div class="table-scroll"><table id="paymentTable"><thead><tr><th>考生 / 报名号</th><th>学校 / 班级</th><th>考试 / 科目</th><th>应缴金额</th><th>缴费状态</th><th>确认记录</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">当前范围暂无已审核通过的报名</td></tr>'}</tbody></table></div></section>`;
}
function adminExams(exams) { function adminExams(exams) {
const active = exams.filter(exam => !exam.archivedAt); const active = exams.filter(exam => !exam.archivedAt);
const archived = exams.filter(exam => exam.archivedAt).sort((left, right) => new Date(right.archivedAt) - new Date(left.archivedAt)); const archived = exams.filter(exam => exam.archivedAt).sort((left, right) => new Date(right.archivedAt) - new Date(left.archivedAt));
@@ -159,11 +175,6 @@ export function createAdminViews(context) {
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>`; 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) { function adminAccountBatches(data) {
const classes = data.classes || []; const classes = data.classes || [];
const batches = data.batches || []; const batches = data.batches || [];
+6 -6
View File
@@ -25,19 +25,19 @@ export function createCandidateViews(context) {
]; ];
function adminNavForUser() { function adminNavForUser() {
const level = state.user?.adminLevel || 'super'; const level = state.user?.adminLevel || 'super';
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
const security = ['security', '账户安全', 'user']; const security = ['security', '账户安全', 'user'];
if (level === 'class') return [core[0], core[1], core[2], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[3], security]; if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security];
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, core[3], security]; if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
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], security]; return [core[0], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
} }
function portalShell(role, page, content, title, description) { function portalShell(role, page, content, title, description) {
const nav = role === 'admin' ? adminNavForUser() : candidateNav; const nav = role === 'admin' ? adminNavForUser() : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心'; const roleName = role === 'admin' ? '管理后台' : '考生中心';
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; 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' ? `<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>`; 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 === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (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' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
} }
function portalHeadingAction(role, page) { function portalHeadingAction(role, page) {
@@ -130,7 +130,7 @@ export function createCandidateViews(context) {
function candidateRegistrations(registrations) { function candidateRegistrations(registrations) {
if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
const card = 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.exam.archivedAt ? 'archived' : 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.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard && !reg.exam.archivedAt ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`; const card = 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.exam.archivedAt ? 'archived' : 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>${money(reg.amountDue || 0)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}${reg.paidAt ? `<small>${formatDate(reg.paidAt, true)} · ${h(reg.paidByName || '班级负责人')}</small>` : ''}</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.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : reg.paymentStatus === 'unpaid' ? '报名已通过,请线下完成缴费并等待班级负责人确认。' : '缴费已经确认,请留意准考证下载通知。'}</p>${reg.admitCard && !reg.exam.archivedAt ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`;
const current = registrations.filter(reg => !reg.exam.archivedAt); const current = registrations.filter(reg => !reg.exam.archivedAt);
const archived = registrations.filter(reg => reg.exam.archivedAt); const archived = registrations.filter(reg => reg.exam.archivedAt);
return `<div class="registration-cards">${current.map(card).join('')}</div>${archived.length ? `<details class="candidate-archive-fold"><summary><span><strong>历史报名记录</strong><small>${archived.length} 场归档考试 · 点击查阅</small></span><b>${archived.length}</b></summary><div class="registration-cards">${archived.map(card).join('')}</div></details>` : ''}`; return `<div class="registration-cards">${current.map(card).join('')}</div>${archived.length ? `<details class="candidate-archive-fold"><summary><span><strong>历史报名记录</strong><small>${archived.length} 场归档考试 · 点击查阅</small></span><b>${archived.length}</b></summary><div class="registration-cards">${archived.map(card).join('')}</div></details>` : ''}`;
+2 -2
View File
@@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
const examId = 'exam_autumn_2026'; const examId = 'exam_autumn_2026';
const registrationId = 'reg_demo_2026'; const registrationId = 'reg_demo_2026';
return { return {
meta: { version: 12, createdAt: nowIso() }, meta: { version: 15, createdAt: nowIso() },
settings: { selfRegistrationEnabled: false }, settings: { selfRegistrationEnabled: false },
organization: { organization: {
name: '海州市教育考试中心', name: '海州市教育考试中心',
@@ -75,7 +75,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
registrations: [ registrations: [
{ {
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'], id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
admitCard: { admitCard: {
planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点', centerCode: 'HZ03-C01', centerAddress: '江苏省 连云港市 连云区 育才路 16 号', planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点', centerCode: 'HZ03-C01', centerAddress: '江苏省 连云港市 连云区 育才路 16 号',
room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [ room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [
+5 -7
View File
@@ -188,10 +188,8 @@ export function createMysqlAdapter(context) {
metadataRows[0].schema_version = 13; metadataRows[0].schema_version = 13;
metadataRows[0].app_version = 13; metadataRows[0].app_version = 13;
} }
if (Number(metadataRows[0]?.schema_version || 1) < 14) { if (Number(metadataRows[0]?.schema_version || 1) < 15) {
await pool.execute('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1'); throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动');
metadataRows[0].schema_version = 14;
metadataRows[0].app_version = 14;
} }
if (Number(metadataRows[0]?.app_version || 1) < 2) { if (Number(metadataRows[0]?.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
@@ -364,8 +362,8 @@ export function createMysqlAdapter(context) {
if (Number(metadataRows[0]?.app_version || 1) < 7) { if (Number(metadataRows[0]?.app_version || 1) < 7) {
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1'); await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
} }
if (Number(metadataRows[0]?.schema_version || 1) < 14) { if (Number(metadataRows[0]?.schema_version || 1) < 15) {
await pool.execute('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1'); throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动');
} }
} }
if (!existing.length) { if (!existing.length) {
@@ -375,7 +373,7 @@ export function createMysqlAdapter(context) {
await connection.beginTransaction(); await connection.beginTransaction();
const [insert] = await connection.execute(` const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 14, ?, ?, ?) VALUES (1, 15, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]); `, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) { if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
+10 -3
View File
@@ -140,7 +140,9 @@ export const sqliteSchema = `
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exam_id TEXT NOT NULL REFERENCES exams(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')), status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid')),
paid_at TEXT,
paid_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
reviewed_at TEXT, reviewed_at TEXT,
review_note TEXT, review_note TEXT,
@@ -409,6 +411,7 @@ export const sqliteSchema = `
CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); 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_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_status ON registrations(status);
CREATE INDEX IF NOT EXISTS idx_registrations_payment ON registrations(payment_status, paid_at);
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at); CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat); CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
@@ -621,7 +624,9 @@ export const mysqlSchema = [
user_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL,
exam_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL,
status ENUM('pending', 'approved', 'rejected') NOT NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL,
payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL, payment_status ENUM('unpaid', 'paid') NOT NULL,
paid_at VARCHAR(35) NULL,
paid_by VARCHAR(64) NULL,
created_at VARCHAR(35) NOT NULL, created_at VARCHAR(35) NOT NULL,
reviewed_at VARCHAR(35) NULL, reviewed_at VARCHAR(35) NULL,
review_note VARCHAR(500) NULL, review_note VARCHAR(500) NULL,
@@ -630,9 +635,11 @@ export const mysqlSchema = [
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), UNIQUE KEY uq_registrations_user_exam (user_id, exam_id),
KEY idx_registrations_status (status), KEY idx_registrations_status (status),
KEY idx_registrations_payment (payment_status, paid_at),
KEY idx_registrations_exam (exam_id), KEY idx_registrations_exam (exam_id),
CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
CONSTRAINT fk_registrations_paid_by FOREIGN KEY (paid_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS registration_subjects ( `CREATE TABLE IF NOT EXISTS registration_subjects (
registration_id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL,
+5 -5
View File
@@ -237,8 +237,8 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.schema_version || 1) < 13) { if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run(); connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
} }
if (existingSystem && Number(existingSystem.schema_version || 1) < 14) { if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
connection.prepare('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1').run(); throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
} }
if (existingSystem && Number(existingSystem.app_version || 1) < 2) { if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed(); const extension = seed();
@@ -390,8 +390,8 @@ export function createSqliteAdapter(context) {
if (existingSystem && Number(existingSystem.app_version || 1) < 7) { if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run(); connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
} }
if (existingSystem && Number(existingSystem.schema_version || 1) < 14) { if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
connection.prepare('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1').run(); throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
} }
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
@@ -400,7 +400,7 @@ export function createSqliteAdapter(context) {
try { try {
connection.prepare(` connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 14, ?, ?, ?) VALUES (1, 15, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()); `).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT'); connection.exec('COMMIT');
+51 -4
View File
@@ -127,13 +127,14 @@ export function createAdminRoutes(context) {
}); });
} }
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|centers|results)$/); const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/);
if (excelMatch && request.method === 'GET') { if (excelMatch && request.method === 'GET') {
const resource = excelMatch[1]; const resource = excelMatch[1];
if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在'); 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 (['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 === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场');
if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料'); if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料');
if (resource === 'payments' && !hasPermission(user, 'payments.read')) return sendError(response, 403, '当前账号不能导出缴费名单');
if (resource === 'results' && !hasPermission(user, 'results.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 requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
const template = requestUrl.searchParams.get('template') === '1'; const template = requestUrl.searchParams.get('template') === '1';
@@ -144,7 +145,7 @@ export function createAdminRoutes(context) {
} }
if (excelMatch && request.method === 'POST') { if (excelMatch && request.method === 'POST') {
const resource = excelMatch[1]; const resource = excelMatch[1];
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出'); if (['account_results', 'payments'].includes(resource)) return sendError(response, 400, '清单只支持导出');
const rows = await parseWorkbook(resource, await readBodyBuffer(request)); const rows = await parseWorkbook(resource, await readBodyBuffer(request));
if (resource === 'results') { if (resource === 'results') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以预览导入成绩'); if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以预览导入成绩');
@@ -568,12 +569,13 @@ export function createAdminRoutes(context) {
}); });
const pendingCandidates = profiles.filter(item => item.status === 'pending').length; const pendingCandidates = profiles.filter(item => item.status === 'pending').length;
const pendingRegistrations = registrations.filter(item => item.status === 'pending').length; const pendingRegistrations = registrations.filter(item => item.status === 'pending').length;
const pendingPayments = registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length;
return sendJson(response, 200, { return sendJson(response, 200, {
ok: true, ok: true,
admin: safeUser(user), admin: safeUser(user),
scopeLabel: adminScopeLabel(db, user), scopeLabel: adminScopeLabel(db, user),
permissions: permissionsByLevel[user.adminLevel || 'super'], 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 }, metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingPayments, 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) logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8)
}); });
} }
@@ -679,6 +681,51 @@ export function createAdminRoutes(context) {
}); });
return sendJson(response, 200, { ok: true, registrations }); return sendJson(response, 200, { ok: true, registrations });
} }
if (request.method === 'GET' && pathname === '/api/admin/payments') {
if (!requirePermission(user, response, 'payments.read')) return true;
const registrations = db.registrations
.filter(registration => registration.status === 'approved' && registrationInScope(db, user, registration))
.map(registration => {
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const view = examRegistrationView(db, registration);
return {
...view,
candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null,
schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '',
className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
amountDue: Number(view.subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)),
paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || ''
};
});
return sendJson(response, 200, {
ok: true,
scopeLabel: adminScopeLabel(db, user),
canConfirmPayment: user.adminLevel === 'class',
registrations
});
}
const paymentMatch = pathname.match(/^\/api\/admin\/payments\/([^/]+)$/);
if (request.method === 'PATCH' && paymentMatch) {
if (user.adminLevel !== 'class') return sendError(response, 403, '缴费确认只能由考生所属班级负责人办理');
const registration = db.registrations.find(item => item.id === paymentMatch[1]);
if (!registration || !registrationInScope(db, user, registration)) return sendError(response, 404, '缴费记录不存在或不在本班范围内');
if (registration.status !== 'approved') return sendError(response, 409, '报名审核通过后才能确认缴费');
if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,缴费记录已冻结');
if (registration.paymentStatus === 'paid') return sendError(response, 409, '该考生已经确认缴费,请勿重复操作');
registration.paymentStatus = 'paid';
registration.paidAt = nowIso();
registration.paidBy = user.id;
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const exam = db.exams.find(item => item.id === registration.examId);
await database.updateRegistrationPayment(
registration,
logAction(db, user, '确认考生缴费', `${profile?.name || registration.registrationNumber} · ${exam?.name || registration.examId}`)
);
return sendJson(response, 200, {
ok: true,
payment: { registrationId: registration.id, status: registration.paymentStatus, paidAt: registration.paidAt, paidBy: registration.paidBy, paidByName: user.displayName }
});
}
if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') { if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') {
if (!requirePermission(user, response, 'registrations.read')) return true; if (!requirePermission(user, response, 'registrations.read')) return true;
const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item)); const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item));
@@ -777,7 +824,7 @@ export function createAdminRoutes(context) {
const account = db.users.find(item => item.id === registration.userId); const account = db.users.find(item => item.id === registration.userId);
if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配'); if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配');
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
registration.status = 'approved'; registration.paymentStatus = 'paid'; registration.reviewNote = note; registration.reviewedAt = nowIso(); registration.status = 'approved'; registration.reviewNote = note; registration.reviewedAt = nowIso();
registration.registrationNumber = account.candidateNumber; registration.registrationNumber = account.candidateNumber;
registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId; registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId;
} }
+1 -1
View File
@@ -118,7 +118,7 @@ export function createCandidateRoutes(context) {
if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试'); if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试');
const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])]; const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])];
if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目'); if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目');
const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || null, admitCard: null }; const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', paidAt: null, paidBy: null, createdAt: nowIso(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || null, admitCard: null };
const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id); const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id);
await database.createRegistration(registration, instance, action); await database.createRegistration(registration, instance, action);
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
+2 -2
View File
@@ -2,8 +2,8 @@ export const adminLevelNames = { super: '超级管理员', school: '校级管理
export const permissionsByLevel = { export const permissionsByLevel = {
super: ['*'], super: ['*'],
school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'],
class: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', 'workflows.inbox'] class: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'workflows.inbox']
}; };
export function hasPermission(user, permission) { export function hasPermission(user, permission) {
+1
View File
File diff suppressed because one or more lines are too long
+36 -4
View File
@@ -16,6 +16,8 @@ assert.equal(mysqlRuleSchemas.length, 2, 'MySQL 应包含两张号码规则表')
for (const statement of mysqlRuleSchemas) { for (const statement of mysqlRuleSchemas) {
assert.match(statement, /`separator` VARCHAR\(10\)/, 'MySQL 保留字 separator 必须作为标识符转义'); assert.match(statement, /`separator` VARCHAR\(10\)/, 'MySQL 保留字 separator 必须作为标识符转义');
} }
const mysqlRegistrationSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS registrations\b/.test(statement));
assert.match(mysqlRegistrationSchema || '', /paid_at VARCHAR\(35\).*paid_by VARCHAR\(64\)/s, 'MySQL ');
await rm(testDb, { force: true }); await rm(testDb, { force: true });
await rm(`${testDb}-shm`, { force: true }); await rm(`${testDb}-shm`, { force: true });
await rm(`${testDb}-wal`, { force: true }); await rm(`${testDb}-wal`, { force: true });
@@ -106,13 +108,15 @@ try {
const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name); const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name);
const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name); const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name);
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name); const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
inspector.close(); inspector.close();
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 14, '考试归档应使用 v14 数据结构'); assert.equal(schemaVersion, 15, '线下缴费确认应使用 v15 数据结构');
assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员'); assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员');
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩'); assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人'); assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照'); assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照'); assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值'); assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值');
@@ -334,9 +338,9 @@ try {
examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(), examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(),
admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(), admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(),
location: '测试考点', status: 'published', subjects: [ location: '测试考点', status: 'published', subjects: [
{ name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passRule: 'fixed_score', passValue: 90 }, { name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 20, fullScore: 150, passRule: 'fixed_score', passValue: 90 },
{ name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 0, fullScore: 150, passScore: 90 }, { name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 20, fullScore: 150, passScore: 90 },
{ name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passRule: 'rank_percent', passValue: 60 } { name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 25, fullScore: 150, passRule: 'rank_percent', passValue: 60 }
] ]
} }
}); });
@@ -431,6 +435,32 @@ try {
const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } }); const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } });
assert.equal(approveRegistration.data.registration.status, 'approved'); assert.equal(approveRegistration.data.registration.status, 'approved');
assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号'); assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号');
assert.equal(approveRegistration.data.registration.paymentStatus, 'unpaid', '报名终审不得代替线下缴费确认');
const superPaymentList = await admin.request('/api/admin/payments');
const schoolPaymentList = await schoolAdmin.request('/api/admin/payments');
const classPaymentList = await classAdmin.request('/api/admin/payments');
assert.ok(superPaymentList.data.registrations.some(item => item.id === registrationId), '超级管理员应能查看全部范围缴费名单');
assert.ok(schoolPaymentList.data.registrations.some(item => item.id === registrationId), '校级管理员应能查看本校缴费名单');
assert.ok(classPaymentList.data.registrations.some(item => item.id === registrationId), '班级负责人应能查看本班缴费名单');
assert.equal(classPaymentList.data.canConfirmPayment, true, '班级负责人应取得缴费确认能力');
assert.equal(superPaymentList.data.canConfirmPayment, false, '超级管理员只能查看和导出,不得代班确认');
assert.equal((await admin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 403, '超级管理员不得确认考生缴费');
assert.equal((await schoolAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 403, '校级管理员不得确认考生缴费');
const confirmedPayment = await classAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' });
assert.equal(confirmedPayment.response.status, 200, '班级负责人应能在线下收款后确认缴费');
assert.equal(confirmedPayment.data.payment.status, 'paid');
assert.ok(confirmedPayment.data.payment.paidAt && confirmedPayment.data.payment.paidByName, '缴费确认应记录时间和办理人');
assert.equal((await classAdmin2.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 409, '重复确认缴费应被拒绝');
const paidCandidateRegistration = (await candidate.request('/api/candidate/registrations')).data.registrations.find(item => item.id === registrationId);
assert.equal(paidCandidateRegistration.paymentStatus, 'paid', '班级确认后考生端应同步显示已缴费');
assert.ok(paidCandidateRegistration.paidAt && paidCandidateRegistration.paidByName, '考生应能看到缴费确认记录');
assert.equal(paidCandidateRegistration.amountDue, 45, '应缴金额应按所选科目费用汇总');
for (const [client, label] of [[admin, '超级管理员'], [schoolAdmin, '校级管理员'], [classAdmin, '班级负责人']]) {
const exportPaymentList = await client.request('/api/admin/excel/payments');
assert.equal(exportPaymentList.response.status, 200, `${label}应能导出管理范围内的缴费名单`);
assert.ok(Buffer.isBuffer(exportPaymentList.data) && exportPaymentList.data.length > 1000, `${label}缴费名单应为有效 Excel 文件`);
}
const arrangementContext = await admin.request('/api/admin/admission-arrangements'); const arrangementContext = await admin.request('/api/admin/admission-arrangements');
assert.equal(arrangementContext.data.rules.length, 4, '系统应预置四种准考证号规则'); assert.equal(arrangementContext.data.rules.length, 4, '系统应预置四种准考证号规则');
@@ -661,6 +691,7 @@ try {
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批'); assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批');
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'score_appeal'), '流程设计应包含考生成绩复议'); assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'score_appeal'), '流程设计应包含考生成绩复议');
assert.match(appSource, /renderAdmin, workflowStepEditor/, '添加流程步骤事件必须取得视图模块导出的编辑器,避免未定义错误'); assert.match(appSource, /renderAdmin, workflowStepEditor/, '添加流程步骤事件必须取得视图模块导出的编辑器,避免未定义错误');
assert.match(appSource, /createAdminViews, numberSegmentMeta/, '保存报名号规则必须导入字段元数据,避免提交时出现未定义错误');
const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } }); const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } });
assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程'); assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程');
@@ -674,6 +705,7 @@ try {
console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); console.log('✓ 校级按班级批量申领、终审原子建号与结果返回');
console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');
console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出');
console.log('✓ 班级负责人线下缴费确认、状态同步与三级名单导出');
console.log('✓ 成绩录入、发布与考生查询'); console.log('✓ 成绩录入、发布与考生查询');
console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); console.log('✓ 成绩复议、班级审批、范围匹配与多人均分');
} finally { } finally {