diff --git a/README.md b/README.md index 1749257..d623bef 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,18 @@ - 考务指标与审计日志 - 考生资料审核、通过或退回修改 - 考试报名及科目审核 -- 创建考试并结构化配置科目日期、时间、费用、满分与单科合格分,自动汇总总分 -- 支持固定总分线、总分得分率、排名前百分比、单科均达线及不判定五类合格策略 +- 创建考试并结构化配置科目日期、时间、费用和满分;每科可独立选择固定分、排名前百分比或不设单科线,并汇总总分 +- 支持固定总分线、总成绩排名前百分比、单科均达线及不判定四类整场合格策略 - 通知发布、草稿、撤回及首页置顶 - 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编 - 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则 - 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排 - 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现 -- 按科目满分校验单科成绩、按得分率计算等级,并在全部科目发布后汇总总分与合格结果 +- 每科可独立设置固定及格分、排名前百分比或不设单科线;成绩等级按同场同科排名百分位自动计算 +- 默认排名等级区间为前 10% A+、前 25% A、前 50% B+、前 70% B、前 90% C、其余 D;同分共享名次 +- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账 +- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论 +- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库 - 管理员与考生接口权限隔离 ### 系统能力 @@ -80,7 +84,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,数据库结构版本为 v9,不兼容旧准考证编排数据;启动时会替换旧准考证相关表,也可运行 `npm run reset-db` 重建完整开发库。v9 增加整场编排方案、五级混编、可扩展号码规则和逐科考场座位。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v12;v9 增加整场编排方案,v10 增加准考证位置快照,v11 增加科目独立及格规则并移除 SQLite 成绩表固定 150 分上限,v12 将所有百分比合格规则统一为排名百分比。旧库启动时会自动迁移,也可运行 `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`。 @@ -155,7 +159,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 ## 项目结构 diff --git a/app.js b/app.js index eee777f..949b1c6 100644 --- a/app.js +++ b/app.js @@ -177,6 +177,7 @@ document.addEventListener('click', async event => { const query = new URLSearchParams(); if (target.dataset.template === '1') query.set('template', '1'); if (target.dataset.batchId) query.set('batchId', target.dataset.batchId); + if (target.dataset.examId) query.set('examId', target.dataset.examId); window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`; return; } @@ -184,6 +185,14 @@ document.addEventListener('click', async event => { document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click(); return; } + if (action === 'result-exam-filter') { + state.resultExamFilter = target.dataset.id; + return renderRoute(); + } + if (action === 'cancel-result-import') { + state.resultImportPreview = null; + return renderRoute(); + } if (action === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); if (registration.admitCard) return openAdmitPreview(registration); @@ -215,7 +224,7 @@ document.addEventListener('click', async event => { } if (action === 'status-filter') { target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target)); - document.querySelectorAll(`#${target.dataset.target} tbody tr`).forEach(row => row.hidden = target.dataset.status !== 'all' && row.dataset.status !== target.dataset.status); + document.querySelectorAll(`#${target.dataset.target} tbody tr`).forEach(row => row.hidden = target.dataset.status !== 'all' && !String(row.dataset.status || '').split(/\s+/).includes(target.dataset.status)); } } catch (error) { toast('操作未完成', error.message); } }); @@ -246,11 +255,15 @@ document.addEventListener('change', event => { input.value = ''; (async () => { try { - toast('正在导入 Excel', `${file.name} · 逐行校验并写入,错误会标出具体行`); + toast(resource === 'results' ? '正在生成导入预览' : '正在导入 Excel', resource === 'results' ? `${file.name} · 当前不会写入数据库` : `${file.name} · 逐行校验并写入,错误会标出具体行`); const result = await api(`/api/admin/excel/${resource}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() }); - if (resource === 'account_quotas') { + if (resource === 'results') { + state.resultImportPreview = { ...result, fileName: file.name }; + toast(result.summary.invalid ? '预览完成,发现错误' : '预览校验通过', `${result.summary.valid}/${result.summary.total} 行可提交,数据库尚未修改`); + renderRoute(); + } else if (resource === 'account_quotas') { document.querySelectorAll('[data-class-id]').forEach(field => { field.value = '0'; }); result.quotas.forEach(item => { const field = document.querySelector(`[data-class-id="${item.classId}"]`); if (field) field.value = item.count; }); toast('班级配额已填入', `已读取 ${result.quotas.length} 个班级,请核对后提交审批`); @@ -275,25 +288,44 @@ document.addEventListener('change', event => { form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super'); form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class'); } - if (event.target.matches('[data-action="result-registration"]')) { - const option = event.target.selectedOptions[0]; - const select = document.querySelector('#resultSubject'); - const subjects = option?.dataset.subjects ? JSON.parse(option.dataset.subjects) : []; - select.innerHTML = `${subjects.map(subject => ``).join('')}`; - select.dispatchEvent(new Event('change', { bubbles: true })); + if (event.target.matches('[data-action="result-exam"]')) { + const form = event.target.closest('form'); + const exam = state.pageData?.exams?.find(item => item.id === event.target.value); + const subjectSelect = form?.querySelector('[name="subjectId"]'); + const candidateSelect = form?.querySelector('[name="registrationId"]'); + if (subjectSelect) subjectSelect.innerHTML = `${(exam?.subjects || []).map(subject => ``).join('')}`; + if (candidateSelect) { candidateSelect.innerHTML = ''; candidateSelect.disabled = true; } + subjectSelect?.dispatchEvent(new Event('change', { bubbles: true })); } if (event.target.matches('[data-action="result-subject"]')) { const form = event.target.closest('form'); const option = event.target.selectedOptions[0]; + const examId = form?.querySelector('[name="examId"]')?.value; + const subjectId = event.target.value; const fullScore = Number(option?.dataset.fullScore || 0); - const passScore = Number(option?.dataset.passScore || 0); + const passText = option?.dataset.passText || ''; const scoreInput = form?.querySelector('[name="score"]'); if (scoreInput) scoreInput.max = fullScore || ''; + const candidateSelect = form?.querySelector('[name="registrationId"]'); + const registrations = (state.pageData?.registrations || []).filter(item => item.examId === examId && item.subjectIds.includes(subjectId)); + if (candidateSelect) { + candidateSelect.disabled = !subjectId; + candidateSelect.innerHTML = `${registrations.map(item => ``).join('')}`; + } const label = form?.querySelector('[data-score-label]'); const hint = form?.querySelector('[data-score-hint]'); if (label) label.textContent = fullScore ? `成绩(0—${fullScore})` : '成绩'; - if (hint) hint.textContent = fullScore ? `本科满分 ${fullScore} 分,单科合格线 ${passScore} 分。` : '选择科目后显示满分与单科合格线。'; + if (hint) hint.textContent = fullScore ? `本科满分 ${fullScore} 分;独立及格规则:${passText}。` : '选择科目后显示其独立及格规则。'; } + if (event.target.matches('[data-action="result-candidate"]')) { + const form = event.target.closest('form'); + const result = state.pageData?.results?.find(item => item.registrationId === event.target.value && item.subjectId === form?.subjectId?.value); + if (form?.score) form.score.value = result?.score ?? ''; + if (form?.published) form.published.checked = result ? result.published : true; + const button = form?.querySelector('button[type="submit"]'); + if (button) button.textContent = result ? '更新这条成绩' : '保存成绩'; + } + if (event.target.matches('[name="subjectPassRule"]')) refreshSubjectPassRuleRow(event.target.closest('.exam-subject-editor')); if (event.target.matches('[name="passPolicy"]')) { refreshExamScoringForm(event.target.closest('form')); } @@ -409,7 +441,7 @@ document.addEventListener('submit', async event => { : body.businessType === 'candidate_account_batch' ? `/api/admin/candidate-account-batches/${body.businessId}` : `/api/admin/score-appeals/${body.businessId}`; - await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote } }); + await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote, ...(body.reviewedScore == null || body.reviewedScore === '' ? {} : { reviewedScore: Number(body.reviewedScore) }) } }); closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute(); } else if (kind === 'flow-transfer') { const body = formObject(form); @@ -428,7 +460,8 @@ document.addEventListener('submit', async event => { body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({ name: row.querySelector('[name="subjectName"]').value.trim(), fullScore: Number(row.querySelector('[name="subjectFullScore"]').value), - passScore: Number(row.querySelector('[name="subjectPassScore"]').value), + passRule: row.querySelector('[name="subjectPassRule"]').value, + passValue: Number(row.querySelector('[name="subjectPassValue"]').value || 0), date: row.querySelector('[name="subjectDate"]').value, start: row.querySelector('[name="subjectStart"]').value, end: row.querySelector('[name="subjectEnd"]').value, @@ -443,6 +476,12 @@ document.addEventListener('submit', async event => { const body = formObject(form); body.published = form.published.checked; await api('/api/admin/results', { method: 'POST', body }); toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute(); + } else if (kind === 'result-import-commit') { + const rows = state.resultImportPreview?.rows || []; + if (!rows.length) throw new Error('没有可提交的成绩预览'); + const result = await api('/api/admin/results/import', { method: 'POST', body: { rows } }); + state.resultImportPreview = null; + toast('批量成绩已写入', `${result.count} 条成绩已在同一事务中提交`); renderRoute(); } } catch (error) { toast('操作未完成', error.message); } finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } } @@ -488,13 +527,25 @@ function openFlowDetail(id) { const isBatch = instance.businessType === 'candidate_account_batch'; const isAppeal = instance.businessType === 'score_appeal'; const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length; + const finalAppealStep = isAppeal && instance.currentStep >= instance.steps.length; const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName; const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; const change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null; const changeSnapshot = change ? `
申请快照

