实现考试归档并永久锁定成绩流程
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
+6
-1
@@ -285,7 +285,7 @@ function publicExam(exam) {
|
||||
return {
|
||||
...exam,
|
||||
totalScore: exam.subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0),
|
||||
registrationState: now < start ? 'upcoming' : now > end ? 'closed' : 'open'
|
||||
registrationState: exam.archivedAt ? 'archived' : now < start ? 'upcoming' : now > end ? 'closed' : 'open'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,6 +374,10 @@ function examResultSummary(db, registration) {
|
||||
|
||||
return {
|
||||
examId: exam.id,
|
||||
examName: exam.name,
|
||||
examCode: exam.code,
|
||||
examStart: exam.examStart,
|
||||
archivedAt: exam.archivedAt || null,
|
||||
complete,
|
||||
publishedSubjects: published.length,
|
||||
subjectCount: subjects.length,
|
||||
@@ -536,6 +540,7 @@ function prepareResultImport(db, rows) {
|
||||
else if (!account) rowErrors.push('报名号不存在');
|
||||
if (!examCode) rowErrors.push('考试代码不能为空');
|
||||
else if (!exam) rowErrors.push('考试代码不存在');
|
||||
else if (exam.archivedAt) rowErrors.push('该考试已归档,成绩已永久锁定');
|
||||
if (!subjectName) rowErrors.push('科目不能为空');
|
||||
else if (exam && !subject) rowErrors.push('该考试中不存在此科目');
|
||||
if (exam && account && !registration) rowErrors.push('该考生没有已通过的本场考试报名');
|
||||
|
||||
@@ -84,11 +84,19 @@ export function createAdminViews(context) {
|
||||
|
||||
function adminRegistrations(registrations) {
|
||||
const readOnly = false;
|
||||
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><div class="table-scroll"><table id="registrationTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>账户报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${registrations.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.status)}</td><td><button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">${readOnly ? '查看' : '查看流程'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
const rows = items => items.map(reg => `<tr data-status="${h(reg.status)}"><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${reg.subjects.map(subject => h(subject.name)).join('、')}</small></td><td class="mono"><strong>${h(reg.registrationNumber || '待同步账户号码')}</strong><small>各次考试保持一致</small></td><td><strong>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</strong><small>${h(reg.workflow?.assignee?.displayName || '')}</small></td><td>${badge(reg.paymentStatus)}</td><td>${badge(reg.exam.archivedAt ? 'archived' : reg.status)}</td><td>${reg.exam.archivedAt ? '<span class="archive-readonly-label">只读封存</span>' : `<button class="row-action" data-action="review-registration" data-id="${h(reg.id)}">${readOnly ? '查看' : '查看流程'}</button>`}</td></tr>`).join('');
|
||||
const table = (items, id) => `<div class="table-scroll"><table id="${id}"><thead><tr><th>考生</th><th>考试 / 科目</th><th>账户报名号</th><th>当前流程</th><th>缴费</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows(items)}</tbody></table></div>`;
|
||||
const current = 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>` : ''}`;
|
||||
}
|
||||
|
||||
function adminExams(exams) {
|
||||
return `<div class="admin-exam-grid">${exams.map(exam => `<article class="admin-exam-card ${exam.status}${exam.status === 'draft' ? ' editable' : ''}" ${exam.status === 'draft' ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><div class="exam-score-band"><span><small>考试总分</small><strong>${h(exam.totalScore)}</strong><em>分</em></span><span><small>合格规则</small><b>${h(passPolicyText(exam))}</b></span></div><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · 满分 ${h(subject.fullScore)} · ${subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${h(subject.passValue)}%` : `固定 ${h(subject.passScore)} 分`}</small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.registrationCount} 人报名 · ${exam.subjects.length} 科</span><div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></div></footer></article>`).join('')}</div>`;
|
||||
const active = exams.filter(exam => !exam.archivedAt);
|
||||
const archived = exams.filter(exam => exam.archivedAt).sort((left, right) => new Date(right.archivedAt) - new Date(left.archivedAt));
|
||||
const card = exam => `<article class="admin-exam-card ${exam.archivedAt ? 'archived' : exam.status}${exam.status === 'draft' && !exam.archivedAt ? ' editable' : ''}" ${exam.status === 'draft' && !exam.archivedAt ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.archivedAt ? 'archived' : exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><div class="exam-score-band"><span><small>考试总分</small><strong>${h(exam.totalScore)}</strong><em>分</em></span><span><small>合格规则</small><b>${h(passPolicyText(exam))}</b></span></div><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · 满分 ${h(subject.fullScore)} · ${subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${h(subject.passValue)}%` : `固定 ${h(subject.passScore)} 分`}</small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.archivedAt ? `${formatDate(exam.archivedAt, true)} 归档 · 成绩永久锁定` : `${exam.registrationCount} 人报名 · ${exam.subjects.length} 科`}</span>${exam.archivedAt ? '' : `<div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button><button class="row-action archive-exam-action" data-action="archive-exam" data-id="${h(exam.id)}" data-name="${h(exam.name)}">归档并锁定</button></div>`}</footer></article>`;
|
||||
const archiveShelf = archived.length ? `<details class="exam-archive-shelf"><summary><span><b>历史考试归档</b><small>${archived.length} 场 · 成绩永久锁定,点击按需查阅</small></span><strong>${archived.length}</strong></summary><div class="admin-exam-grid archived-exam-grid">${archived.map(card).join('')}</div></details>` : '';
|
||||
return `<div class="admin-exam-grid">${active.map(card).join('') || emptyState('没有进行中的考试', '新建考试,或在下方查阅已归档历史。')}</div>${archiveShelf}`;
|
||||
}
|
||||
|
||||
function adminNotices(notices) {
|
||||
@@ -97,7 +105,7 @@ export function createAdminViews(context) {
|
||||
|
||||
function adminAdmit(data) {
|
||||
const approved = data.registrations || [];
|
||||
const selectedExam = data.exams.find(exam => exam.approvedCount > 0) || data.exams[0];
|
||||
const selectedExam = data.exams.find(exam => !exam.archivedAt && exam.approvedCount > 0) || data.exams.find(exam => !exam.archivedAt) || data.exams[0];
|
||||
const selectedPlan = selectedExam?.plan;
|
||||
const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code;
|
||||
const form = data.canArrange ? (selectedExam ? `<section class="panel arrangement-config"><div class="panel-title"><div><h2>整场编排控制台</h2><p>先预检容量与档案,再以一个事务生成或替换整套编排。</p></div><span>${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位</span></div><form data-form="admission-arrangement"><div class="arrangement-fields"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selectedExam.id ? 'selected' : ''}>${h(exam.name)}(审核通过 ${exam.approvedCount} 人 / 已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label><label><span>混编范围 *</span><select name="mixingScope" required>${data.mixingScopes.map(item => `<option value="${h(item.code)}" ${item.code === (selectedPlan?.mixingScope || 'school') ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>准考证号规则 *</span><select name="numberRuleId" required>${data.rules.map(rule => `<option value="${h(rule.id)}" ${rule.id === selectedPlan?.numberRuleId ? 'selected' : ''}>${h(rule.name)}</option>`).join('')}</select></label><label><span>稳定编排种子</span><input name="seed" value="${h(selectedPlan?.randomSeed || selectedExam.code)}" maxlength="80"><small>相同数据与种子会得到相同顺序,便于复核。</small></label></div><div class="arrangement-actions"><button type="button" class="ghost-button" data-action="preview-arrangement">仅预检,不写入</button><button type="submit" class="solid-button">${selectedPlan ? '重新编排整场考试' : '生成整场编排'}</button></div></form><div data-arrangement-preview></div></section>` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。')) : '';
|
||||
@@ -118,19 +126,23 @@ export function createAdminViews(context) {
|
||||
|
||||
function adminResults(data) {
|
||||
const preview = state.resultImportPreview;
|
||||
const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => item.registrationCount) || data.exams[0];
|
||||
const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => !item.archivedAt && item.registrationCount) || data.exams.find(item => !item.archivedAt) || data.exams[0];
|
||||
if (activeExam) state.resultExamFilter = activeExam.id;
|
||||
const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results;
|
||||
const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []);
|
||||
const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null;
|
||||
const entry = state.user.adminLevel === 'super' ? `<section class="panel result-entry result-entry-console"><div class="panel-title"><div><h2>单科成绩录入</h2><p>按考试和科目缩小候选范围,已有成绩会自动回填。</p></div><span>实时校验</span></div><form data-form="result-entry"><div class="result-entry-fields"><label><span>考试 *</span><select name="examId" data-action="result-exam" required><option value="">请选择考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>考试科目 *</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请选择科目</option>${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" data-full-score="${h(subject.fullScore)}" data-pass-score="${h(subject.passScore ?? '')}" data-pass-text="${h(subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${subject.passValue}%` : `固定 ${subject.passScore} 分`)}">${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label><label><span>考生 *</span><select name="registrationId" data-action="result-candidate" required disabled><option value="">请先选择科目</option></select></label><label><span data-score-label>成绩 *</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级规则</span><input value="按同场同科排名自动计算" disabled></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示其独立及格规则。</p><div class="result-entry-submit"><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></div></form></section>` : '';
|
||||
const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `<section class="panel result-entry result-entry-console"><div class="panel-title"><div><h2>单科成绩录入</h2><p>按考试和科目缩小候选范围,已有成绩会自动回填。</p></div><span>实时校验</span></div><form data-form="result-entry"><div class="result-entry-fields"><label><span>考试 *</span><select name="examId" data-action="result-exam" required><option value="">请选择考试</option>${data.exams.filter(exam => !exam.archivedAt).map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>考试科目 *</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请选择科目</option>${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" data-full-score="${h(subject.fullScore)}" data-pass-score="${h(subject.passScore ?? '')}" data-pass-text="${h(subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${subject.passValue}%` : `固定 ${subject.passScore} 分`)}">${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label><label><span>考生 *</span><select name="registrationId" data-action="result-candidate" required disabled><option value="">请先选择科目</option></select></label><label><span data-score-label>成绩 *</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级规则</span><input value="按同场同科排名自动计算" disabled></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示其独立及格规则。</p><div class="result-entry-submit"><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></div></form></section>` : '';
|
||||
const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="table-scroll"><table class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${preview.rows.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div><form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : '';
|
||||
const appealLedger = `<section class="panel data-panel"><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>${examAppeals.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div></section>`;
|
||||
const examStrip = `<section class="result-exam-strip">${data.exams.map(exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`).join('')}</section>`;
|
||||
const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>成绩 Excel 工作区</strong><small>模板导入会先暂存预览,确认后才原子写入数据库</small></span><div><button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载模板</button><button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`;
|
||||
const examButton = exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''} ${exam.archivedAt ? 'archived' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.archivedAt ? `归档于 ${formatDate(exam.archivedAt, true)} · 已锁定` : `${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次`}</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`;
|
||||
const currentExams = data.exams.filter(exam => !exam.archivedAt);
|
||||
const archivedExams = data.exams.filter(exam => exam.archivedAt);
|
||||
const examStrip = `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher" ${activeExam?.archivedAt ? 'open' : ''}><summary>历史归档考试 <span>${archivedExams.length} 场 · 成绩永久锁定</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}`;
|
||||
const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}</strong><small>${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}</small></span><div>${activeExam?.archivedAt ? '' : '<button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载模板</button>'}<button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`;
|
||||
const metrics = `<section class="result-metric-grid"><article><small>报名考生</small><strong>${activeExam?.registrationCount ?? 0}</strong><span>本场已通过报名</span></article><article><small>录入进度</small><strong>${activeExam?.scored ?? 0}<em> / ${activeExam?.enrolledSubjects ?? 0}</em></strong><span>剩余 ${activeExam?.missing ?? 0} 科次</span></article><article><small>已发布</small><strong>${activeExam?.published ?? 0}</strong><span>草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条</span></article><article><small>成绩已出齐</small><strong>${activeExam?.complete ?? 0}</strong><span>人</span></article><article><small>整场合格率</small><strong>${passRate == null ? '—' : `${passRate}%`}</strong><span>按本场排名或所设规则判定</span></article><article><small>成绩复议</small><strong>${examAppeals.length}</strong><span>当前考试累计</span></article></section>`;
|
||||
const ledger = `<section class="panel data-panel result-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultTable" placeholder="搜索报名号、姓名、学校或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultTable" data-status="published">已发布</button><button data-action="status-filter" data-target="resultTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="resultTable" data-status="qualified">单科达线</button><button data-action="status-filter" data-target="resultTable" data-status="unqualified">单科未达线</button></div></div><div class="table-scroll"><table id="resultTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>排名 / 等级</th><th>单科及格规则</th><th>达线</th><th>发布</th><th>更新时间</th></tr></thead><tbody>${examResults.map(result => `<tr data-status="${result.published ? 'published' : 'draft'} ${result.qualified === true ? 'qualified' : result.qualified === false ? 'unqualified' : ''}"><td><div class="person-cell"><span>${h((result.candidateName || '?').slice(0,1))}</span><div><strong>${h(result.candidateName)}</strong><small class="mono">${h(result.candidateNumber)}</small><small>${h(result.schoolName)} · ${h(result.className)}</small></div></div></td><td><strong>${h(result.subjectName)}</strong><small>${h(result.examCode)}</small></td><td><strong class="result-score">${h(result.score)}<em> / ${h(result.fullScore)}</em></strong></td><td><strong>第 ${h(result.rank)} / ${h(result.cohortSize)} 名</strong><small>${h(result.grade)} · 前 ${h(result.rankPercent)}%</small></td><td><strong>${h(result.passText)}</strong></td><td>${result.qualified == null ? '<span class="result-neutral">不判定</span>' : result.qualified ? '<span class="result-qualified">达线</span>' : '<span class="result-unqualified">未达线</span>'}</td><td>${badge(result.published ? 'published' : 'draft')}</td><td>${formatDate(result.updatedAt || result.publishedAt, true)}</td></tr>`).join('') || '<tr><td colspan="8" class="empty-state">本场考试还没有成绩记录</td></tr>'}</tbody></table></div></section>`;
|
||||
return `${examStrip}${metrics}${toolbar}${importPreview}${entry}${ledger}${appealLedger}`;
|
||||
const archiveLock = activeExam?.archivedAt ? `<section class="exam-lock-banner"><span>${icons.check}</span><div><strong>本场考试已归档</strong><small>${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。</small></div></section>` : '';
|
||||
return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${ledger}${appealLedger}`;
|
||||
}
|
||||
|
||||
function adminUsers(data) {
|
||||
|
||||
@@ -129,7 +129,11 @@ export function createCandidateViews(context) {
|
||||
}
|
||||
|
||||
function candidateRegistrations(registrations) {
|
||||
return registrations.length ? `<div class="registration-cards">${registrations.map(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.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.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}</p>${reg.admitCard ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`).join('')}</div>` : 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 current = 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>` : ''}`;
|
||||
}
|
||||
|
||||
function candidateAdmit(registrations) {
|
||||
@@ -142,24 +146,28 @@ export function createCandidateViews(context) {
|
||||
const assignment = assignments.get(subject.id) || {};
|
||||
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · 考试考场序号 ${h(assignment.examRoomCode || '待定')} · ${h(assignment.roomName || assignment.room || '场地待定')}(${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat || '—')}</small></span>`;
|
||||
}).join('');
|
||||
return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>逐科详细安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸横向打印</small></div></article>`;
|
||||
const ticket = `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(reg.exam.archivedAt ? 'archived' : open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>逐科详细安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open && !reg.exam.archivedAt ? '' : 'disabled'}>${reg.exam.archivedAt ? '已归档' : open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>${reg.exam.archivedAt ? '历史准考证仅供查阅' : '下载后请使用 A4 纸横向打印'}</small></div></article>`;
|
||||
return reg.exam.archivedAt ? `<details class="candidate-archive-fold admit-archive-fold"><summary><span><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)} · ${formatDate(reg.exam.archivedAt, true)} 归档</small></span><b>查看历史准考证</b></summary>${ticket}</details>` : ticket;
|
||||
}).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态');
|
||||
}
|
||||
|
||||
function candidateResults(data) {
|
||||
const { results, summaries = [] } = data;
|
||||
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
||||
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
|
||||
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examId) : results.reduce((acc, item) => ((acc[item.examId] ||= []).push(item), acc), {});
|
||||
const completeSummaries = summaries.filter(item => item.complete);
|
||||
const overview = `<section class="candidate-result-overview"><article><small>已发布考试</small><strong>${Object.keys(grouped).length}</strong><span>场</span></article><article><small>已发布科目</small><strong>${results.length}</strong><span>科</span></article><article><small>整场已合格</small><strong>${completeSummaries.filter(item => item.qualified === true).length}</strong><span>场</span></article><article><small>复议处理中</small><strong>${results.filter(item => item.appeal?.status === 'pending').length}</strong><span>项</span></article></section>`;
|
||||
return `${overview}<div class="result-groups">${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([examName, items]) => {
|
||||
return `${overview}<div class="result-groups">${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([, items]) => {
|
||||
const examName = items[0].examName;
|
||||
const summary = summaries.find(item => item.examId === items[0].examId);
|
||||
const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线';
|
||||
const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `第 ${summary.rank} / ${summary.cohortSize} 名` : summary ? passPolicyText(summary) : '';
|
||||
const scores = items.map(item => {
|
||||
const appeal = item.appeal;
|
||||
const latestAction = appeal?.actions?.at(-1);
|
||||
const appealPanel = appeal?.status === 'pending'
|
||||
const appealPanel = item.archivedAt
|
||||
? `<div class="score-appeal-state archived-score-lock">${badge('archived')}<small>本场成绩已永久锁定,复议入口已关闭</small></div>`
|
||||
: appeal?.status === 'pending'
|
||||
? `<div class="score-appeal-state">${badge('pending')}<small>${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}</small></div>`
|
||||
: appeal?.status === 'approved'
|
||||
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
|
||||
@@ -167,7 +175,8 @@ export function createCandidateViews(context) {
|
||||
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
|
||||
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
|
||||
}).join('');
|
||||
return `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。</p><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
||||
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。'}</p><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
||||
return items[0].archivedAt ? `<details class="candidate-archive-fold result-archive-fold"><summary><span><strong>${h(examName)}</strong><small>${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定</small></span><b>历史成绩</b></summary>${panel}</details>` : panel;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export const statusLabels = {
|
||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||
published: '已发布', draft: '草稿', closed: '已结束',
|
||||
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||
};
|
||||
|
||||
@@ -66,6 +66,8 @@ export function createMysqlAdapter(context) {
|
||||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
|
||||
"ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'rank_percent'",
|
||||
'ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_value DOUBLE NOT NULL DEFAULT 60',
|
||||
'ALTER TABLE exams ADD COLUMN IF NOT EXISTS archived_at VARCHAR(35) NULL',
|
||||
'ALTER TABLE exams ADD COLUMN IF NOT EXISTS archived_by VARCHAR(64) NULL',
|
||||
'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS full_score DOUBLE NOT NULL DEFAULT 150',
|
||||
'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_score DOUBLE NOT NULL DEFAULT 90',
|
||||
"ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_rule ENUM('fixed_score', 'score_ratio', 'none') NOT NULL DEFAULT 'fixed_score'",
|
||||
@@ -99,6 +101,37 @@ export function createMysqlAdapter(context) {
|
||||
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
|
||||
];
|
||||
for (const statement of mysqlColumnMigrations) await pool.execute(statement);
|
||||
const [resultLockTriggers] = await pool.execute(`
|
||||
SELECT TRIGGER_NAME FROM information_schema.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME LIKE 'trg_results_lock_archived_%'
|
||||
`);
|
||||
const existingResultLockTriggers = new Set(resultLockTriggers.map(item => item.TRIGGER_NAME));
|
||||
const mysqlResultLockTriggers = {
|
||||
trg_results_lock_archived_insert: `CREATE TRIGGER trg_results_lock_archived_insert BEFORE INSERT ON results FOR EACH ROW
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id
|
||||
WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定';
|
||||
END IF;
|
||||
END`,
|
||||
trg_results_lock_archived_update: `CREATE TRIGGER trg_results_lock_archived_update BEFORE UPDATE ON results FOR EACH ROW
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id
|
||||
WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定';
|
||||
END IF;
|
||||
END`,
|
||||
trg_results_lock_archived_delete: `CREATE TRIGGER trg_results_lock_archived_delete BEFORE DELETE ON results FOR EACH ROW
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id
|
||||
WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定';
|
||||
END IF;
|
||||
END`
|
||||
};
|
||||
for (const [name, statement] of Object.entries(mysqlResultLockTriggers)) {
|
||||
if (!existingResultLockTriggers.has(name)) await pool.execute(statement);
|
||||
}
|
||||
const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'");
|
||||
if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number');
|
||||
const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
|
||||
@@ -155,6 +188,11 @@ export function createMysqlAdapter(context) {
|
||||
metadataRows[0].schema_version = 13;
|
||||
metadataRows[0].app_version = 13;
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 14) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 14;
|
||||
metadataRows[0].app_version = 14;
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
@@ -326,8 +364,8 @@ export function createMysqlAdapter(context) {
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 7) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 13) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 14) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1');
|
||||
}
|
||||
}
|
||||
if (!existing.length) {
|
||||
@@ -337,7 +375,7 @@ export function createMysqlAdapter(context) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 13, ?, ?, ?)
|
||||
VALUES (1, 14, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
|
||||
+41
-1
@@ -114,6 +114,8 @@ export const sqliteSchema = `
|
||||
pass_policy TEXT NOT NULL DEFAULT 'rank_percent' CHECK (pass_policy IN ('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none')),
|
||||
pass_value REAL NOT NULL DEFAULT 60,
|
||||
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
|
||||
archived_at TEXT,
|
||||
archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
@@ -401,6 +403,7 @@ export const sqliteSchema = `
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end);
|
||||
@@ -417,6 +420,39 @@ export const sqliteSchema = `
|
||||
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
|
||||
BEFORE INSERT ON results
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM registrations registration
|
||||
JOIN exams exam ON exam.id = registration.exam_id
|
||||
WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_update
|
||||
BEFORE UPDATE ON results
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM registrations registration
|
||||
JOIN exams exam ON exam.id = registration.exam_id
|
||||
WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_delete
|
||||
BEFORE DELETE ON results
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM registrations registration
|
||||
JOIN exams exam ON exam.id = registration.exam_id
|
||||
WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, '归档考试成绩已永久锁定');
|
||||
END;
|
||||
`;
|
||||
|
||||
export const mysqlSchema = [
|
||||
@@ -553,10 +589,14 @@ export const mysqlSchema = [
|
||||
pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'rank_percent',
|
||||
pass_value DOUBLE NOT NULL DEFAULT 60,
|
||||
status ENUM('draft', 'published', 'closed') NOT NULL,
|
||||
archived_at VARCHAR(35) NULL,
|
||||
archived_by VARCHAR(64) NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_exams_code (code),
|
||||
KEY idx_exams_status_registration (status, registration_start, registration_end)
|
||||
KEY idx_exams_status_registration (status, registration_start, registration_end),
|
||||
KEY idx_exams_archive (archived_at, exam_end),
|
||||
CONSTRAINT fk_exams_archived_by FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS exam_subjects (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
|
||||
@@ -41,7 +41,8 @@ export function createSqliteAdapter(context) {
|
||||
]);
|
||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||||
ensureColumns('exams', [
|
||||
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60']
|
||||
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'],
|
||||
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
||||
]);
|
||||
ensureColumns('exam_subjects', [
|
||||
['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90'],
|
||||
@@ -236,6 +237,9 @@ export function createSqliteAdapter(context) {
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 14) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
@@ -386,8 +390,8 @@ export function createSqliteAdapter(context) {
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 14) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 14, app_version = 14 WHERE id = 1').run();
|
||||
}
|
||||
|
||||
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||||
@@ -396,7 +400,7 @@ export function createSqliteAdapter(context) {
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 13, ?, ?, ?)
|
||||
VALUES (1, 14, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
|
||||
@@ -753,6 +753,7 @@ export function createAdminRoutes(context) {
|
||||
const body = await readJson(request);
|
||||
const registration = db.registrations.find(item => item.id === registrationMatch[1]);
|
||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||
if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,报名流程已冻结');
|
||||
if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内');
|
||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
@@ -802,6 +803,7 @@ export function createAdminRoutes(context) {
|
||||
const note = cleanText(body.reviewNote, 300);
|
||||
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议流程已永久锁定');
|
||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||
const finalStep = instance.currentStep >= workflow.steps.length;
|
||||
let reviewedScore = null;
|
||||
@@ -841,6 +843,8 @@ export function createAdminRoutes(context) {
|
||||
const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/);
|
||||
if (request.method === 'POST' && arrangementMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const arrangementExam = db.exams.find(item => item.id === arrangementMatch[1]);
|
||||
if (arrangementExam?.archivedAt) return sendError(response, 409, '该考试已归档,不能重新编排准考证');
|
||||
const body = await readJson(request);
|
||||
const generatedAt = nowIso();
|
||||
const result = buildAdmissionArrangement(db, {
|
||||
@@ -897,12 +901,32 @@ export function createAdminRoutes(context) {
|
||||
await database.createExam(exam, log);
|
||||
return sendJson(response, 201, { ok: true, exam });
|
||||
}
|
||||
const examArchiveMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/archive$/);
|
||||
if (request.method === 'POST' && examArchiveMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const exam = db.exams.find(item => item.id === examArchiveMatch[1]);
|
||||
if (!exam) return sendError(response, 404, '考试不存在');
|
||||
if (exam.archivedAt) return sendError(response, 409, '该考试已经归档,归档操作不可撤销');
|
||||
const resultIds = new Set(db.results.filter(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
return registration?.examId === exam.id;
|
||||
}).map(result => result.id));
|
||||
const pendingAppeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && instance.status === 'pending' && resultIds.has(instance.businessId));
|
||||
if (pendingAppeals.length) return sendError(response, 409, `本场还有 ${pendingAppeals.length} 项成绩复议待处理,请先办结后再归档`);
|
||||
exam.archivedAt = nowIso();
|
||||
exam.archivedBy = user.id;
|
||||
exam.status = 'closed';
|
||||
const log = logAction(db, user, '归档考试并永久锁定成绩', `${exam.name} · ${exam.code}`);
|
||||
await database.archiveExam(exam, log);
|
||||
return sendJson(response, 200, { ok: true, exam: publicExam(exam), message: '考试已归档,全部成绩已永久锁定' });
|
||||
}
|
||||
const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && examMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === examMatch[1]);
|
||||
if (!exam) return sendError(response, 404, '考试不存在');
|
||||
if (exam.archivedAt) return sendError(response, 409, '该考试已归档,所有配置和成绩均已锁定');
|
||||
const originalStatus = exam.status;
|
||||
const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'];
|
||||
const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null || body.passPolicy != null || body.passValue != null;
|
||||
@@ -1021,6 +1045,7 @@ export function createAdminRoutes(context) {
|
||||
const registration = db.registrations.find(item => item.id === body.registrationId && item.status === 'approved');
|
||||
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩已永久锁定');
|
||||
if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目');
|
||||
const score = Number(body.score);
|
||||
const subject = exam.subjects.find(item => item.id === body.subjectId);
|
||||
|
||||
@@ -102,7 +102,7 @@ export function createCandidateRoutes(context) {
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/exams') {
|
||||
const registrations = db.registrations.filter(item => item.userId === user.id);
|
||||
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null }));
|
||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null }));
|
||||
return sendJson(response, 200, { ok: true, profileStatus: profile.status, exams });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/registrations') {
|
||||
@@ -111,7 +111,7 @@ export function createCandidateRoutes(context) {
|
||||
if (request.method === 'POST' && pathname === '/api/candidate/registrations') {
|
||||
if (profile.status !== 'approved') return sendError(response, 403, '个人资料审核通过后才能报名考试');
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === body.examId && item.status === 'published');
|
||||
const exam = db.exams.find(item => item.id === body.examId && item.status === 'published' && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 404, '考试不存在或尚未发布');
|
||||
const state = publicExam(exam).registrationState;
|
||||
if (state !== 'open') return sendError(response, 400, state === 'upcoming' ? '报名尚未开始' : '报名已经截止');
|
||||
@@ -134,7 +134,7 @@ export function createCandidateRoutes(context) {
|
||||
const rank = resultRankInfo(db, result);
|
||||
const pass = subjectPassEvaluation(db, result, subject);
|
||||
return {
|
||||
...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart,
|
||||
...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart, archivedAt: exam.archivedAt || null,
|
||||
subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150,
|
||||
passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
|
||||
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified,
|
||||
@@ -149,13 +149,14 @@ export function createCandidateRoutes(context) {
|
||||
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
|
||||
const registration = db.registrations.find(item => item.id === result?.registrationId && item.userId === user.id);
|
||||
if (!result || !registration) return sendError(response, 404, '已发布成绩不存在或不属于当前考生');
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议入口已永久锁定');
|
||||
if (pendingWorkflow(db, 'score_appeal', result.id)) return sendError(response, 409, '该科成绩已有待处理复议,请勿重复提交');
|
||||
const body = await readJson(request);
|
||||
const reason = cleanText(body.reason, 500);
|
||||
if (reason.length < 5) return sendError(response, 400, '请至少填写 5 个字的复议理由');
|
||||
const { instance, action } = createWorkflowSubmission(db, 'score_appeal', result.id, profile, user.id);
|
||||
action.note = reason;
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||
const log = logAction(db, user, '提交成绩复议', `${exam?.name || ''} · ${subject?.name || ''}`);
|
||||
await database.createWorkflow(instance, action, log);
|
||||
|
||||
@@ -51,8 +51,8 @@ export function createPublicRoutes(context) {
|
||||
const db = await readDb();
|
||||
if (pathname === '/api/public/home') {
|
||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||
const exams = db.exams.filter(item => item.status === 'published').map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: db.exams.filter(item => item.status === 'published').length, registrations: db.registrations.length } });
|
||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
||||
if (noticeMatch) {
|
||||
|
||||
+40
@@ -434,6 +434,46 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; transition-duration:.01ms !important; } }
|
||||
|
||||
/* 考试归档:以档案封存条区分当前考务与只读历史。 */
|
||||
.status-archived { color:#596272; background:#e9ecf1; }
|
||||
.admin-exam-card.archived { border-style:dashed; border-color:#c7ccd5; background:#f6f7f9; box-shadow:none; }
|
||||
.admin-exam-card.archived::before { content:"封存"; position:absolute; top:21px; right:-31px; width:112px; padding:4px 0; color:#fff; background:#697180; font:700 8px Consolas,monospace; letter-spacing:.16em; text-align:center; transform:rotate(38deg); }
|
||||
.archive-exam-action { color:#a33f39; border-color:#e2bbb8; }
|
||||
.exam-archive-shelf,.candidate-archive-fold,.result-archive-switcher { margin-top:18px; border:1px dashed #c7ced9; border-radius:12px; background:#f5f6f8; }
|
||||
.exam-archive-shelf > summary,.candidate-archive-fold > summary,.result-archive-switcher > summary { display:flex; align-items:center; justify-content:space-between; gap:18px; padding:17px 20px; color:#4f5868; cursor:pointer; list-style:none; }
|
||||
.exam-archive-shelf > summary::-webkit-details-marker,.candidate-archive-fold > summary::-webkit-details-marker,.result-archive-switcher > summary::-webkit-details-marker { display:none; }
|
||||
.exam-archive-shelf > summary span,.candidate-archive-fold > summary span { display:grid; gap:3px; }
|
||||
.exam-archive-shelf > summary b,.candidate-archive-fold > summary strong { font-size:11px; }
|
||||
.exam-archive-shelf > summary small,.candidate-archive-fold > summary small,.result-archive-switcher > summary span { color:#858d9b; font-size:8px; }
|
||||
.exam-archive-shelf > summary > strong,.candidate-archive-fold > summary > b { min-width:31px; height:31px; display:grid; place-items:center; border:1px solid #d3d7de; border-radius:50%; color:#616a78; background:#fff; font-size:9px; }
|
||||
.exam-archive-shelf[open] > summary,.candidate-archive-fold[open] > summary { border-bottom:1px dashed #c7ced9; }
|
||||
.archived-exam-grid { padding:18px; }
|
||||
.candidate-archive-fold .registration-cards,.candidate-archive-fold > .result-panel,.candidate-archive-fold > .admit-ticket { margin:18px; }
|
||||
.admit-archive-fold { margin:0; }
|
||||
.result-archive-fold { margin-top:0; }
|
||||
.result-archive-switcher { margin:0 0 14px; }
|
||||
.result-archive-switcher > summary { justify-content:flex-start; padding:12px 16px; font-size:9px; font-weight:700; }
|
||||
.result-archive-switcher .result-exam-strip { margin:0; padding:4px 14px 15px; }
|
||||
.result-exam-strip > button.archived { border-style:dashed; box-shadow:none; background:#f6f7f9; }
|
||||
.result-exam-strip > button.archived i { background:#9299a5; }
|
||||
.exam-lock-banner { display:flex; align-items:center; gap:14px; margin-bottom:14px; padding:15px 18px; border:1px solid #d1d5dc; border-left:4px solid #697180; border-radius:10px; color:#4f5868; background:#f2f3f5; }
|
||||
.exam-lock-banner > span { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:#697180; }
|
||||
.exam-lock-banner svg { width:16px; fill:none; stroke:currentColor; stroke-width:2; }
|
||||
.exam-lock-banner div { display:grid; gap:3px; }
|
||||
.exam-lock-banner strong { font-size:10px; }
|
||||
.exam-lock-banner small { color:#7e8693; font-size:8px; }
|
||||
.result-panel.archived { filter:saturate(.72); }
|
||||
.archived-score-lock { border-color:#d4d8df; background:#f0f2f5; }
|
||||
.admin-registration-archive .table-scroll { margin:0 18px 18px; border:1px solid #dfe2e7; border-radius:9px; background:#fff; }
|
||||
.archive-readonly-label { color:#777f8d; font-size:8px; }
|
||||
@media (max-width:620px) {
|
||||
.exam-card-actions { align-items:stretch; flex-direction:column; }
|
||||
.exam-archive-shelf > summary,.candidate-archive-fold > summary { padding:14px; }
|
||||
.archived-exam-grid { padding:12px; }
|
||||
.candidate-archive-fold .registration-cards,.candidate-archive-fold > .result-panel,.candidate-archive-fold > .admit-ticket { margin:12px; }
|
||||
.admin-registration-archive .table-scroll { margin:0 12px 12px; }
|
||||
}
|
||||
|
||||
/* Role scopes, numbering and workflow studio */
|
||||
.hidden { display:none !important; }
|
||||
.scope-banner { display:flex; align-items:center; gap:14px; margin-bottom:14px; padding:14px 18px; border:1px solid #d8e1f3; border-radius:11px; background:linear-gradient(90deg,#edf2fb,#f9fbff); }
|
||||
|
||||
+34
-1
@@ -83,6 +83,8 @@ try {
|
||||
assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件');
|
||||
assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭');
|
||||
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
||||
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
||||
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
const inspector = new DatabaseSync(testDb, { readOnly: true });
|
||||
@@ -92,14 +94,18 @@ try {
|
||||
ORDER BY name
|
||||
`).all().map(row => row.name);
|
||||
const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version;
|
||||
const resultLockTriggers = inspector.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'trg_results_lock_archived_%'").all().map(row => row.name);
|
||||
const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name);
|
||||
const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_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 userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
|
||||
inspector.close();
|
||||
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
||||
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
||||
assert.equal(schemaVersion, 13, '账户归档应使用 v13 数据结构');
|
||||
assert.equal(schemaVersion, 14, '考试归档应使用 v14 数据结构');
|
||||
assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员');
|
||||
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
||||
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
||||
assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
|
||||
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
|
||||
@@ -616,6 +622,33 @@ try {
|
||||
assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[0].id).score, 128, '考生端应立即看到复议后的语文成绩');
|
||||
assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[2].id).score, 89, '考生端应立即看到复议后的外语成绩');
|
||||
|
||||
assert.equal((await schoolAdmin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' })).response.status, 403, '只有超级管理员可以归档考试');
|
||||
const archivedExam = await admin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' });
|
||||
assert.equal(archivedExam.response.status, 200, '超级管理员应能归档考试');
|
||||
assert.ok(archivedExam.data.exam.archivedAt, '归档应记录不可逆时间戳');
|
||||
assert.equal(archivedExam.data.exam.status, 'closed', '归档考试应退出公开发布状态');
|
||||
assert.equal((await admin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' })).response.status, 409, '归档操作不可重复或撤销');
|
||||
assert.equal((await admin.request(`/api/admin/exams/${exam.id}`, { method: 'PATCH', body: { status: 'published' } })).response.status, 409, '归档后考试配置不得再修改');
|
||||
assert.equal((await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody })).response.status, 409, '归档后不得重新编排准考证');
|
||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1, published: true } })).response.status, 409, '归档后手工录入不得修改成绩');
|
||||
const lockInspector = new DatabaseSync(testDb);
|
||||
assert.throws(() => lockInspector.prepare('UPDATE results SET score = 1 WHERE id = ?').run(appealResults[0].id), /永久锁定/, '绕过接口直接写库也不得修改归档成绩');
|
||||
lockInspector.close();
|
||||
const archivedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 1, published: '发布' }]));
|
||||
const archivedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: archivedResultFile });
|
||||
assert.equal(archivedPreview.response.status, 200, '归档成绩 Excel 仍应返回逐行预览');
|
||||
assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交');
|
||||
assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩');
|
||||
assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议');
|
||||
const archivedAdminResults = await admin.request('/api/admin/results');
|
||||
assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态');
|
||||
const archivedCandidateResults = await candidate.request('/api/candidate/results');
|
||||
assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区');
|
||||
const archivedRegistrations = await candidate.request('/api/candidate/registrations');
|
||||
assert.ok(archivedRegistrations.data.registrations.find(item => item.exam.id === exam.id)?.exam.archivedAt, '报名和准考证信息应同步进入归档折叠区');
|
||||
const homeAfterArchive = await anonymous.request('/api/public/home');
|
||||
assert.ok(!homeAfterArchive.data.exams.some(item => item.id === exam.id), '归档考试不得继续出现在公开考试列表');
|
||||
|
||||
const workflowDefinitions = await admin.request('/api/admin/workflows');
|
||||
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
||||
assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批');
|
||||
|
||||
Reference in New Issue
Block a user