diff --git a/README.md b/README.md index ec1bb5d..25526db 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ - 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账 - 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论 - 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库 +- 超级管理员可将整场考试不可逆归档;归档后手工录入、Excel 导入、复议改分和考试配置全部锁定,历史报名、准考证与成绩默认折叠展示 - 管理员与考生接口权限隔离 ### 系统能力 @@ -84,7 +85,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `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 src/data/china-regions.mjs`。 diff --git a/app.js b/app.js index 90b0123..22e6cda 100644 --- a/app.js +++ b/app.js @@ -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); diff --git a/database.mjs b/database.mjs index 12dbcf4..961b83c 100644 --- a/database.mjs +++ b/database.mjs @@ -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( diff --git a/server.mjs b/server.mjs index a17f4fd..a017cc7 100644 --- a/server.mjs +++ b/server.mjs @@ -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('该考生没有已通过的本场考试报名'); diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index d484778..333b7b6 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -84,11 +84,19 @@ export function createAdminViews(context) { function adminRegistrations(registrations) { const readOnly = false; - return `
${registrations.map(reg => ``).join('')}
考生考试 / 科目账户报名号当前流程缴费状态操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.status)}
`; + const rows = items => items.map(reg => `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.exam.archivedAt ? 'archived' : reg.status)}${reg.exam.archivedAt ? '只读封存' : ``}`).join(''); + const table = (items, id) => `
${rows(items)}
考生考试 / 科目账户报名号当前流程缴费状态操作
`; + const current = registrations.filter(reg => !reg.exam.archivedAt); + const archived = registrations.filter(reg => reg.exam.archivedAt); + return `
${table(current, 'registrationTable')}
${archived.length ? `
归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}${table(archived, 'archivedRegistrationTable')}
` : ''}`; } function adminExams(exams) { - return `
${exams.map(exam => `
${h(exam.code)}${badge(exam.status)}

${h(exam.name)}

${h(exam.description)}

考试总分${h(exam.totalScore)}合格规则${h(passPolicyText(exam))}
报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · 满分 ${h(subject.fullScore)} · ${subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${h(subject.passValue)}%` : `固定 ${h(subject.passScore)} 分`}`).join('') || '科目待配置'}
${exam.registrationCount} 人报名 · ${exam.subjects.length} 科
${exam.status === 'draft' ? `` : ''}
`).join('')}
`; + 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 => `
${h(exam.code)}${badge(exam.archivedAt ? 'archived' : exam.status)}

${h(exam.name)}

${h(exam.description)}

