缴费
This commit is contained in:
@@ -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 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -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
@@ -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 => ({
|
||||||
|
|||||||
@@ -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 = '数据' } = {}) {
|
||||||
@@ -90,6 +96,16 @@ export function createAdminViews(context) {
|
|||||||
const archived = registrations.filter(reg => reg.exam.archivedAt);
|
const archived = registrations.filter(reg => reg.exam.archivedAt);
|
||||||
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);
|
||||||
@@ -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 || [];
|
||||||
|
|||||||
@@ -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
@@ -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: [
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+36
-4
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user