实现考试归档并永久锁定成绩流程
This commit is contained in:
1 parent
6d22b41ce8
commit
1a87e06fc8
15 files changed
+265
-39
No files matched your search
@@ -57,6 +57,7 @@
|
|||||||
- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账
|
- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账
|
||||||
- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论
|
- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论
|
||||||
- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库
|
- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库
|
||||||
|
- 超级管理员可将整场考试不可逆归档;归档后手工录入、Excel 导入、复议改分和考试配置全部锁定,历史报名、准考证与成绩默认折叠展示
|
||||||
- 管理员与考生接口权限隔离
|
- 管理员与考生接口权限隔离
|
||||||
|
|
||||||
### 系统能力
|
### 系统能力
|
||||||
@@ -84,7 +85,7 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <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`。
|
需要清空并重建本地演示库时运行 `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`。
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ document.addEventListener('click', async event => {
|
|||||||
await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
||||||
toast(target.dataset.status === 'published' ? '考试已发布' : '考试已撤回', '公开页面状态已同步'); return refreshPublic().then(renderRoute);
|
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') {
|
if (action === 'toggle-notice') {
|
||||||
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
||||||
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
|
toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
|
||||||
|
|||||||
+17
-7
@@ -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 = 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,
|
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||||
state.meta?.createdAt || new Date().toISOString()
|
state.meta?.createdAt || new Date().toISOString()
|
||||||
);
|
);
|
||||||
@@ -126,11 +126,12 @@ function buildSeedOperations(state) {
|
|||||||
add(
|
add(
|
||||||
`INSERT INTO exams (
|
`INSERT INTO exams (
|
||||||
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
|
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
|
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
||||||
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '',
|
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(
|
exam.subjects.forEach((subject, index) => add(
|
||||||
`INSERT INTO exam_subjects (
|
`INSERT INTO exam_subjects (
|
||||||
@@ -533,6 +534,8 @@ function stateFromRows(rows) {
|
|||||||
passPolicy: row.pass_policy === 'score_ratio' ? 'rank_percent' : (row.pass_policy || 'rank_percent'),
|
passPolicy: row.pass_policy === 'score_ratio' ? 'rank_percent' : (row.pass_policy || 'rank_percent'),
|
||||||
passValue: Number(row.pass_value ?? 60),
|
passValue: Number(row.pass_value ?? 60),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
archivedAt: row.archived_at || null,
|
||||||
|
archivedBy: row.archived_by || null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
subjects: subjectsByExam.get(row.id) || []
|
subjects: subjectsByExam.get(row.id) || []
|
||||||
})),
|
})),
|
||||||
@@ -1314,11 +1317,11 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
const operations = [operation(
|
const operations = [operation(
|
||||||
`INSERT INTO exams (
|
`INSERT INTO exams (
|
||||||
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
|
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
|
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
||||||
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
|
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(
|
exam.subjects.forEach((subject, index) => operations.push(operation(
|
||||||
`INSERT INTO exam_subjects (
|
`INSERT INTO exam_subjects (
|
||||||
@@ -1357,6 +1360,13 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
operations.push(auditOperation(log));
|
operations.push(auditOperation(log));
|
||||||
await transaction(operations);
|
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) {
|
async createNotice(notice, log) {
|
||||||
await transaction([
|
await transaction([
|
||||||
operation(
|
operation(
|
||||||
|
|||||||
Loaded 3 of 15 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user