Add independent subject pass rules and atomic score imports

This commit is contained in:
2026-07-20 15:42:36 +08:00 Unverified
parent c5b18d0b15
commit 9f850fa045
17 changed files with 688 additions and 114 deletions
+16 -5
View File
@@ -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 `<div class="admin-exam-grid">${exams.map(exam => `<article class="admin-exam-card ${exam.status}${exam.status === 'draft' ? ' editable' : ''}" ${exam.status === 'draft' ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><div class="exam-score-band"><span><small>考试总分</small><strong>${h(exam.totalScore)}</strong><em>分</em></span><span><small>合格规则</small><b>${h(passPolicyText(exam))}</b></span></div><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(subject.fullScore)} </small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.registrationCount} 人报名 · ${exam.subjects.length} 科</span><div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></div></footer></article>`).join('')}</div>`;
return `<div class="admin-exam-grid">${exams.map(exam => `<article class="admin-exam-card ${exam.status}${exam.status === 'draft' ? ' editable' : ''}" ${exam.status === 'draft' ? `data-action="edit-exam" data-id="${h(exam.id)}" title="点击编辑草稿"` : ''}><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.status)}</header><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><div class="exam-score-band"><span><small>考试总分</small><strong>${h(exam.totalScore)}</strong><em>分</em></span><span><small>合格规则</small><b>${h(passPolicyText(exam))}</b></span></div><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考点</dt><dd>${h(exam.location)}</dd></div></dl><div class="admin-subjects">${exam.subjects.map(subject => `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · 满分 ${h(subject.fullScore)} · ${subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${h(subject.passValue)}%` : `固定 ${h(subject.passScore)}`}</small></span>`).join('') || '<span>科目待配置</span>'}</div><footer><span>${exam.registrationCount} 人报名 · ${exam.subjects.length} 科</span><div class="exam-card-actions">${exam.status === 'draft' ? `<button class="row-action" data-action="edit-exam" data-id="${h(exam.id)}">编辑草稿</button>` : ''}<button class="row-action" data-action="toggle-exam" data-id="${h(exam.id)}" data-status="${exam.status === 'published' ? 'draft' : 'published'}">${exam.status === 'published' ? '撤回为草稿' : '发布考试'}</button></div></footer></article>`).join('')}</div>`;
}
function adminNotices(notices) {
@@ -109,9 +109,20 @@ export function createAdminViews(context) {
}
function adminResults(data) {
const entry = state.user.adminLevel === 'super' ? `<section class="panel result-entry"><div class="panel-title"><h2>录入单科成绩</h2><span>按科目满分校验</span></div><form data-form="result-entry"><label><span>报名记录</span><select name="registrationId" data-action="result-registration" required><option value="">请选择考生和考试</option>${data.registrations.map(reg => `<option value="${h(reg.id)}" data-subjects='${h(JSON.stringify(reg.subjects.map(subject => ({id:subject.id,name:subject.name,fullScore:subject.fullScore,passScore:subject.passScore}))))}'>${h(reg.exam.name)} · ${h(reg.registrationNumber || reg.id)}</option>`).join('')}</select></label><label><span>考试科目</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请先选择报名记录</option></select></label><div class="field-row"><label><span data-score-label>成绩</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级</span><input name="grade" placeholder="留空将按得分率计算"></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示满分与单科合格线。</p><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></form></section>` : '';
const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>成绩复议台账</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${(data.appeals || []).map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无成绩复议申请</td></tr>'}</tbody></table></div></section>`;
return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}<div class="results-admin-grid ${state.user.adminLevel === 'super' ? '' : 'read-only'}">${entry}<section class="panel published-results"><div class="panel-title"><h2>${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'}</h2><span>${data.results.length} 条记录</span></div>${data.results.slice(0, 50).map(result => `<div><span class="user-avatar">${h((result.candidateName || '?').slice(0,1))}</span><p><strong>${h(result.candidateName)} · ${h(result.subjectName)}</strong><small>${h(result.examName)}</small></p><b>${h(result.score)}</b>${badge(result.published ? 'published' : 'draft')}</div>`).join('') || '<p class="empty-state">还没有成绩记录</p>'}</section></div>${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' ? `<section class="panel result-entry result-entry-console"><div class="panel-title"><div><h2>单科成绩录入</h2><p>按考试和科目缩小候选范围,已有成绩会自动回填。</p></div><span>实时校验</span></div><form data-form="result-entry"><div class="result-entry-fields"><label><span>考试 *</span><select name="examId" data-action="result-exam" required><option value="">请选择考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === activeExam?.id ? 'selected' : ''}>${h(exam.name)} · ${h(exam.code)}</option>`).join('')}</select></label><label><span>考试科目 *</span><select name="subjectId" id="resultSubject" data-action="result-subject" required><option value="">请选择科目</option>${(activeExam?.subjects || []).map(subject => `<option value="${h(subject.id)}" data-full-score="${h(subject.fullScore)}" data-pass-score="${h(subject.passScore ?? '')}" data-pass-text="${h(subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${subject.passValue}%` : `固定 ${subject.passScore}`)}">${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}</select></label><label><span>考生 *</span><select name="registrationId" data-action="result-candidate" required disabled><option value="">请先选择科目</option></select></label><label><span data-score-label>成绩 *</span><input type="number" name="score" min="0" step="0.5" required></label><label><span>等级规则</span><input value="按同场同科排名自动计算" disabled></label></div><p class="score-rule-hint" data-score-hint>选择科目后显示其独立及格规则。</p><div class="result-entry-submit"><label class="agreement publish-switch"><input type="checkbox" name="published" checked><span>保存后立即向考生发布</span></label><button class="solid-button" type="submit">保存成绩</button></div></form></section>` : '';
const importPreview = preview ? `<section class="panel result-import-preview"><header><div><span>EXCEL STAGING AREA</span><h2>${h(preview.fileName || '成绩导入预览')}</h2><p>此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。</p></div><button class="row-action" data-action="cancel-result-import">取消预览</button></header><div class="import-preview-metrics"><span><small>总行数</small><strong>${preview.summary.total}</strong></span><span class="valid"><small>可提交</small><strong>${preview.summary.valid}</strong></span><span class="invalid"><small>有错误</small><strong>${preview.summary.invalid}</strong></span><span><small>新增 / 更新</small><strong>${preview.summary.create} / ${preview.summary.update}</strong></span><span><small>将发布</small><strong>${preview.summary.publish}</strong></span></div><div class="table-scroll"><table class="import-preview-table"><thead><tr><th>Excel 行</th><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>独立及格线</th><th>发布</th><th>写入方式 / 校验</th></tr></thead><tbody>${preview.rows.map(row => `<tr class="${row.errors.length ? 'row-invalid' : ''}"><td class="mono">${h(row.sourceRow)}</td><td><strong>${h(row.candidateName || '未匹配')}</strong><small class="mono">${h(row.candidateNumber)}</small></td><td><strong>${h(row.examName || row.examCode)}</strong><small>${h(row.subjectName)}</small></td><td><strong>${Number.isFinite(row.score) ? h(row.score) : '—'}</strong><small>${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}</small></td><td><strong>${h(row.passText || '—')}</strong><small>${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}</small></td><td>${badge(row.published ? 'published' : 'draft')}</td><td>${row.errors.length ? `<ul>${row.errors.map(error => `<li>${h(error)}</li>`).join('')}</ul>` : `<strong>${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}</strong><small>校验通过</small>`}</td></tr>`).join('')}</tbody></table></div><form data-form="result-import-commit"><div class="import-commit-bar"><p>${preview.summary.invalid ? `${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}</p><button class="solid-button" type="submit" ${preview.summary.invalid ? 'disabled' : ''}>确认提交到数据库</button></div></form></section>` : '';
const appealLedger = `<section class="panel data-panel"><div class="panel-title"><div><h2>本场成绩复议</h2><p>复议按考生所属班级和学校自动分配,可在流程中心办理。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>考生 / 科目</th><th>考试</th><th>原成绩</th><th>复议理由</th><th>当前步骤</th><th>责任人</th><th>状态</th></tr></thead><tbody>${examAppeals.map(appeal => `<tr><td><strong>${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}</strong></td><td>${h(appeal.result?.examName)}</td><td><strong>${h(appeal.result?.score)}</strong></td><td><small>${h(appeal.reason)}</small></td><td>${h(appeal.currentStepDetail?.name || '流程已结束')}</td><td>${h(appeal.assignee?.displayName || '—')}</td><td>${badge(appeal.status)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">本场暂无成绩复议申请</td></tr>'}</tbody></table></div></section>`;
const examStrip = `<section class="result-exam-strip">${data.exams.map(exam => `<button class="${exam.id === activeExam?.id ? 'active' : ''}" data-action="result-exam-filter" data-id="${h(exam.id)}"><span>${h(exam.code)}</span><strong>${h(exam.name)}</strong><small>${exam.registrationCount} 人 · 已录 ${exam.scored}/${exam.enrolledSubjects} 科次</small><i style="--progress:${exam.enrolledSubjects ? Math.round(exam.scored / exam.enrolledSubjects * 100) : 0}%"></i></button>`).join('')}</section>`;
const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>成绩 Excel 工作区</strong><small>模板导入会先暂存预览,确认后才原子写入数据库</small></span><div><button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载模板</button><button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`;
const metrics = `<section class="result-metric-grid"><article><small>报名考生</small><strong>${activeExam?.registrationCount ?? 0}</strong><span>本场已通过报名</span></article><article><small>录入进度</small><strong>${activeExam?.scored ?? 0}<em> / ${activeExam?.enrolledSubjects ?? 0}</em></strong><span>剩余 ${activeExam?.missing ?? 0} 科次</span></article><article><small>已发布</small><strong>${activeExam?.published ?? 0}</strong><span>草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条</span></article><article><small>成绩已出齐</small><strong>${activeExam?.complete ?? 0}</strong><span>人</span></article><article><small>整场合格率</small><strong>${passRate == null ? '—' : `${passRate}%`}</strong><span>按本场排名或所设规则判定</span></article><article><small>成绩复议</small><strong>${examAppeals.length}</strong><span>当前考试累计</span></article></section>`;
const ledger = `<section class="panel data-panel result-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultTable" placeholder="搜索报名号、姓名、学校或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultTable" data-status="published">已发布</button><button data-action="status-filter" data-target="resultTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="resultTable" data-status="qualified">单科达线</button><button data-action="status-filter" data-target="resultTable" data-status="unqualified">单科未达线</button></div></div><div class="table-scroll"><table id="resultTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>排名 / 等级</th><th>单科及格规则</th><th>达线</th><th>发布</th><th>更新时间</th></tr></thead><tbody>${examResults.map(result => `<tr data-status="${result.published ? 'published' : 'draft'} ${result.qualified === true ? 'qualified' : result.qualified === false ? 'unqualified' : ''}"><td><div class="person-cell"><span>${h((result.candidateName || '?').slice(0,1))}</span><div><strong>${h(result.candidateName)}</strong><small class="mono">${h(result.candidateNumber)}</small><small>${h(result.schoolName)} · ${h(result.className)}</small></div></div></td><td><strong>${h(result.subjectName)}</strong><small>${h(result.examCode)}</small></td><td><strong class="result-score">${h(result.score)}<em> / ${h(result.fullScore)}</em></strong></td><td><strong>第 ${h(result.rank)} / ${h(result.cohortSize)} 名</strong><small>${h(result.grade)} · 前 ${h(result.rankPercent)}%</small></td><td><strong>${h(result.passText)}</strong></td><td>${result.qualified == null ? '<span class="result-neutral">不判定</span>' : result.qualified ? '<span class="result-qualified">达线</span>' : '<span class="result-unqualified">未达线</span>'}</td><td>${badge(result.published ? 'published' : 'draft')}</td><td>${formatDate(result.updatedAt || result.publishedAt, true)}</td></tr>`).join('') || '<tr><td colspan="8" class="empty-state">本场考试还没有成绩记录</td></tr>'}</tbody></table></div></section>`;
return `${examStrip}${metrics}${toolbar}${importPreview}${entry}${ledger}${appealLedger}`;
}
function adminUsers(data) {
+7 -4
View File
@@ -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 `<div class="result-groups">${Object.entries(grouped).map(([examName, items]) => {
const completeSummaries = summaries.filter(item => item.complete);
const overview = `<section class="candidate-result-overview"><article><small>已发布考试</small><strong>${Object.keys(grouped).length}</strong><span>场</span></article><article><small>已发布科目</small><strong>${results.length}</strong><span>科</span></article><article><small>整场已合格</small><strong>${completeSummaries.filter(item => item.qualified === true).length}</strong><span>场</span></article><article><small>复议处理中</small><strong>${results.filter(item => item.appeal?.status === 'pending').length}</strong><span>项</span></article></section>`;
return `${overview}<div class="result-groups">${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([examName, items]) => {
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'
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
: `${appeal ? `<div class="score-appeal-state">${badge('rejected')}<small>${h(latestAction?.note || '可补充理由后重新提交')}</small></div>` : ''}<form class="score-appeal-form" data-form="score-appeal"><input type="hidden" name="resultId" value="${h(item.id)}"><textarea name="reason" rows="2" minlength="5" maxlength="500" required placeholder="填写成绩复议理由(至少 5 个字)"></textarea><button class="row-action primary" type="submit">${appeal ? '重新申请复议' : '申请成绩复议'}</button></form>`;
return `<article><span>${h(item.subjectName)}</span><strong>${h(item.score)}</strong><em>${h(item.grade)}</em><small>满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)}</small>${appealPanel}</article>`;
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
}).join('');
return `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small> ${formatDate(items[0].publishedAt, true)}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small></small><strong>${h(summary?.total ?? '')}<em> / ${h(summary?.fullScore ?? '')}</em></strong></span><span><small></small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span></div><div class="score-grid">${scores}</div><footer><p></p><strong> ${items.length} </strong></footer></section>`;
return `<section class="panel result-panel"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small> ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small></small><strong>${h(summary?.total ?? '')}<em> / ${h(summary?.fullScore ?? '')}</em></strong><i></i></span><span><small></small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small></small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} </em></strong><i>${summary?.complete ? '' : ''}</i></span></div><div class="score-grid">${scores}</div><footer><p></p><strong> ${items.length} </strong></footer></section>`;
}).join('')}</div>`;
}
+2
View File
@@ -5,5 +5,7 @@ export const state = {
permissions: [],
scopeLabel: '',
pageData: null,
resultExamFilter: '',
resultImportPreview: null,
loading: false
};
+2 -2
View File
@@ -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) {
+5 -5
View File
@@ -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: [
+18 -4
View File
@@ -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);
+7 -3
View File
@@ -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),
+43 -5
View File
@@ -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');
+107 -16
View File
@@ -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);
+12 -1
View File
@@ -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 });