${h(change.name)} · ${h(change.code)}

${change.rooms.length} 个考场
地址
${h(change.address)}
负责人
${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}
开放时间
${h(change.gateOpenTime || '未填写')}
档案状态
${change.centerStatus === 'active' ? '启用' : '停用'}
${change.rooms.map(room => `${h(room.name)}${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}`).join('')}
` : ''; const batch = instance.accountBatch; const batchSnapshot = batch ? `
班级配额

${batch.totalCount} 个待建账户

${batch.quotas.length} 个班级
${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}

最终批准时才生成固定报名号和随机初始密码。

` : ''; + const appeal = instance.appealResult; + const appealSnapshot = appeal ? `
复议成绩快照

${h(appeal.examName)} · ${h(appeal.subjectName)}

${h(appeal.score)} / ${h(appeal.fullScore)}
当前等级
${h(appeal.grade)}
本科排名
第 ${h(appeal.rank)} / ${h(appeal.cohortSize)} 名(前 ${h(appeal.rankPercent)}%)
单科规则
${h(appeal.passText)}
当前结论
${appeal.qualified == null ? '不判定' : appeal.qualified ? '达线' : '未达线'}
` : ''; + const reviewedScoreField = finalAppealStep ? `` : ''; setModal(`${changeSnapshot}${batchSnapshot}
${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}

流程轨迹

