From 67863e7ebf516e1e346094daeae5a28009963c25 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 18:04:10 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=B4=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++--- app.js | 8 ++++- database.mjs | 32 +++++++++++++------ excel.mjs | 13 ++++++++ server.mjs | 27 ++++++++++++++-- src/client/admin-views.mjs | 25 ++++++++++----- src/client/candidate-views.mjs | 12 +++---- src/data/seed.mjs | 4 +-- src/database/mysql-adapter.mjs | 12 +++---- src/database/schema.mjs | 13 ++++++-- src/database/sqlite-adapter.mjs | 10 +++--- src/routes/admin.routes.mjs | 55 ++++++++++++++++++++++++++++++--- src/routes/candidate.routes.mjs | 2 +- src/security/authorization.mjs | 4 +-- styles.css | 1 + tests/system.test.mjs | 40 +++++++++++++++++++++--- 16 files changed, 211 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 25526db..8815b2d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号 - 资料审核状态与管理员审核意见 - 查看开放考试并自主选择多个报考科目 -- 查看报名、缴费和审核状态 +- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录 - 准考证生成状态、开放时间与下载 - 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论 - 通知公告中心 @@ -30,7 +30,9 @@ - 超级、校级、班级三级管理员,同一级支持多个账号 - 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 - 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 -- 班级管理员可查看并审批本班考生、报名与成绩复议流程,成绩录入仍仅限超级管理员 +- 班级管理员可查看并审批本班考生、报名与成绩复议流程,并在线下收款后确认考生已缴费;成绩录入仍仅限超级管理员 +- 报名终审与缴费确认相互独立,系统不接入支付 SDK;仅考生所属班级负责人可确认缴费,并记录确认人和时间 +- 超级、校级、班级管理员均可按各自数据范围查看及导出 Excel 缴费名单 - 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 - 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分 - 当前处理人可将流程转交给同范围的同级管理员 @@ -85,7 +87,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v14;v14 增加考试不可逆归档与成绩永久锁定。开发阶段可直接运行 `npm run reset-db` 重建完整数据库,不要求兼容旧数据。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v15;v15 将线下缴费确认从报名审核中拆分,并增加缴费确认人、确认时间及名单导出。开发阶段可直接运行 `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 src/data/china-regions.mjs`。 @@ -160,7 +162,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、班级负责人缴费确认、三级缴费名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 ## 项目结构 diff --git a/app.js b/app.js index 22e6cda..9de55ae 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,5 @@ 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 { createPublicViews } from './src/client/public-views.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(); } 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') { const query = new URLSearchParams(); if (target.dataset.template === '1') query.set('template', '1'); diff --git a/database.mjs b/database.mjs index ac9a0a6..d81a56a 100644 --- a/database.mjs +++ b/database.mjs @@ -57,7 +57,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; 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, state.meta?.createdAt || new Date().toISOString() ); @@ -147,11 +147,12 @@ function buildSeedOperations(state) { for (const registration of state.registrations) { add( `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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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) ); for (const subjectId of registration.subjectIds) { @@ -546,6 +547,8 @@ function stateFromRows(rows) { subjectIds: registrationSubjects.get(row.id) || [], status: row.status, paymentStatus: row.payment_status, + paidAt: row.paid_at || null, + paidBy: row.paid_by || null, createdAt: row.created_at, reviewedAt: row.reviewed_at, reviewNote: row.review_note || '', @@ -957,11 +960,12 @@ function createRepository({ client, location, read, transaction, close }) { async createRegistration(registration, instance, action) { const operations = [operation( `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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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) )]; for (const subjectId of registration.subjectIds) { @@ -992,6 +996,15 @@ function createRepository({ client, location, read, transaction, close }) { 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) { const operations = [ operation( @@ -1012,9 +1025,10 @@ function createRepository({ client, location, read, transaction, close }) { } else if (instance.businessType === 'registration_review') { operations.push(operation( `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 = ?`, - 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 )); } else if (instance.businessType === 'center_change') { diff --git a/excel.mjs b/excel.mjs index a23567d..0f2b193 100644 --- a/excel.mjs +++ b/excel.mjs @@ -47,6 +47,19 @@ const resourceSpecs = { ], 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: { title: '考点考场档案', sheet: '考点考场', columns: [ diff --git a/server.mjs b/server.mjs index a017cc7..3a15df7 100644 --- a/server.mjs +++ b/server.mjs @@ -397,7 +397,14 @@ function examRegistrationView(db, registration) { 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') || 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) { @@ -409,7 +416,7 @@ function logAction(db, user, action, detail) { const excelResourceNames = { classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', - account_results: '报名号下发结果', candidates: '考生资料', centers: '考点考场档案', results: '成绩台账', + account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账', 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, 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 => { const rooms = db.testRooms.filter(room => room.centerId === center.id); return (rooms.length ? rooms : [{}]).map(room => ({ diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 333b7b6..f636db7 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -1,5 +1,10 @@ import { formatRegionAddress } from './region-select.mjs'; +export const numberSegmentMeta = { + year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], + sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] +}; + export function createAdminViews(context) { const { state, @@ -27,6 +32,7 @@ export function createAdminViews(context) { const meta = { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], 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' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], @@ -46,7 +52,7 @@ export function createAdminViews(context) { const data = page === 'security' ? {} : await api(`/api/admin/${endpoint}`); state.pageData = data; 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), 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() @@ -58,7 +64,7 @@ export function createAdminViews(context) { function adminDashboard(data) { const m = data.metrics; const canFlow = true; - return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.exam}
待处理流程${m.pendingFlows ?? 0}${canFlow ? '进入流程中心办理' : '班级账号只读'}
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; + return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.ticket}
待确认缴费${m.pendingPayments ?? 0}${state.user.adminLevel === 'class' ? '由本班负责人办理' : '按管理范围统计'}
${icons.exam}
待处理流程${m.pendingFlows ?? 0}进入流程中心办理
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; } function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) { @@ -90,6 +96,16 @@ export function createAdminViews(context) { const archived = registrations.filter(reg => reg.exam.archivedAt); return `
${table(current, 'registrationTable')}
${archived.length ? `
归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}${table(archived, 'archivedRegistrationTable')}
` : ''}`; } + + 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 => `
${h((item.candidate?.name || '?').slice(0, 1))}
${h(item.candidate?.name || '未知考生')}${h(item.registrationNumber || '')}
${h(item.schoolName)}${h(item.className)}${h(item.exam?.name || '')}${item.subjects.map(subject => h(subject.name)).join('、')}${money(item.amountDue || 0)}${badge(item.paymentStatus)}${item.paidAt ? `${formatDate(item.paidAt, true)}${h(item.paidByName || '班级负责人')}` : ''}${data.canConfirmPayment && item.paymentStatus === 'unpaid' && !item.exam?.archivedAt ? `` : item.exam?.archivedAt ? '只读封存' : ''}`).join(''); + return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${registrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${rows || ''}
考生 / 报名号学校 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
`; + } function adminExams(exams) { const active = exams.filter(exam => !exam.archivedAt); @@ -159,11 +175,6 @@ export function createAdminViews(context) { return `${excelToolbar('centers', { label: '考点考场档案' })}
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; } - const numberSegmentMeta = { - year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], - sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] - }; - function adminAccountBatches(data) { const classes = data.classes || []; const batches = data.batches || []; diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 98144c4..9e12c44 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -25,19 +25,19 @@ export function createCandidateViews(context) { ]; function adminNavForUser() { 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']; - 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']]; - if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ['admit', '校内准考证', 'ticket'], ...operations, core[3], 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]; + 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], 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) { const nav = role === 'admin' ? adminNavForUser() : candidateNav; const roleName = role === 'admin' ? '管理后台' : '考生中心'; const adminTitle = statusLabels[state.user?.adminLevel] || '管理员'; - return `
${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; + return `
${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; } function portalHeadingAction(role, page) { @@ -130,7 +130,7 @@ export function createCandidateViews(context) { function candidateRegistrations(registrations) { if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); - const card = reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.exam.archivedAt ? 'archived' : reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}

${reg.admitCard && !reg.exam.archivedAt ? `` : ''}
`; + const card = reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.exam.archivedAt ? 'archived' : reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
应缴金额
${money(reg.amountDue || 0)}
缴费状态
${badge(reg.paymentStatus)}${reg.paidAt ? `${formatDate(reg.paidAt, true)} · ${h(reg.paidByName || '班级负责人')}` : ''}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : reg.paymentStatus === 'unpaid' ? '报名已通过,请线下完成缴费并等待班级负责人确认。' : '缴费已经确认,请留意准考证下载通知。'}

${reg.admitCard && !reg.exam.archivedAt ? `` : ''}
`; const current = registrations.filter(reg => !reg.exam.archivedAt); const archived = registrations.filter(reg => reg.exam.archivedAt); return `
${current.map(card).join('')}
${archived.length ? `
历史报名记录${archived.length} 场归档考试 · 点击查阅${archived.length}
${archived.map(card).join('')}
` : ''}`; diff --git a/src/data/seed.mjs b/src/data/seed.mjs index 9338e52..d9198eb 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 12, createdAt: nowIso() }, + meta: { version: 15, createdAt: nowIso() }, settings: { selfRegistrationEnabled: false }, organization: { name: '海州市教育考试中心', @@ -75,7 +75,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { 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', + 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: { 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: [ diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 5858e31..7481308 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -188,10 +188,8 @@ export function createMysqlAdapter(context) { metadataRows[0].schema_version = 13; metadataRows[0].app_version = 13; } - if (Number(metadataRows[0]?.schema_version || 1) < 14) { - await pool.execute('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1'); - metadataRows[0].schema_version = 14; - metadataRows[0].app_version = 14; + if (Number(metadataRows[0]?.schema_version || 1) < 15) { + throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动'); } if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); @@ -364,8 +362,8 @@ export function createMysqlAdapter(context) { if (Number(metadataRows[0]?.app_version || 1) < 7) { await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1'); } - if (Number(metadataRows[0]?.schema_version || 1) < 14) { - await pool.execute('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1'); + if (Number(metadataRows[0]?.schema_version || 1) < 15) { + throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动'); } } if (!existing.length) { @@ -375,7 +373,7 @@ export function createMysqlAdapter(context) { 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, 14, ?, ?, ?) + VALUES (1, 15, ?, ?, ?) `, [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); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index cad9f68..08c72c6 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -140,7 +140,9 @@ export const sqliteSchema = ` 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')), + 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, reviewed_at 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_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_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_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); @@ -621,7 +624,9 @@ export const mysqlSchema = [ 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, + payment_status ENUM('unpaid', 'paid') NOT NULL, + paid_at VARCHAR(35) NULL, + paid_by VARCHAR(64) NULL, created_at VARCHAR(35) NOT NULL, reviewed_at VARCHAR(35) NULL, review_note VARCHAR(500) NULL, @@ -630,9 +635,11 @@ export const mysqlSchema = [ PRIMARY KEY (id), UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), KEY idx_registrations_status (status), + KEY idx_registrations_payment (payment_status, paid_at), 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 + 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`, `CREATE TABLE IF NOT EXISTS registration_subjects ( registration_id VARCHAR(64) NOT NULL, diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index c487399..66d7e92 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -237,8 +237,8 @@ export function createSqliteAdapter(context) { if (existingSystem && Number(existingSystem.schema_version || 1) < 13) { connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run(); } - if (existingSystem && Number(existingSystem.schema_version || 1) < 14) { - connection.prepare('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1').run(); + if (existingSystem && Number(existingSystem.schema_version || 1) < 15) { + throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库'); } if (existingSystem && Number(existingSystem.app_version || 1) < 2) { const extension = seed(); @@ -390,8 +390,8 @@ export function createSqliteAdapter(context) { if (existingSystem && Number(existingSystem.app_version || 1) < 7) { connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run(); } - if (existingSystem && Number(existingSystem.schema_version || 1) < 14) { - connection.prepare('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1').run(); + if (existingSystem && Number(existingSystem.schema_version || 1) < 15) { + throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库'); } if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { @@ -400,7 +400,7 @@ export function createSqliteAdapter(context) { try { connection.prepare(` 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()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 3159db6..a313b68 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -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') { 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 === 'payments' && !hasPermission(user, 'payments.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'; @@ -144,7 +145,7 @@ export function createAdminRoutes(context) { } if (excelMatch && request.method === 'POST') { 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)); if (resource === 'results') { 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 pendingRegistrations = registrations.filter(item => item.status === 'pending').length; + const pendingPayments = registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').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 }, + 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) }); } @@ -679,6 +681,51 @@ export function createAdminRoutes(context) { }); 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 (!requirePermission(user, response, 'registrations.read')) return true; 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); 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.status = 'approved'; registration.reviewNote = note; registration.reviewedAt = nowIso(); registration.registrationNumber = account.candidateNumber; registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId; } diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index b32bd42..86e0b64 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -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, '你已经报名该考试'); 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 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); await database.createRegistration(registration, instance, action); return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); diff --git a/src/security/authorization.mjs b/src/security/authorization.mjs index b6c52e9..b4eb8f0 100644 --- a/src/security/authorization.mjs +++ b/src/security/authorization.mjs @@ -2,8 +2,8 @@ export const adminLevelNames = { super: '超级管理员', school: '校级管理 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', 'candidates.review', 'registrations.read', 'registrations.review', 'results.read', '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', 'payments.read', 'payments.write', 'results.read', 'workflows.inbox'] }; export function hasPermission(user, permission) { diff --git a/styles.css b/styles.css index 050a84d..cb7659e 100644 --- a/styles.css +++ b/styles.css @@ -492,6 +492,7 @@ button:disabled { cursor: not-allowed; opacity: .5; } /* Controlled test-site dossiers and batch numbering */ .center-summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:15px; }.center-summary > div { display:flex; align-items:baseline; gap:8px; padding:16px 18px; border:1px solid var(--line); border-radius:11px; background:#fff; }.center-summary span { color:#8790a2; font-size:8px; }.center-summary strong { margin-left:auto; color:var(--navy); font-family:Georgia,serif; font-size:24px; font-weight:400; }.center-dossier-grid { display:grid; gap:16px; }.center-dossier { overflow:hidden; }.center-dossier > header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; padding:19px 21px; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff,#f7f9fd); }.center-dossier > header > div:last-child { display:flex; align-items:center; gap:8px; }.center-dossier header span { color:#70809f; font-size:8px; }.center-dossier header h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:22px; font-weight:400; }.pending-mark { padding:5px 8px; border-radius:6px; color:#99631d !important; background:#fff0d3; font-weight:700; }.row-action:disabled { color:#9ca4b3; cursor:not-allowed; }.center-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; border-bottom:1px solid var(--line); background:var(--line); }.center-metrics > div { display:flex; align-items:baseline; gap:5px; padding:14px 20px; background:#fff; }.center-metrics small { margin-right:auto; color:#9098a9; font-size:7px; }.center-metrics strong { font-family:Georgia,serif; font-size:19px; font-weight:400; }.center-metrics span { color:#8f97a8; font-size:7px; }.center-profile { display:grid; grid-template-columns:1fr 1fr; gap:10px 24px; margin:0; padding:18px 21px; }.center-profile div { display:grid; grid-template-columns:74px 1fr; gap:7px; }.center-profile dt { color:#949cac; font-size:7px; }.center-profile dd { margin:0; color:#556077; font-size:8px; line-height:1.6; }.room-table-wrap { margin:0 20px 18px; overflow:auto; border:1px solid var(--line); border-radius:9px; }.room-table th { background:#f3f6fb; }.center-dossier > footer { display:flex; justify-content:space-between; gap:15px; padding:12px 21px; border-top:1px solid var(--line); color:#8b94a5; font-size:7px; }.center-change-ledger { margin-top:16px; overflow:hidden; }.center-change-ledger .panel-title { min-height:70px; height:auto; }.center-change-ledger .panel-title p { margin:4px 0 0; color:#8b94a5; font-size:7px; }.modal-card:has(.center-dossier-form) { width:min(980px,100%); }.center-dossier-form { gap:18px; }.center-form-section { display:grid; gap:13px; padding:16px; border:1px solid var(--line); border-radius:10px; background:#fbfcfe; }.center-form-section > h3,.center-form-section > header h3 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.center-form-section > header { display:flex; align-items:center; justify-content:space-between; gap:15px; }.center-form-section > header p { margin:4px 0 0; color:#8f97a8; font-size:7px; }.rooms-section > div { display:grid; gap:10px; }.center-room-editor { overflow:hidden; border:1px solid #dce3ef; border-radius:9px; background:#fff; }.center-room-editor > header { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; background:#f1f4fa; }.center-room-editor > header span { color:#5e6c87; font-size:8px; font-weight:700; }.center-room-editor > header button { border:0; color:#a74c45; background:transparent; font-size:7px; }.room-editor-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; padding:12px; }.room-editor-grid label { min-width:0; }.room-editor-grid input,.room-editor-grid select { width:100%; }.room-editor-grid .room-notes { grid-column:span 2; }.approval-callout { display:flex; align-items:center; gap:12px; padding:12px 14px; border-left:3px solid var(--amber); border-radius:8px; background:#fff7e9; }.approval-callout strong { color:#765c30; font-size:9px; }.approval-callout span { color:#94794d; font-size:8px; }.batch-number-panel { margin-top:16px; padding:21px; background:linear-gradient(120deg,#fff 0 65%,#f0f4fb); }.batch-number-intro span { color:#7786a5; font-family:Consolas,monospace; font-size:7px; letter-spacing:1.2px; }.batch-number-intro h2 { margin:5px 0; font-family:"STKaiti"; font-size:22px; font-weight:400; }.batch-number-intro p { margin:0; color:#8790a2; font-size:8px; }.batch-number-panel form { display:grid; grid-template-columns:1fr 1fr 130px auto; align-items:end; gap:12px; margin-top:18px; }.batch-number-panel form label { display:grid; gap:6px; }.batch-number-panel form label span { color:#68738a; font-size:8px; font-weight:700; }.batch-number-panel select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.batch-ready { display:flex; align-items:baseline; gap:5px; min-height:40px; padding:8px 12px; border:1px solid #d6dfef; border-radius:8px; background:#f5f8fd; }.batch-ready small { margin-right:auto; color:#7f899d; font-size:7px; }.batch-ready strong { color:var(--blue); font-family:Georgia,serif; font-size:20px; }.batch-ready span { color:#8892a5; font-size:7px; }.batch-candidate-strip { display:flex; flex-wrap:wrap; gap:7px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.batch-candidate-strip > span { display:grid; gap:2px; padding:7px 9px; border-radius:7px; background:#f2f5fa; }.batch-candidate-strip b { font-size:8px; }.batch-candidate-strip small,.batch-candidate-strip p,.batch-candidate-strip em { color:#8a93a5; font-size:7px; font-style:normal; }.flow-center-snapshot { margin:18px 24px 4px; padding:14px; border:1px solid #dce3ef; border-radius:10px; background:#f7f9fd; }.flow-center-snapshot header { display:flex; justify-content:space-between; align-items:center; }.flow-center-snapshot header span { color:#8390a8; font-size:7px; }.flow-center-snapshot h3 { margin:3px 0 0; font-size:12px; }.flow-center-snapshot header b { color:var(--blue); font-size:9px; }.flow-center-snapshot dl { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:12px 0; }.flow-center-snapshot dl div { display:grid; grid-template-columns:62px 1fr; gap:6px; }.flow-center-snapshot dt { color:#929bad; font-size:7px; }.flow-center-snapshot dd { margin:0; font-size:8px; }.flow-center-snapshot > div { display:flex; flex-wrap:wrap; gap:6px; }.flow-center-snapshot > div span { display:grid; gap:2px; padding:6px 8px; border-radius:6px; background:#fff; }.flow-center-snapshot > div strong { font-size:8px; }.flow-center-snapshot > div small { color:#8d96a8; font-size:7px; } +.payment-summary { grid-template-columns:repeat(5,1fr); }.payment-summary strong { font-size:18px; }.registration-info dd > small { display:block; margin-top:5px; color:#8d95a5; font-family:inherit; font-size:7px; } /* Candidate account issuance and first-login onboarding */ .registration-closed { display:grid; gap:12px; padding:20px; border:1px solid #dde3ee; border-radius:11px; background:#f7f9fc; }.registration-closed strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.registration-closed span { color:#7f889b; font-size:9px; line-height:1.8; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index b97b83a..a588e54 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -16,6 +16,8 @@ assert.equal(mysqlRuleSchemas.length, 2, 'MySQL 应包含两张号码规则表') for (const statement of mysqlRuleSchemas) { 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}-shm`, { 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 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 registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name); inspector.close(); assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); 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.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩'); 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(['building', 'floor'].every(column => admitSubjectColumns.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(), admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(), 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: '13:00', end: '15:00', fee: 0, 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: '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: 20, fullScore: 150, passScore: 90 }, + { 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: '科目与资格终审通过' } }); assert.equal(approveRegistration.data.registration.status, 'approved'); 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'); 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 === 'score_appeal'), '流程设计应包含考生成绩复议'); 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 })) } }); assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程'); @@ -674,6 +705,7 @@ try { console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); + console.log('✓ 班级负责人线下缴费确认、状态同步与三级名单导出'); console.log('✓ 成绩录入、发布与考生查询'); console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); } finally {