考试总分${h(exam.totalScore)}合格规则${h(passPolicyText(exam))}
报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · 满分 ${h(subject.fullScore)} · ${subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${h(subject.passValue)}%` : `固定 ${h(subject.passScore)} 分`}`).join('') || '科目待配置'}
${exam.archivedAt ? `${formatDate(exam.archivedAt, true)} 归档 · 成绩永久锁定` : `${exam.registrationCount} 人报名 · ${exam.subjects.length} 科`}${exam.archivedAt ? '' : `
${exam.status === 'draft' ? `` : ''}
`}
`; + const archiveShelf = archived.length ? `
历史考试归档${archived.length} 场 · 成绩永久锁定,点击按需查阅${archived.length}
${archived.map(card).join('')}
` : ''; + return `
${active.map(card).join('') || emptyState('没有进行中的考试', '新建考试,或在下方查阅已归档历史。')}
${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 ? `

整场编排控制台

先预检容量与档案,再以一个事务生成或替换整套编排。

${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位
` : 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' ? `

单科成绩录入

按考试和科目缩小候选范围,已有成绩会自动回填。

实时校验

选择科目后显示其独立及格规则。

` : ''; + const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `

单科成绩录入

按考试和科目缩小候选范围,已有成绩会自动回填。

实时校验

选择科目后显示其独立及格规则。

` : ''; const importPreview = preview ? `
EXCEL STAGING AREA

${h(preview.fileName || '成绩导入预览')}

此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。

总行数${preview.summary.total}可提交${preview.summary.valid}有错误${preview.summary.invalid}新增 / 更新${preview.summary.create} / ${preview.summary.update}将发布${preview.summary.publish}
${preview.rows.map(row => ``).join('')}
Excel 行考生考试 / 科目成绩独立及格线发布写入方式 / 校验
${h(row.sourceRow)}${h(row.candidateName || '未匹配')}${h(row.candidateNumber)}${h(row.examName || row.examCode)}${h(row.subjectName)}${Number.isFinite(row.score) ? h(row.score) : '—'}${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}${h(row.passText || '—')}${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}${badge(row.published ? 'published' : 'draft')}${row.errors.length ? `
    ${row.errors.map(error => `
  • ${h(error)}
  • `).join('')}
` : `${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}校验通过`}

${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}

` : ''; const appealLedger = `

本场成绩复议

复议按考生所属班级和学校自动分配,可在流程中心办理。

${examAppeals.map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
本场暂无成绩复议申请
`; - const examStrip = `
${data.exams.map(exam => ``).join('')}
`; - const toolbar = `
成绩 Excel 工作区模板导入会先暂存预览,确认后才原子写入数据库
${state.user.adminLevel === 'super' ? '' : ''}
`; + const examButton = exam => ``; + const currentExams = data.exams.filter(exam => !exam.archivedAt); + const archivedExams = data.exams.filter(exam => exam.archivedAt); + const examStrip = `
${currentExams.map(examButton).join('')}
${archivedExams.length ? `
历史归档考试 ${archivedExams.length} 场 · 成绩永久锁定
${archivedExams.map(examButton).join('')}
` : ''}`; + const toolbar = `
${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}
${activeExam?.archivedAt ? '' : ''}${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '' : ''}
`; const metrics = `
报名考生${activeExam?.registrationCount ?? 0}本场已通过报名
录入进度${activeExam?.scored ?? 0} / ${activeExam?.enrolledSubjects ?? 0}剩余 ${activeExam?.missing ?? 0} 科次
已发布${activeExam?.published ?? 0}草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条
成绩已出齐${activeExam?.complete ?? 0}
整场合格率${passRate == null ? '—' : `${passRate}%`}按本场排名或所设规则判定
成绩复议${examAppeals.length}当前考试累计
`; const ledger = `
${examResults.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
`; - return `${examStrip}${metrics}${toolbar}${importPreview}${entry}${ledger}${appealLedger}`; + const archiveLock = activeExam?.archivedAt ? `
${icons.check}
本场考试已归档${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。
` : ''; + return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${ledger}${appealLedger}`; } function adminUsers(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 62381ed..98144c4 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -129,7 +129,11 @@ export function createCandidateViews(context) { } function candidateRegistrations(registrations) { - return registrations.length ? `
${registrations.map(reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}

${reg.admitCard ? `` : ''}
`).join('')}
` : emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); + if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); + const card = reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.exam.archivedAt ? 'archived' : reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
责任人
${h(reg.workflow?.assignee?.displayName || '—')}
缴费状态
${badge(reg.paymentStatus)}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : '本次报名已经确认,请留意准考证下载通知。'}

${reg.admitCard && !reg.exam.archivedAt ? `` : ''}
`; + const current = registrations.filter(reg => !reg.exam.archivedAt); + const archived = registrations.filter(reg => reg.exam.archivedAt); + return `
${current.map(card).join('')}
${archived.length ? `
历史报名记录${archived.length} 场归档考试 · 点击查阅${archived.length}
${archived.map(card).join('')}
` : ''}`; } function candidateAdmit(registrations) { @@ -142,24 +146,28 @@ export function createCandidateViews(context) { const assignment = assignments.get(subject.id) || {}; return `${h(subject.name)}${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 || '—')}`; }).join(''); - return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
逐科详细安排
${subjectRows}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸横向打印
`; + const ticket = `
${h(reg.exam.code)}${badge(reg.exam.archivedAt ? 'archived' : open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
逐科详细安排
${subjectRows}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
${reg.exam.archivedAt ? '历史准考证仅供查阅' : '下载后请使用 A4 纸横向打印'}
`; + return reg.exam.archivedAt ? `
${h(reg.exam.name)}${h(reg.exam.code)} · ${formatDate(reg.exam.archivedAt, true)} 归档查看历史准考证${ticket}
` : ticket; }).join('')}` : 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 = `
已发布考试${Object.keys(grouped).length}
已发布科目${results.length}
整场已合格${completeSummaries.filter(item => item.qualified === true).length}
复议处理中${results.filter(item => item.appeal?.status === 'pending').length}
`; - return `${overview}
${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([examName, items]) => { + return `${overview}
${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 + ? `
${badge('archived')}本场成绩已永久锁定,复议入口已关闭
` + : appeal?.status === 'pending' ? `
${badge('pending')}${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}
` : appeal?.status === 'approved' ? `
${badge('approved')}${h(latestAction?.note || '复议流程已完成')}
` @@ -167,7 +175,8 @@ export function createCandidateViews(context) { const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified'; return `
${h(item.subjectName)}${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}
${h(item.score)} / ${h(item.fullScore)}${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%
本科排名${h(item.passText || '不设单科线')}
${appealPanel}
`; }).join(''); - return `
${h(items[0].examCode)}

${h(examName)}

最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}

等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。

已发布 ${items.length} 科
`; + const panel = `
${h(items[0].examCode)}

${h(examName)}

${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}

${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。'}

已发布 ${items.length} 科
`; + return items[0].archivedAt ? `
${h(examName)}${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定历史成绩${panel}
` : panel; }).join('')}
`; } diff --git a/src/client/ui.mjs b/src/client/ui.mjs index 5252494..1152f1d 100644 --- a/src/client/ui.mjs +++ b/src/client/ui.mjs @@ -1,6 +1,6 @@ export const statusLabels = { pending: '待审核', approved: '已通过', rejected: '需修改', - published: '已发布', draft: '草稿', closed: '已结束', + published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档', open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费', super: '超级管理员', school: '校级管理员', class: '班级管理员' }; diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 4d0fb69..d50c256 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -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); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index 4f1d7ba..7508f96 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -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, diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index da4158c..c487399 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -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'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 7d25d8f..3159db6 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -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); diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 275a789..b32bd42 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -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); diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index 161e59c..6229726 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -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) { diff --git a/styles.css b/styles.css index cf26e24..050a84d 100644 --- a/styles.css +++ b/styles.css @@ -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); } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 0654d35..0191548 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -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'), '流程设计应包含考点考场变更审批');