${history || '

暂无操作

'}
${canProcess ? `` : '
当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`); + if (appealSnapshot) modalRoot.querySelector('.modal-head')?.insertAdjacentHTML('afterend', appealSnapshot); + if (finalAppealStep) { + const processForm = modalRoot.querySelector('[data-form="flow-process"]'); + const fieldRow = processForm?.querySelector('.field-row'); + if (fieldRow) fieldRow.insertAdjacentHTML('beforeend', reviewedScoreField); + const processButton = processForm?.querySelector('button[type="submit"]'); + if (processButton) processButton.textContent = '批准并更新成绩'; + } } function openCandidateReview(id) { @@ -523,18 +574,47 @@ function dateTimeLocal(value) { } function examSubjectEditor(subject = {}) { - return `
科目明细
`; + const passRule = subject.passRule === 'score_ratio' ? 'rank_percent' : (subject.passRule || 'fixed_score'); + const passValue = subject.passValue ?? subject.passScore ?? 60; + return `
科目明细

`; +} + +function refreshSubjectPassRuleRow(row) { + if (!row) return; + const ruleSelect = row.querySelector('[name="subjectPassRule"]'); + const legacyOption = ruleSelect?.querySelector('option[value="score_ratio"]'); + if (legacyOption) { legacyOption.value = 'rank_percent'; legacyOption.textContent = '按排名比例'; } + const rule = ruleSelect?.value || 'fixed_score'; + const fullScore = Number(row.querySelector('[name="subjectFullScore"]')?.value || 0); + const valueField = row.querySelector('[data-subject-pass-value]'); + const input = row.querySelector('[name="subjectPassValue"]'); + const label = row.querySelector('[data-subject-pass-label]'); + const unit = row.querySelector('[data-subject-pass-unit]'); + const preview = row.querySelector('[data-subject-pass-preview]'); + valueField?.classList.toggle('hidden', rule === 'none'); + if (input) { + input.disabled = rule === 'none'; + input.max = rule === 'rank_percent' ? '100' : String(fullScore || 1000); + input.min = rule === 'rank_percent' ? '0.1' : '0'; + } + if (label) label.textContent = rule === 'rank_percent' ? '排名比例 *' : '固定及格分 *'; + if (unit) unit.textContent = rule === 'rank_percent' ? '%' : '分'; + const value = Number(input?.value || 0); + if (preview) preview.textContent = rule === 'none' ? '本科只展示成绩,不单独判定达线。' : rule === 'rank_percent' ? `本科排名前 ${value}% 达线;分数边界随本次已发布成绩队列变化。` : `本科达到 ${value} 分视为单科达线。`; } function refreshExamScoringForm(form) { if (!form?.matches('[data-form="exam-form"]')) return; const rows = [...form.querySelectorAll('.exam-subject-editor')]; + rows.forEach(refreshSubjectPassRuleRow); const total = rows.reduce((sum, row) => sum + Number(row.querySelector('[name="subjectFullScore"]')?.value || 0), 0); const totalElement = form.querySelector('[data-exam-total]'); const countElement = form.querySelector('[data-exam-subject-count]'); if (totalElement) totalElement.textContent = total; if (countElement) countElement.textContent = rows.length; - const policy = form.querySelector('[name="passPolicy"]')?.value || 'score_ratio'; + const policySelect = form.querySelector('[name="passPolicy"]'); + policySelect?.querySelector('option[value="score_ratio"]')?.remove(); + const policy = policySelect?.value || 'rank_percent'; const valueField = form.querySelector('[data-pass-value-field]'); const valueInput = form.querySelector('[name="passValue"]'); const unit = form.querySelector('[data-pass-unit]'); @@ -549,10 +629,9 @@ function refreshExamScoringForm(form) { if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%'; const hints = { fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。', - score_ratio: '按“实得总分 ÷ 所报科目满分”判断,适合允许选科的考试。', rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。', subject_scores: '每个科目都必须达到上方配置的单科合格分。', - none: '只展示成绩、总分和得分率,不显示合格或未合格。' + none: '只展示成绩、总分和排名,不显示合格或未合格。' }; if (hint) hint.textContent = hints[policy]; } @@ -561,7 +640,7 @@ function openExamForm(exam = null) { const editing = Boolean(exam); if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿'); const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }]; - const passPolicy = exam?.passPolicy || 'score_ratio'; + const passPolicy = exam?.passPolicy === 'score_ratio' ? 'rank_percent' : (exam?.passPolicy || 'rank_percent'); setModal(``); refreshExamScoringForm(modalRoot.querySelector('[data-form="exam-form"]')); } diff --git a/database.mjs b/database.mjs index 8787f56..2364bfa 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 = 10, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 12, 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() ); @@ -129,15 +129,16 @@ function buildSeedOperations(state) { ) 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', Number(exam.passValue ?? 60), exam.status, exam.createdAt + exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'), Number(exam.passValue ?? 60), exam.status, exam.createdAt ); exam.subjects.forEach((subject, index) => add( `INSERT INTO exam_subjects ( - id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore || 150), - Number(subject.passScore ?? Number(subject.fullScore || 150) * .6), Number(subject.order || index + 1) + Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), + Number(subject.passValue ?? subject.passScore ?? Number(subject.fullScore || 150) * .6), Number(subject.order || index + 1) )); } @@ -342,6 +343,9 @@ function buildSeedOperations(state) { function stateFromRows(rows) { const subjectsByExam = new Map(); for (const row of rows.subjects) { + const fullScore = Number(row.full_score ?? 150); + const passRule = row.pass_rule === 'score_ratio' ? 'rank_percent' : (row.pass_rule || 'fixed_score'); + const passValue = Number(row.pass_value ?? row.pass_score ?? fullScore * .6); const subject = { id: row.id, name: row.name, @@ -349,8 +353,10 @@ function stateFromRows(rows) { start: row.start_time, end: row.end_time, fee: Number(row.fee), - fullScore: Number(row.full_score ?? 150), - passScore: Number(row.pass_score ?? 90), + fullScore, + passRule, + passValue, + passScore: passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null, order: Number(row.position) }; const subjects = subjectsByExam.get(row.exam_id) || []; @@ -521,7 +527,7 @@ function stateFromRows(rows) { admitDownloadStart: row.admit_download_start, admitDownloadEnd: row.admit_download_end, location: row.location, - passPolicy: row.pass_policy || 'score_ratio', + passPolicy: row.pass_policy === 'score_ratio' ? 'rank_percent' : (row.pass_policy || 'rank_percent'), passValue: Number(row.pass_value ?? 60), status: row.status, createdAt: row.created_at, @@ -1008,6 +1014,12 @@ function createRepository({ client, location, read, transaction, close }) { status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id )); + } else if (instance.businessType === 'score_appeal' && business) { + operations.push(operation( + `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, + Number(business.score), business.grade, business.published ? 1 : 0, + optional(business.updatedAt), optional(business.publishedAt), business.id + )); } if (log) operations.push(auditOperation(log)); await transaction(operations); @@ -1298,11 +1310,11 @@ function createRepository({ client, location, read, transaction, close }) { )]; exam.subjects.forEach((subject, index) => operations.push(operation( `INSERT INTO exam_subjects ( - id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore), - Number(subject.passScore), Number(subject.order || index + 1) + Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), Number(subject.passValue ?? subject.passScore ?? 0), Number(subject.order || index + 1) ))); operations.push(auditOperation(log)); await transaction(operations); @@ -1323,11 +1335,11 @@ function createRepository({ client, location, read, transaction, close }) { operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id)); exam.subjects.forEach((subject, index) => operations.push(operation( `INSERT INTO exam_subjects ( - id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore), - Number(subject.passScore), Number(subject.order || index + 1) + Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), Number(subject.passValue ?? subject.passScore ?? 0), Number(subject.order || index + 1) ))); } operations.push(auditOperation(log)); @@ -1370,6 +1382,26 @@ function createRepository({ client, location, read, transaction, close }) { optional(result.updatedAt), optional(result.publishedAt), result.id ); await transaction([resultOperation, auditOperation(log)]); + }, + async saveResults(entries) { + const operations = []; + for (const { result, isNew, log } of entries) { + operations.push(isNew + ? operation( + `INSERT INTO results ( + id, registration_id, subject_id, score, grade, published, updated_at, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + result.id, result.registrationId, result.subjectId, result.score, result.grade, + result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt) + ) + : operation( + `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, + result.score, result.grade, result.published ? 1 : 0, + optional(result.updatedAt), optional(result.publishedAt), result.id + )); + operations.push(auditOperation(log)); + } + await transaction(operations); } }; } diff --git a/excel.mjs b/excel.mjs index fad2da3..a23567d 100644 --- a/excel.mjs +++ b/excel.mjs @@ -72,12 +72,17 @@ const resourceSpecs = { results: { title: '考试成绩台账', sheet: '成绩', columns: [ - ['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['examCode', '考试代码*', 20, 'EX-2026-AUT'], - ['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''], ['passScore', '单科合格线(只读参考)', 20, ''], ['score', '成绩*', 12, 120], - ['grade', '等级', 12, 'A'], ['published', '发布状态*', 14, '发布'] + ['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['candidateName', '姓名(只读参考)', 16, ''], + ['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''], + ['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''], + ['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''], + ['score', '成绩*', 12, 120], ['rank', '本科排名(导出计算)', 18, ''], + ['rankPercent', '排名百分位(导出计算)', 20, ''], ['qualified', '单科达线(导出计算)', 16, ''], ['grade', '排名等级(自动计算)', 18, ''], + ['published', '发布状态*', 14, '发布'], ['updatedAt', '更新时间(只读参考)', 24, ''] ], validations: { published: ['发布', '不发布'] }, - numberColumns: ['score'] + numberColumns: ['fullScore', 'passScore', 'score', 'rank', 'rankPercent'], + numberFormats: { fullScore: '0.00', passScore: '0.00', score: '0.00', rank: '0', rankPercent: '0.00' } }, admit_cards: { title: '准考证信息台账', sheet: '准考证信息', @@ -149,7 +154,7 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub const lastColumn = spec.columns.length; sheet.columns = spec.columns.map(([key, , width]) => ({ key, width })); spec.columns.forEach(([key], index) => { - sheet.getColumn(index + 1).numFmt = spec.numberColumns?.includes(key) ? '0' : '@'; + sheet.getColumn(index + 1).numFmt = spec.numberFormats?.[key] || (spec.numberColumns?.includes(key) ? '0' : '@'); }); sheet.mergeCells(1, 1, 1, lastColumn); const titleCell = sheet.getCell(1, 1); diff --git a/server.mjs b/server.mjs index 98946eb..c51643b 100644 --- a/server.mjs +++ b/server.mjs @@ -284,6 +284,52 @@ function publicExam(exam) { }; } +function subjectPassThreshold(subject) { + const rule = subject?.passRule || 'fixed_score'; + if (rule !== 'fixed_score') return null; + const value = Number(subject?.passValue ?? subject?.passScore ?? 0); + return Number(value.toFixed(2)); +} + +function subjectPassText(subject) { + const rule = subject?.passRule || 'fixed_score'; + if (rule === 'none') return '不设单科线'; + if (rule === 'rank_percent') return `本科排名前 ${Number(subject.passValue ?? 60)}% 达线`; + return `固定 ${subjectPassThreshold(subject)} 分`; +} + +function gradeForRank(rank, cohortSize) { + const cutoff = ratio => Math.max(1, Math.ceil(cohortSize * ratio)); + if (rank <= cutoff(.1)) return 'A+'; + if (rank <= cutoff(.25)) return 'A'; + if (rank <= cutoff(.5)) return 'B+'; + if (rank <= cutoff(.7)) return 'B'; + if (rank <= cutoff(.9)) return 'C'; + return 'D'; +} + +function resultRankInfo(db, result, scoreOverride = result?.score) { + if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' }; + const score = Number(scoreOverride); + const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published); + const cohortSize = peers.length + 1; + const rank = 1 + peers.filter(item => Number(item.score) > score).length; + const rankPercent = Number((rank / cohortSize * 100).toFixed(2)); + return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) }; +} + +function subjectPassEvaluation(db, result, subject, scoreOverride = result?.score) { + const rule = subject?.passRule || 'fixed_score'; + if (rule === 'none') return { qualified: null, passScore: null, cutoffRank: null, ...resultRankInfo(db, result, scoreOverride) }; + if (rule === 'rank_percent') { + const rankInfo = resultRankInfo(db, result, scoreOverride); + const cutoffRank = Math.max(1, Math.ceil(rankInfo.cohortSize * Number(subject.passValue ?? 60) / 100)); + return { ...rankInfo, qualified: rankInfo.rank <= cutoffRank, passScore: null, cutoffRank }; + } + const passScore = subjectPassThreshold(subject); + return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null }; +} + function examResultSummary(db, registration) { const exam = db.exams.find(item => item.id === registration.examId); if (!exam) return null; @@ -294,15 +340,17 @@ function examResultSummary(db, registration) { const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0); const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0); const scoreRatio = fullScore ? total / fullScore * 100 : 0; - const policy = exam.passPolicy || 'score_ratio'; + const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'); const value = Number(exam.passValue ?? 60); let qualified = null; let rank = null; let cohortSize = null; if (complete && policy === 'fixed_score') qualified = total >= value; - if (complete && policy === 'score_ratio') qualified = scoreRatio >= value; - if (complete && policy === 'subject_scores') qualified = subjects.every(subject => Number(resultsBySubject.get(subject.id).score) >= Number(subject.passScore)); + if (complete && policy === 'subject_scores') qualified = subjects.every(subject => { + const subjectResult = resultsBySubject.get(subject.id); + return subjectPassEvaluation(db, subjectResult, subject).qualified !== false; + }); if (complete && policy === 'none') qualified = null; if (complete && policy === 'rank_percent') { const subjectKey = [...registration.subjectIds].sort().join('|'); @@ -432,12 +480,24 @@ function excelRowsForResource(db, user, resource, searchParams) { })); }); if (resource === 'results') { + const examId = cleanText(searchParams.get('examId'), 64); const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); - return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { + return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => { const registration = db.registrations.find(item => item.id === result.registrationId); const exam = db.exams.find(item => item.id === registration?.examId); const subject = exam?.subjects.find(item => item.id === result.subjectId); - return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: subject?.name || '', fullScore: subject?.fullScore || '', passScore: subject?.passScore || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' }; + const account = db.users.find(item => item.id === registration?.userId); + const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); + const evaluation = subjectPassEvaluation(db, result, subject); + const rank = resultRankInfo(db, result); + return { + candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '', + examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '', + fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score, + rank: rank.rank, rankPercent: rank.rankPercent, + qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线', + grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || '' + }; }); } if (resource === 'admit_cards') { @@ -452,6 +512,112 @@ function excelImportError(row, message) { return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 }); } +function prepareResultImport(db, rows) { + const normalized = []; + const errors = []; + const seen = new Set(); + for (const source of Array.isArray(rows) ? rows : []) { + const sourceRow = Number(source.__row || source.sourceRow || normalized.length + 3); + const candidateNumber = cleanText(source.candidateNumber, 120); + const examCode = cleanText(source.examCode, 60); + const subjectName = cleanText(source.subjectName, 50); + const account = db.users.find(item => item.candidateNumber === candidateNumber); + const exam = db.exams.find(item => item.code.toUpperCase() === examCode.toUpperCase()); + const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved'); + const subject = exam?.subjects.find(item => item.name.toLowerCase() === subjectName.toLowerCase()); + const score = source.score == null || String(source.score).trim() === '' ? Number.NaN : Number(source.score); + const rowErrors = []; + if (!candidateNumber) rowErrors.push('报名号不能为空'); + else if (!account) rowErrors.push('报名号不存在'); + if (!examCode) rowErrors.push('考试代码不能为空'); + else if (!exam) rowErrors.push('考试代码不存在'); + if (!subjectName) rowErrors.push('科目不能为空'); + else if (exam && !subject) rowErrors.push('该考试中不存在此科目'); + if (exam && account && !registration) rowErrors.push('该考生没有已通过的本场考试报名'); + if (registration && subject && !registration.subjectIds.includes(subject.id)) rowErrors.push('该考生未报考此科目'); + if (!Number.isFinite(score) || score < 0 || (subject && score > Number(subject.fullScore))) rowErrors.push(`成绩须在 0—${subject?.fullScore ?? 0} 之间`); + const publishText = typeof source.published === 'boolean' ? (source.published ? '发布' : '不发布') : cleanText(source.published, 20) || '不发布'; + if (!['发布', '不发布'].includes(publishText)) rowErrors.push('发布状态只能是“发布”或“不发布”'); + const key = registration && subject ? `${registration.id}|${subject.id}` : `${candidateNumber}|${examCode}|${subjectName}`; + if (seen.has(key)) rowErrors.push('同一考生、考试和科目在文件中重复'); + seen.add(key); + const existing = registration && subject ? db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id) : null; + const evaluation = subject ? subjectPassEvaluation(db, existing || { id: `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, subject, score) : null; + const profile = db.candidateProfiles.find(item => item.userId === account?.id); + const schoolClass = db.classes.find(item => item.id === profile?.classId); + const rank = subject && Number.isFinite(score) ? resultRankInfo(db, { id: existing?.id || `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, score) : null; + const row = { + sourceRow, candidateNumber, candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '', + examId: exam?.id || '', examCode, examName: exam?.name || '', registrationId: registration?.id || '', + subjectId: subject?.id || '', subjectName, fullScore: subject?.fullScore ?? null, + passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore ?? null, + passScore: evaluation?.passScore ?? null, passText: subject ? subjectPassText(subject) : '', score, + rank: rank?.rank ?? null, cohortSize: rank?.cohortSize ?? null, rankPercent: rank?.rankPercent ?? null, + qualified: !Number.isFinite(score) ? null : evaluation?.qualified ?? null, + grade: rank?.grade || '', + published: publishText === '发布', existingResultId: existing?.id || '', mode: existing ? 'update' : 'create', errors: rowErrors + }; + normalized.push(row); + errors.push(...rowErrors.map(message => ({ row: sourceRow, message }))); + } + const previewResults = db.results.map(item => ({ ...item })); + for (const row of normalized.filter(item => !item.errors.length)) { + const existingIndex = row.existingResultId ? previewResults.findIndex(item => item.id === row.existingResultId) : -1; + const previewResult = { + ...(existingIndex >= 0 ? previewResults[existingIndex] : {}), + id: row.existingResultId || `preview-${row.sourceRow}`, + registrationId: row.registrationId, + subjectId: row.subjectId, + score: row.score, + published: row.published + }; + if (existingIndex >= 0) previewResults[existingIndex] = previewResult; + else previewResults.push(previewResult); + } + const previewDb = { ...db, results: previewResults }; + for (const row of normalized.filter(item => !item.errors.length)) { + const previewResult = previewResults.find(item => item.id === (row.existingResultId || `preview-${row.sourceRow}`)); + const subject = db.exams.find(item => item.id === row.examId)?.subjects.find(item => item.id === row.subjectId); + const rank = resultRankInfo(previewDb, previewResult, row.score); + const evaluation = subjectPassEvaluation(previewDb, previewResult, subject, row.score); + Object.assign(row, { rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, grade: row.published ? rank.grade : '待发布', passScore: evaluation.passScore, cutoffRank: evaluation.cutoffRank, qualified: evaluation.qualified }); + } + return { + rows: normalized, + errors, + summary: { + total: normalized.length, + valid: normalized.filter(item => !item.errors.length).length, + invalid: normalized.filter(item => item.errors.length).length, + create: normalized.filter(item => !item.errors.length && item.mode === 'create').length, + update: normalized.filter(item => !item.errors.length && item.mode === 'update').length, + publish: normalized.filter(item => !item.errors.length && item.published).length + } + }; +} + +async function commitResultImport(db, user, rows) { + if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以批量提交成绩'), { status: 403 }); + const prepared = prepareResultImport(db, rows); + if (prepared.errors.length) { + const first = prepared.errors[0]; + throw Object.assign(new Error(`第 ${first.row} 行:${first.message};请返回预览修正后重试`), { status: 400 }); + } + const entries = prepared.rows.map(row => { + const existing = row.existingResultId ? db.results.find(item => item.id === row.existingResultId) : null; + const result = existing || { id: uid('result'), registrationId: row.registrationId, subjectId: row.subjectId }; + Object.assign(result, { + score: row.score, grade: row.grade, published: row.published, updatedAt: nowIso(), + publishedAt: row.published ? (existing?.publishedAt || nowIso()) : null + }); + const log = logAction(db, user, row.published ? 'Excel 批量发布成绩' : 'Excel 批量保存成绩', `${row.candidateNumber} · ${row.examName} · ${row.subjectName} · ${row.score}`); + return { result, isNew: !existing, log }; + }); + await database.saveResults(entries); + for (const entry of entries) if (entry.isNew) db.results.push(entry.result); + return { count: entries.length, summary: prepared.summary }; +} + async function importExcelResource(db, user, resource, rows) { if (resource === 'classes') { if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 }); @@ -554,23 +720,7 @@ async function importExcelResource(db, user, resource, rows) { return { count: groups.size }; } if (resource === 'results') { - if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 }); - for (const row of rows) { - const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); - const exam = db.exams.find(item => item.code === cleanText(row.examCode, 60)); - const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved'); - const subject = exam?.subjects.find(item => item.name === cleanText(row.subjectName, 50)); - const score = Number(row.score); - if (!registration || !subject || !registration.subjectIds.includes(subject.id) || !Number.isFinite(score) || score < 0 || score > subject.fullScore) throw excelImportError(row, `报名号、考试、科目无效,或成绩不在 0—${subject?.fullScore || 0} 之间`); - let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id); - const isNew = !result; - if (!result) result = { id: uid('result'), registrationId: registration.id, subjectId: subject.id }; - const ratio = score / subject.fullScore; - Object.assign(result, { score, grade: cleanText(row.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null }); - await database.saveResult(result, isNew, logAction(db, user, 'Excel 导入成绩', `${row.candidateNumber} · ${exam.name} · ${subject.name}`)); - if (isNew) db.results.push(result); - } - return { count: rows.length }; + return commitResultImport(db, user, rows); } throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 }); } @@ -648,12 +798,17 @@ const routeContext = { publicExam, examRegistrationView, examResultSummary, + subjectPassText, + subjectPassEvaluation, + resultRankInfo, logAction, excelResourceNames, excelRowsForResource, admissionRowsForRegistrations, centerMaterialRows, importExcelResource, + prepareResultImport, + commitResultImport, admitCardHtml, admitCardsHtml, hashPassword, diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 87c9513..13190c9 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -27,7 +27,7 @@ export function createAdminViews(context) { dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], - results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'], + results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'], admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'], @@ -80,7 +80,7 @@ export function createAdminViews(context) { } 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)} 分`).join('') || '科目待配置'}
${exam.registrationCount} 人报名 · ${exam.subjects.length} 科
${exam.status === 'draft' ? `` : ''}
`).join('')}
`; + 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('')}
`; } function adminNotices(notices) { @@ -109,9 +109,20 @@ export function createAdminViews(context) { } function adminResults(data) { - const entry = state.user.adminLevel === 'super' ? `

录入单科成绩

按科目满分校验

选择科目后显示满分与单科合格线。

` : ''; - const appealLedger = `

成绩复议台账

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

${(data.appeals || []).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)}
暂无成绩复议申请
`; - return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}
${entry}

${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}

${data.results.length} 条记录
${data.results.slice(0, 50).map(result => `
${h((result.candidateName || '?').slice(0,1))}

${h(result.candidateName)} · ${h(result.subjectName)}${h(result.examName)}

${h(result.score)}${badge(result.published ? 'published' : 'draft')}
`).join('') || '

还没有成绩记录

'}
${appealLedger}`; + const preview = state.resultImportPreview; + const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => item.registrationCount) || 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 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 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}`; } function adminUsers(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 72ae3cf..64c5468 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -147,10 +147,12 @@ export function createCandidateViews(context) { 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), {}); - return `
${Object.entries(grouped).map(([examName, items]) => { + 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]) => { 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 ? `得分率 ${summary.scoreRatio}%` : ''; + 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); @@ -159,9 +161,10 @@ export function createCandidateViews(context) { : appeal?.status === 'approved' ? `
${badge('approved')}${h(latestAction?.note || '复议流程已完成')}
` : `${appeal ? `
${badge('rejected')}${h(latestAction?.note || '可补充理由后重新提交')}
` : ''}
`; - return `
${h(item.subjectName)}${h(item.score)}${h(item.grade)}满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)}${appealPanel}
`; + 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[0].publishedAt, true)}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}合格判定${h(stateText)}${h(detail)}
${scores}

