This commit is contained in:
biss committed 2026-07-20 18:04:10 +08:00
1 parent a30477bb58
commit 67863e7ebf
16 files changed
+211 -57

No files matched your search

+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` 和完整关系型数据库结构。当前数据库结构版本为 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 <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') {
Loaded 3 of 16 files, more files were not shown because too many files have changed in this diff. Show more