实现考试归档并永久锁定成绩流程

This commit is contained in:
biss committed 2026-07-20 17:08:17 +08:00
1 parent 6d22b41ce8
commit 1a87e06fc8
15 files changed
+265 -39

No files matched your search

+2 -1
View File
@@ -57,6 +57,7 @@
- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账
- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论
- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库
- 超级管理员可将整场考试不可逆归档;归档后手工录入、Excel 导入、复议改分和考试配置全部锁定,历史报名、准考证与成绩默认折叠展示
- 管理员与考生接口权限隔离
### 系统能力
@@ -84,7 +85,7 @@ npm start
打开 <http://127.0.0.1:4173>。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v12;v9 增加整场编排方案,v10 增加准考证位置快照,v11 增加科目独立及格规则并移除 SQLite 成绩表固定 150 分上限,v12 将所有百分比合格规则统一为排名百分比。旧库启动时会自动迁移,也可运行 `npm run reset-db` 重建完整开发库。
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v14;v14 增加考试不可逆归档与成绩永久锁定。开发阶段可直接运行 `npm run reset-db` 重建完整数据库,不要求兼容旧数据。
需要清空并重建本地演示库时运行 `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`。
+8
View File
@@ -227,6 +227,14 @@ document.addEventListener('click', async event => {
await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
toast(target.dataset.status === 'published' ? '考试已发布' : '考试已撤回', '公开页面状态已同步'); return refreshPublic().then(renderRoute);
}
if (action === 'archive-exam') {
const confirmed = window.confirm(`确认归档“${target.dataset.name}”吗?\n\n归档不可撤销;本场成绩、Excel 导入和复议改分将永久锁定。`);
if (!confirmed) return;
const result = await api(`/api/admin/exams/${target.dataset.id}/archive`, { method: 'POST' });
state.resultImportPreview = null;
toast('考试已归档', result.message || '全部成绩已永久锁定');
return refreshPublic().then(renderRoute);
}
if (action === 'toggle-notice') {
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
+17 -7
View File
@@ -57,7 +57,7 @@ function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value;
add(
'UPDATE schema_metadata SET schema_version = 13, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
'UPDATE schema_metadata SET schema_version = 14, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
);
@@ -126,11 +126,12 @@ function buildSeedOperations(state) {
add(
`INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '',
exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'), Number(exam.passValue ?? 60), exam.status, exam.createdAt
exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'), Number(exam.passValue ?? 60), exam.status,
nullable(exam.archivedAt), nullable(exam.archivedBy), exam.createdAt
);
exam.subjects.forEach((subject, index) => add(
`INSERT INTO exam_subjects (
@@ -533,6 +534,8 @@ function stateFromRows(rows) {
passPolicy: row.pass_policy === 'score_ratio' ? 'rank_percent' : (row.pass_policy || 'rank_percent'),
passValue: Number(row.pass_value ?? 60),
status: row.status,
archivedAt: row.archived_at || null,
archivedBy: row.archived_by || null,
createdAt: row.created_at,
subjects: subjectsByExam.get(row.id) || []
})),
@@ -1314,11 +1317,11 @@ function createRepository({ client, location, read, transaction, close }) {
const operations = [operation(
`INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
exam.location || '', exam.passPolicy, exam.passValue, exam.status, exam.createdAt
exam.location || '', exam.passPolicy, exam.passValue, exam.status, optional(exam.archivedAt), optional(exam.archivedBy), exam.createdAt
)];
exam.subjects.forEach((subject, index) => operations.push(operation(
`INSERT INTO exam_subjects (
@@ -1357,6 +1360,13 @@ function createRepository({ client, location, read, transaction, close }) {
operations.push(auditOperation(log));
await transaction(operations);
},
async archiveExam(exam, log) {
await transaction([
operation('UPDATE exams SET status = ?, archived_at = ?, archived_by = ? WHERE id = ? AND archived_at IS NULL',
exam.status, exam.archivedAt, exam.archivedBy, exam.id),
auditOperation(log)
]);
},
async createNotice(notice, log) {
await transaction([
operation(
Loaded 3 of 15 files, more files were not shown because too many files have changed in this diff. Show more