如对成绩有异议,可直接提交复议,系统会按班级、学校和考试中心流程办理。

已发布 ${items.length} 科
`; + 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}
`; }).join('')}
`; } diff --git a/src/client/state.mjs b/src/client/state.mjs index 4ca8962..90bbb2b 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -5,5 +5,7 @@ export const state = { permissions: [], scopeLabel: '', pageData: null, + resultExamFilter: '', + resultImportPreview: null, loading: false }; diff --git a/src/client/ui.mjs b/src/client/ui.mjs index 2597060..5252494 100644 --- a/src/client/ui.mjs +++ b/src/client/ui.mjs @@ -29,11 +29,11 @@ export function passPolicyText(exam) { const value = Number(exam?.passValue ?? 60); return { fixed_score: `总分达到 ${value} 分`, - score_ratio: `得分率达到 ${value}%`, + score_ratio: `总成绩排名前 ${value}%`, rank_percent: `总成绩排名前 ${value}%`, subject_scores: '所有报考科目均达单科线', none: '仅发布成绩,不判定合格' - }[exam?.passPolicy || 'score_ratio']; + }[exam?.passPolicy || 'rank_percent']; } export function formatDate(value, withTime = false) { diff --git a/src/data/seed.mjs b/src/data/seed.mjs index c7990a2..9338e52 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { const examId = 'exam_autumn_2026'; const registrationId = 'reg_demo_2026'; return { - meta: { version: 10, createdAt: nowIso() }, + meta: { version: 12, createdAt: nowIso() }, settings: { selfRegistrationEnabled: false }, organization: { name: '海州市教育考试中心', @@ -53,10 +53,10 @@ export function createSeedDatabase({ nowIso, hashPassword }) { registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z', examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z', admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z', - location: '海州市各指定考点', passPolicy: 'score_ratio', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z', + location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z', subjects: [ - { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passScore: 90 }, - { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passScore: 90 }, + { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passRule: 'fixed_score', passValue: 90, passScore: 90 }, + { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passRule: 'rank_percent', passValue: 60, passScore: null }, { id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 }, { id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 }, { id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30, fullScore: 150, passScore: 90 }, @@ -69,7 +69,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) { registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z', examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z', admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z', - location: '考点待公布', passPolicy: 'score_ratio', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [] + location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [] } ], registrations: [ diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 4a9f3ab..63461ba 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -62,10 +62,12 @@ export function createMysqlAdapter(context) { 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL', 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL', '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 'score_ratio'", + "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 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'", + 'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_value DOUBLE NOT NULL DEFAULT 90', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL', 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL', @@ -134,6 +136,18 @@ export function createMysqlAdapter(context) { metadataRows[0].schema_version = 10; metadataRows[0].app_version = 10; } + if (Number(metadataRows[0]?.schema_version || 1) < 11) { + await pool.execute("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score"); + await pool.execute('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1'); + metadataRows[0].schema_version = 11; + metadataRows[0].app_version = 11; + } + if (Number(metadataRows[0]?.schema_version || 1) < 12) { + await pool.execute("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'"); + await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1'); + metadataRows[0].schema_version = 12; + metadataRows[0].app_version = 12; + } if (Number(metadataRows[0]?.app_version || 1) < 2) { const extension = seed(); const connection = await pool.getConnection(); @@ -305,8 +319,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) < 10) { - await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1'); + if (Number(metadataRows[0]?.schema_version || 1) < 12) { + await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1'); } } if (!existing.length) { @@ -316,7 +330,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, 10, ?, ?, ?) + VALUES (1, 12, ?, ?, ?) `, [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 2836ed4..20b4549 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -109,7 +109,7 @@ export const sqliteSchema = ` admit_download_start TEXT NOT NULL, admit_download_end TEXT NOT NULL, location TEXT NOT NULL, - pass_policy TEXT NOT NULL DEFAULT 'score_ratio' CHECK (pass_policy IN ('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none')), + 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')), created_at TEXT NOT NULL @@ -125,6 +125,8 @@ export const sqliteSchema = ` fee REAL NOT NULL DEFAULT 0, full_score REAL NOT NULL DEFAULT 150, pass_score REAL NOT NULL DEFAULT 90, + pass_rule TEXT NOT NULL DEFAULT 'fixed_score' CHECK (pass_rule IN ('fixed_score', 'score_ratio', 'none')), + pass_value REAL NOT NULL DEFAULT 90, position INTEGER NOT NULL, UNIQUE (exam_id, position) ) STRICT; @@ -207,7 +209,7 @@ export const sqliteSchema = ` id TEXT PRIMARY KEY, registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, - score REAL NOT NULL CHECK (score >= 0 AND score <= 150), + score REAL NOT NULL CHECK (score >= 0), grade TEXT NOT NULL, published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), updated_at TEXT, @@ -541,7 +543,7 @@ export const mysqlSchema = [ admit_download_start VARCHAR(35) NOT NULL, admit_download_end VARCHAR(35) NOT NULL, location VARCHAR(200) NOT NULL, - pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'score_ratio', + 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, created_at VARCHAR(35) NOT NULL, @@ -559,6 +561,8 @@ export const mysqlSchema = [ fee DOUBLE NOT NULL DEFAULT 0, full_score DOUBLE NOT NULL DEFAULT 150, pass_score DOUBLE NOT NULL DEFAULT 90, + pass_rule ENUM('fixed_score', 'score_ratio', 'none') NOT NULL DEFAULT 'fixed_score', + pass_value DOUBLE NOT NULL DEFAULT 90, position INT UNSIGNED NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_subjects_exam_position (exam_id, position), diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index 3002c07..b95ae8c 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -40,10 +40,11 @@ export function createSqliteAdapter(context) { ]); ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]); ensureColumns('exams', [ - ['pass_policy', "TEXT NOT NULL DEFAULT 'score_ratio'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'] + ['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'] ]); ensureColumns('exam_subjects', [ - ['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90'] + ['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90'], + ['pass_rule', "TEXT NOT NULL DEFAULT 'fixed_score'"], ['pass_value', 'REAL NOT NULL DEFAULT 90'] ]); ensureColumns('test_centers', [ ['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], @@ -194,6 +195,43 @@ export function createSqliteAdapter(context) { `).run(); connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run(); } + if (existingSystem && Number(existingSystem.schema_version || 1) < 11) { + connection.exec('PRAGMA foreign_keys = OFF;'); + connection.exec('BEGIN IMMEDIATE'); + try { + connection.prepare("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score").run(); + connection.exec(` + CREATE TABLE results_v11 ( + id TEXT PRIMARY KEY, + registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + score REAL NOT NULL CHECK (score >= 0), + grade TEXT NOT NULL, + published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + updated_at TEXT, + published_at TEXT, + UNIQUE (registration_id, subject_id) + ) STRICT; + INSERT INTO results_v11 (id, registration_id, subject_id, score, grade, published, updated_at, published_at) + SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results; + DROP TABLE results; + ALTER TABLE results_v11 RENAME TO results; + CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); + `); + connection.prepare('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } finally { + try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {} + } + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 12) { + connection.prepare("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'").run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run(); + } if (existingSystem && Number(existingSystem.app_version || 1) < 2) { const extension = seed(); connection.exec('BEGIN IMMEDIATE'); @@ -344,8 +382,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) < 10) { - connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run(); + if (existingSystem && Number(existingSystem.schema_version || 1) < 12) { + connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run(); } if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { @@ -354,7 +392,7 @@ export function createSqliteAdapter(context) { try { connection.prepare(` INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) - VALUES (1, 10, ?, ?, ?) + VALUES (1, 12, ?, ?, ?) `).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 dd81102..447acfe 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -33,6 +33,10 @@ export function createAdminRoutes(context) { parseCenterChange, maskId, publicExam, + examResultSummary, + subjectPassText, + resultRankInfo, + subjectPassEvaluation, examRegistrationView, logAction, excelResourceNames, @@ -40,6 +44,8 @@ export function createAdminRoutes(context) { admissionRowsForRegistrations, centerMaterialRows, importExcelResource, + prepareResultImport, + commitResultImport, admitCardHtml, admitCardsHtml, hashPassword, @@ -56,7 +62,8 @@ export function createAdminRoutes(context) { permissionsByLevel } = context; - const passPolicies = new Set(['fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none']); + const passPolicies = new Set(['fixed_score', 'rank_percent', 'subject_scores', 'none']); + const subjectPassRules = new Set(['fixed_score', 'rank_percent', 'none']); function normalizeSubjects(input, examStart) { const source = Array.isArray(input) ? input : String(input || '').split(/[,,]/); @@ -65,6 +72,10 @@ export function createAdminRoutes(context) { const name = cleanText(structured ? item.name : item, 50); if (!name) return null; const fullScore = Number(structured ? item.fullScore : 150); + const requestedRule = item?.passRule === 'score_ratio' ? 'rank_percent' : item?.passRule; + const passRule = subjectPassRules.has(requestedRule) ? requestedRule : 'fixed_score'; + const passValue = passRule === 'none' ? 0 : Number(structured ? (item.passValue ?? item.passScore ?? fullScore * .6) : fullScore * .6); + const passScore = passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null; return { id: uid('sub'), name, @@ -73,7 +84,9 @@ export function createAdminRoutes(context) { end: cleanText(structured ? item.end : '', 5) || '11:00', fee: Number(structured ? item.fee ?? 0 : 0), fullScore, - passScore: Number(structured ? item.passScore : fullScore * .6), + passRule, + passValue, + passScore, order: index + 1 }; }).filter(Boolean); @@ -82,12 +95,14 @@ export function createAdminRoutes(context) { function validateExamScoring(subjects, passPolicy, passValue) { if (!subjects.length) return '请至少添加一个考试科目'; if (subjects.some(item => !Number.isFinite(item.fullScore) || item.fullScore <= 0 || item.fullScore > 1000)) return '科目满分必须大于 0 且不超过 1000'; - if (subjects.some(item => !Number.isFinite(item.passScore) || item.passScore < 0 || item.passScore > item.fullScore)) return '单科合格分必须在 0 与该科满分之间'; + if (subjects.some(item => !subjectPassRules.has(item.passRule))) return '请选择有效的单科合格线计算方式'; + if (subjects.some(item => item.passRule === 'fixed_score' && (!Number.isFinite(item.passValue) || item.passValue < 0 || item.passValue > item.fullScore))) return '固定单科合格分必须在 0 与该科满分之间'; + if (subjects.some(item => item.passRule === 'rank_percent' && (!Number.isFinite(item.passValue) || item.passValue <= 0 || item.passValue > 100))) return '单科排名比例必须大于 0 且不超过 100%'; if (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内'; if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略'; const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0); if (passPolicy === 'fixed_score' && (!Number.isFinite(passValue) || passValue < 0 || passValue > totalScore)) return `固定合格线必须在 0 与总分 ${totalScore} 之间`; - if (['score_ratio', 'rank_percent'].includes(passPolicy) && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '百分比必须大于 0 且不超过 100'; + if (passPolicy === 'rank_percent' && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '排名比例必须大于 0 且不超过 100'; return ''; } @@ -128,6 +143,10 @@ export function createAdminRoutes(context) { const resource = excelMatch[1]; if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出'); const rows = await parseWorkbook(resource, await readBodyBuffer(request)); + if (resource === 'results') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以预览导入成绩'); + return sendJson(response, 200, { ok: true, preview: true, ...prepareResultImport(db, rows) }); + } const result = await importExcelResource(db, user, resource, rows); return sendJson(response, 200, { ok: true, ...result }); } @@ -458,13 +477,21 @@ export function createAdminRoutes(context) { const appealRegistration = appealResult ? db.registrations.find(item => item.id === appealResult.registrationId) : null; const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null; const appealSubject = appealExam?.subjects.find(item => item.id === appealResult?.subjectId); + const appealRank = appealResult ? resultRankInfo(db, appealResult) : null; + const appealPass = appealResult ? subjectPassEvaluation(db, appealResult, appealSubject) : null; return { ...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '', examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '', centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null, accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null, batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0, - appealResult: appealResult ? { score: appealResult.score, grade: appealResult.grade, examName: appealExam?.name || '', subjectName: appealSubject?.name || '' } : null + appealResult: appealResult ? { + score: appealResult.score, grade: appealRank?.grade || appealResult.grade, rank: appealRank?.rank, cohortSize: appealRank?.cohortSize, rankPercent: appealRank?.rankPercent, + examName: appealExam?.name || '', examCode: appealExam?.code || '', subjectName: appealSubject?.name || '', + fullScore: appealSubject?.fullScore, passRule: appealSubject?.passRule || 'fixed_score', passValue: appealSubject?.passValue ?? appealSubject?.passScore, + passScore: appealPass?.passScore ?? null, cutoffRank: appealPass?.cutoffRank ?? null, + passText: subjectPassText(appealSubject), qualified: appealPass?.qualified ?? null + } : null }; }); const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser); @@ -718,6 +745,14 @@ export function createAdminRoutes(context) { if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); 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); + const subject = exam?.subjects.find(item => item.id === result.subjectId); + const finalStep = instance.currentStep >= workflow.steps.length; + let reviewedScore = null; + if (body.status === 'approved' && finalStep) { + reviewedScore = body.reviewedScore == null || String(body.reviewedScore).trim() === '' ? Number.NaN : Number(body.reviewedScore); + if (!Number.isFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > Number(subject?.fullScore || 0)) return sendError(response, 400, `最终审批必须填写 0—${subject?.fullScore || 0} 之间的复核后分数`); + } if (body.status === 'rejected') { instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; } else if (instance.currentStep < workflow.steps.length) { @@ -727,12 +762,25 @@ export function createAdminRoutes(context) { instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; } else { instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + const originalScore = result.score; + result.score = reviewedScore; + const rank = resultRankInfo(db, result, reviewedScore); + result.grade = rank.grade; + result.updatedAt = nowIso(); + const pass = subjectPassEvaluation(db, result, subject, reviewedScore); + const passConclusion = pass.qualified == null + ? '本科不判定单科达线' + : `${pass.qualified ? '达到' : '未达到'}${subject.passRule === 'rank_percent' ? `排名前 ${subject.passValue}%(当前第 ${pass.rank}/${pass.cohortSize} 名,截止第 ${pass.cutoffRank} 名)` : `固定及格线 ${pass.passScore} 分`}`; + action.note = [`原分 ${originalScore} → 复核后 ${reviewedScore}`, passConclusion, note].filter(Boolean).join(';'); } - 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, body.status === 'approved' ? '处理成绩复议' : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}`); - await database.processWorkflow(instance, action, null, log); - return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + const log = logAction(db, user, body.status === 'approved' ? (finalStep ? '复议终审并更新成绩' : '处理成绩复议') : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}${finalStep && body.status === 'approved' ? ` · ${result.score} 分` : ''}`); + await database.processWorkflow(instance, action, finalStep && body.status === 'approved' ? result : null, log); + const rank = resultRankInfo(db, result); + const pass = subjectPassEvaluation(db, result, subject); + return sendJson(response, 200, { + ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance), + result: { ...result, ...rank, passScore: pass.passScore, cutoffRank: pass.cutoffRank, qualified: pass.qualified } + }); } const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/); if (request.method === 'POST' && arrangementMatch) { @@ -783,7 +831,8 @@ export function createAdminRoutes(context) { const name = cleanText(body.name, 100); if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期'); const subjects = normalizeSubjects(body.subjects, body.examStart); - const passPolicy = passPolicies.has(body.passPolicy) ? body.passPolicy : 'score_ratio'; + const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy; + const passPolicy = passPolicies.has(requestedPolicy) ? requestedPolicy : 'rank_percent'; const passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60); const scoringError = validateExamScoring(subjects, passPolicy, passValue); if (scoringError) return sendError(response, 400, scoringError); @@ -804,7 +853,10 @@ export function createAdminRoutes(context) { if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑'); if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status; detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); }); - if (body.passPolicy != null && passPolicies.has(body.passPolicy)) exam.passPolicy = body.passPolicy; + if (body.passPolicy != null) { + const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy; + if (passPolicies.has(requestedPolicy)) exam.passPolicy = requestedPolicy; + } if (body.passValue != null) exam.passValue = Number(body.passValue); let replaceSubjects = false; if (body.subjects != null) { @@ -857,16 +909,55 @@ export function createAdminRoutes(context) { const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { const registration = db.registrations.find(item => item.id === result.registrationId); const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); + const account = db.users.find(item => item.id === registration?.userId); const exam = db.exams.find(item => item.id === registration?.examId); const subject = exam?.subjects.find(item => item.id === result.subjectId); - return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name }; + const pass = subjectPassEvaluation(db, result, subject); + const rank = resultRankInfo(db, result); + return { + ...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, + candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '', + schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '', + examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name, + fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore, + passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), + qualified: pass.qualified + }; }); const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => { const result = results.find(item => item.id === instance.businessId); const workflow = workflowView(db, instance); return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' }; }); - return sendJson(response, 200, { ok: true, results, appeals, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] }); + const approved = scopedRegistrations.filter(item => item.status === 'approved'); + const exams = db.exams.map(exam => { + const examRegistrations = approved.filter(item => item.examId === exam.id); + const examResults = results.filter(item => item.examId === exam.id); + const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean); + const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0); + const scored = examResults.length; + return { + ...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored, + published: examResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored), + complete: summaries.filter(item => item.complete).length, + qualified: summaries.filter(item => item.complete && item.qualified === true).length, + unqualified: summaries.filter(item => item.complete && item.qualified === false).length, + appeals: appeals.filter(item => item.result?.examId === exam.id).length + }; + }).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super'); + const registrations = user.adminLevel === 'super' ? approved.map(item => { + const view = examRegistrationView(db, item); + const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId); + const account = db.users.find(accountItem => accountItem.id === item.userId); + return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' }; + }) : []; + return sendJson(response, 200, { ok: true, results, appeals, registrations, exams }); + } + if (request.method === 'POST' && pathname === '/api/admin/results/import') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const result = await commitResultImport(db, user, body.rows); + return sendJson(response, 200, { ok: true, ...result }); } if (request.method === 'POST' && pathname === '/api/admin/results') { if (!requirePermission(user, response, '*')) return true; @@ -884,8 +975,8 @@ export function createAdminRoutes(context) { result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId }; db.results.push(result); } - const ratio = score / subject.fullScore; - Object.assign(result, { score, grade: cleanText(body.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null }); + Object.assign(result, { score, published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? (result.publishedAt || nowIso()) : null }); + result.grade = result.published ? resultRankInfo(db, result, score).grade : '待发布'; const profile = db.candidateProfiles.find(item => item.userId === registration.userId); const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`); await database.saveResult(result, isNew, log); diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index f5d863c..275a789 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -32,6 +32,9 @@ export function createCandidateRoutes(context) { publicExam, examRegistrationView, examResultSummary, + subjectPassText, + resultRankInfo, + subjectPassEvaluation, logAction, excelResourceNames, excelRowsForResource, @@ -128,7 +131,15 @@ export function createCandidateRoutes(context) { const subject = exam.subjects.find(item => item.id === result.subjectId); const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id); const appeal = appealInstance ? workflowView(db, appealInstance) : null; - return { ...result, examId: exam.id, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, passScore: subject?.passScore || 90, appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null }; + 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, + 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, + appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null + }; }); const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects); return sendJson(response, 200, { ok: true, results, summaries }); diff --git a/styles.css b/styles.css index 430a450..d28f703 100644 --- a/styles.css +++ b/styles.css @@ -317,6 +317,108 @@ button:disabled { cursor: not-allowed; opacity: .5; } .public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; } } +.flow-appeal-snapshot { margin:18px 24px 4px; padding:16px; border:1px solid #d4dfed; border-radius:10px; background:linear-gradient(120deg,#f7f9fd,#eef4fb); } +.flow-appeal-snapshot header { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; } +.flow-appeal-snapshot header span { color:#7888a6; font-size:7px; font-weight:700; letter-spacing:.08em; } +.flow-appeal-snapshot h3 { margin:4px 0 0; color:var(--navy); font-size:14px; } +.flow-appeal-snapshot header b { color:var(--blue); font-family:Georgia,serif; font-size:22px; } +.flow-appeal-snapshot dl { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px 18px; margin:15px 0 0; } +.flow-appeal-snapshot dl div { display:grid; grid-template-columns:70px 1fr; gap:8px; } +.flow-appeal-snapshot dt { color:#8994a8; font-size:7px; } +.flow-appeal-snapshot dd { margin:0; color:#33425b; font-size:8px; } +.rank-rule-line { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:10px; padding:9px 10px; border-radius:7px; background:#f1f4f9; } +.rank-rule-line span { color:#8993a6; font-size:7px; } +.rank-rule-line b { color:#45536c; font-size:8px; } +@media (max-width: 620px) { .flow-appeal-snapshot dl { grid-template-columns:1fr; } } + +/* Multi-exam score center, staged Excel import, and per-subject pass rules */ +.result-exam-strip { display:flex; gap:10px; margin-bottom:14px; overflow-x:auto; padding:2px 1px 8px; scrollbar-width:thin; } +.result-exam-strip > button { position:relative; min-width:245px; display:grid; gap:5px; overflow:hidden; padding:16px 17px 18px; border:1px solid #dce3ed; border-radius:12px; color:#536078; background:#fff; text-align:left; box-shadow:0 7px 20px rgba(20,36,81,.04); } +.result-exam-strip > button.active { border-color:#315fba; color:#142451; background:linear-gradient(130deg,#fff,#f0f5ff); box-shadow:0 10px 26px rgba(49,95,186,.12); } +.result-exam-strip span { color:#7383a5; font:700 10px Consolas,monospace; letter-spacing:.05em; } +.result-exam-strip strong { font-size:14px; } +.result-exam-strip small { color:#8a93a5; font-size:10px; } +.result-exam-strip i { position:absolute; right:0; bottom:0; left:0; height:4px; background:linear-gradient(90deg,#315fba var(--progress),#e8edf5 var(--progress)); } +.result-metric-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:10px; margin-bottom:14px; } +.result-metric-grid article { min-width:0; padding:15px 16px; border:1px solid #e0e6ef; border-radius:11px; background:#fff; box-shadow:0 7px 20px rgba(20,36,81,.04); } +.result-metric-grid small,.result-metric-grid span { display:block; color:#8a93a5; font-size:9px; } +.result-metric-grid strong { display:block; margin:7px 0 5px; color:#173f60; font:400 25px Georgia,serif; } +.result-metric-grid strong em { color:#8c96aa; font-size:13px; font-style:normal; } +.result-excel-toolbar { margin-bottom:14px; } +.result-entry-console { margin-bottom:14px; overflow:hidden; } +.result-entry-console .panel-title { height:auto; min-height:74px; } +.result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; } +.result-entry-console form { padding:18px 20px 20px; } +.result-entry-fields { display:grid; grid-template-columns:1.2fr 1fr 1.3fr .65fr .65fr; gap:11px; } +.result-entry-fields label { min-width:0; display:grid; gap:6px; } +.result-entry-fields label > span { color:#667187; font-size:10px; font-weight:700; } +.result-entry-fields input,.result-entry-fields select { width:100%; min-height:40px; padding:8px 10px; border:1px solid #d7deea; border-radius:8px; color:#253149; background:#fff; font-size:11px; } +.result-entry-fields select:disabled { color:#a0a7b4; background:#f4f6f9; } +.result-entry-console .score-rule-hint { margin:12px 0; padding:10px 12px; border-left:3px solid #315fba; border-radius:6px; color:#61708c; background:#f1f5fc; font-size:10px; } +.result-entry-submit { display:flex; align-items:center; justify-content:space-between; gap:16px; padding-top:12px; border-top:1px solid #e8ecf2; } +.result-entry-submit .agreement { margin:0; } +.result-import-preview { margin-bottom:14px; overflow:hidden; border-color:#bed0e9; } +.result-import-preview > header { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:20px 22px; color:#fff; background:linear-gradient(112deg,#142451,#234c77); } +.result-import-preview header span { color:#83bdd0; font-size:9px; font-weight:800; letter-spacing:.14em; } +.result-import-preview header h2 { margin:5px 0; font-family:"STKaiti","KaiTi",serif; font-size:23px; font-weight:400; } +.result-import-preview header p { margin:0; color:#c1cedf; font-size:10px; } +.result-import-preview header .row-action { border-color:#6d82a3; color:#fff; background:rgba(255,255,255,.08); } +.import-preview-metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:1px; background:#e1e7ef; } +.import-preview-metrics span { padding:13px 18px; background:#fff; } +.import-preview-metrics small { display:block; color:#8993a5; font-size:9px; } +.import-preview-metrics strong { display:block; margin-top:3px; color:#173f60; font-size:18px; } +.import-preview-metrics .valid strong { color:#268466; } +.import-preview-metrics .invalid strong { color:#c8473d; } +.import-preview-table { min-width:1040px; } +.import-preview-table tr.row-invalid { background:#fff4f2; } +.import-preview-table td ul { margin:0; padding-left:15px; color:#b3453e; font-size:9px; } +.import-preview-table td strong,.import-preview-table td small { display:block; } +.import-preview-table td small { margin-top:3px; color:#8791a3; } +.import-commit-bar { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 20px; border-top:1px solid #e1e7ef; background:#f7f9fc; } +.import-commit-bar p { margin:0; color:#69768e; font-size:10px; } +.result-ledger { margin-bottom:14px; overflow:hidden; } +.result-ledger .data-toolbar { flex-wrap:wrap; } +.result-ledger table { min-width:1120px; } +.result-ledger td > strong,.result-ledger td > small { display:block; } +.result-score { color:#173f60; font:700 18px Georgia,serif; } +.result-score em { color:#929bad; font-size:10px; font-style:normal; } +.result-qualified,.result-unqualified,.result-neutral { display:inline-flex; padding:5px 8px; border-radius:99px; font-size:9px; font-weight:700; } +.result-qualified { color:#1f7459; background:#e2f3ec; } +.result-unqualified { color:#a7453f; background:#fbe8e6; } +.result-neutral { color:#69758c; background:#edf0f5; } +.subject-pass-preview { margin:0; padding:9px 12px; border-top:1px dashed #dce3ee; color:#6d7890; background:#f8fafe; font-size:9px; } +.candidate-result-overview { display:grid; grid-template-columns:repeat(4,1fr); gap:11px; margin-bottom:15px; } +.candidate-result-overview article { display:flex; align-items:baseline; gap:5px; padding:16px 18px; border:1px solid #e0e5ee; border-radius:11px; background:#fff; box-shadow:0 8px 22px rgba(20,36,81,.05); } +.candidate-result-overview small { margin-right:auto; color:#818b9e; font-size:9px; } +.candidate-result-overview strong { color:#173f60; font:400 25px Georgia,serif; } +.candidate-result-overview span { color:#8d96a7; font-size:9px; } +.result-panel .result-summary { grid-template-columns:repeat(3,1fr); } +.result-summary > span > i { display:block; margin-top:5px; color:#7e899c; font-size:9px; font-style:normal; } +.score-grid article { position:relative; } +.score-subject-head { display:flex; align-items:center; justify-content:space-between; gap:8px; } +.score-subject-head i { padding:4px 7px; border-radius:99px; color:#6d7890; background:#edf0f5; font-size:8px; font-style:normal; } +.score-grid article.qualified .score-subject-head i { color:#24755b; background:#e2f3ec; } +.score-grid article.unqualified .score-subject-head i { color:#aa463f; background:#fbe8e6; } +.score-grid article > strong small { color:#8d96a7; font-size:10px; } +.score-progress { position:relative; height:7px; margin:10px 0 7px; border-radius:99px; background:#e8ecf3; } +.score-progress > i { position:absolute; inset:0 auto 0 0; border-radius:99px; background:linear-gradient(90deg,#315fba,#4e8dc0); } +.score-progress > b { position:absolute; top:-3px; width:2px; height:13px; background:#c8473d; box-shadow:0 0 0 2px #fff; } + +@media (max-width: 1100px) { + .result-metric-grid { grid-template-columns:repeat(3,1fr); } + .result-entry-fields { grid-template-columns:repeat(2,minmax(0,1fr)); } + .result-entry-fields label:first-child { grid-column:span 2; } +} +@media (max-width: 700px) { + .result-metric-grid,.candidate-result-overview { grid-template-columns:repeat(2,1fr); } + .result-entry-fields { grid-template-columns:1fr; } + .result-entry-fields label:first-child { grid-column:auto; } + .result-entry-submit,.result-import-preview > header,.import-commit-bar { align-items:flex-start; flex-direction:column; } + .result-entry-submit button,.import-commit-bar button { width:100%; } + .import-preview-metrics { grid-template-columns:repeat(2,1fr); } + .result-panel .result-summary { grid-template-columns:1fr; } +} + /* Admission-card arrangement engine */ .arrangement-config { margin-bottom:15px; overflow:hidden; }.arrangement-config .panel-title { height:auto; min-height:74px; }.arrangement-config .panel-title p { margin:4px 0 0; color:var(--muted); font-size:8px; }.arrangement-config form { padding:20px; }.arrangement-fields { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }.arrangement-fields label { display:grid; align-content:start; gap:6px; }.arrangement-fields label > span { color:#59657c; font-size:8px; font-weight:700; }.arrangement-fields select,.arrangement-fields input { width:100%; min-height:42px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; color:var(--ink); background:#fff; font-size:9px; }.arrangement-fields label small { color:#9199a9; font-size:7px; line-height:1.6; }.arrangement-actions { display:flex; justify-content:flex-end; gap:9px; margin-top:16px; }.arrangement-preview { display:grid; gap:5px; margin:0 20px 20px; padding:13px 15px; border-radius:9px; }.arrangement-preview.ok { color:#245f4c; background:#e7f4ef; }.arrangement-preview.warning { color:#795a27; background:#fff3dc; }.arrangement-preview strong { font-size:9px; }.arrangement-preview span,.arrangement-preview small,.arrangement-preview li { font-size:8px; }.arrangement-preview ul { margin:5px 0 0; padding-left:18px; } .arrangement-rules { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:15px; }.arrangement-rules article { display:grid; align-content:start; gap:6px; min-height:155px; padding:16px; border:1px solid var(--line); border-radius:11px; background:linear-gradient(145deg,#fff,#f5f7fb); }.arrangement-rules span { color:#8792a9; font-size:7px; }.arrangement-rules h3 { margin:0; font-family:"STKaiti"; font-size:16px; font-weight:400; }.arrangement-rules p { margin:0; color:#818b9f; font-size:7px; line-height:1.7; }.arrangement-rules code { margin-top:auto; color:var(--blue); font-size:10px; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index afea169..140476f 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -94,12 +94,14 @@ try { const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version; 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); inspector.close(); assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); - assert.equal(schemaVersion, 10, '准考证详细位置快照应使用 v10 数据结构'); + assert.equal(schemaVersion, 12, '排名比例、排名等级与复议改分应使用 v12 数据结构'); assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照'); assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照'); + assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值'); const publicHome = await anonymous.request('/api/public/home'); assert.equal(publicHome.response.status, 200); @@ -293,14 +295,16 @@ try { examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(), admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(), location: '测试考点', status: 'published', subjects: [ - { name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passScore: 90 }, + { name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passRule: 'fixed_score', passValue: 90 }, { name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 0, fullScore: 150, passScore: 90 }, - { name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passScore: 90 } + { name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passRule: 'rank_percent', passValue: 60 } ] } }); assert.equal(createExam.response.status, 201); assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试'); + assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线'); + assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线'); const exam = createExam.data.exam; const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } }); assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划'); @@ -519,6 +523,16 @@ try { const refreshedHome = await anonymous.request('/api/public/home'); assert.ok(refreshedHome.data.notices.some(item => item.title === '系统测试成绩发布通知'), '管理员发布通知后首页应可见'); + const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }])); + const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile }); + assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览'); + assert.equal(stagedPreview.data.preview, true); + assert.equal(stagedPreview.data.summary.valid, 1); + assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库'); + const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } }); + assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩'); + assert.equal(stagedCommit.data.count, 1); + assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库'); const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } }); assert.equal(publishResult.response.status, 200); assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分'); @@ -528,7 +542,8 @@ try { const resultSummary = results.data.summaries.find(item => item.examId === exam.id); assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分'); assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总'); - assert.equal(resultSummary.qualified, true, '全部科目发布后应按得分率策略自动判定合格'); + assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格'); + assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算'); const classResults = await classAdmin.request('/api/admin/results'); assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩'); assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩'); @@ -560,11 +575,19 @@ try { const schoolApproved = await schoolClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校完成成绩复核' } }); assert.equal(schoolApproved.response.status, 200); assert.equal(schoolApproved.data.workflow.currentStepDetail.adminLevel, 'super', '校级复核后应进入考试中心终审'); - const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审完成' } }); + const missingReviewedScore = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '遗漏复核分数' } }); + assert.equal(missingReviewedScore.response.status, 400, '成绩复议终审批准时必须填写复核后分数'); + const reviewedScore = appeal.result.subjectId === exam.subjects[0].id ? 128 : 89; + const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewedScore, reviewNote: '考试中心终审完成' } }); assert.equal(finalApproved.data.workflow.status, 'approved', '成绩复议应可完整走完班级、学校和考试中心流程'); + assert.equal(finalApproved.data.result.score, reviewedScore, '终审批准应在同一事务中更新复核后成绩'); + assert.equal(finalApproved.data.result.grade, 'A+', '复议后等级应按当前同科排名重新判断'); + if (appeal.result.subjectId === exam.subjects[2].id) assert.equal(finalApproved.data.result.qualified, true, '排名比例科目只判断复核后成绩所在当前排名区间'); } const appealedResults = await candidate.request('/api/candidate/results'); assert.ok(appealedResults.data.results.filter(item => appealResults.some(result => result.id === item.id)).every(item => item.appeal?.status === 'approved'), '考生端应显示每科复议结果'); + 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, '考生端应立即看到复议后的外语成绩'); const workflowDefinitions = await admin.request('/api/admin/workflows'); const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');