Add independent subject pass rules and atomic score imports
This commit is contained in:
@@ -45,14 +45,18 @@
|
|||||||
- 考务指标与审计日志
|
- 考务指标与审计日志
|
||||||
- 考生资料审核、通过或退回修改
|
- 考生资料审核、通过或退回修改
|
||||||
- 考试报名及科目审核
|
- 考试报名及科目审核
|
||||||
- 创建考试并结构化配置科目日期、时间、费用、满分与单科合格分,自动汇总总分
|
- 创建考试并结构化配置科目日期、时间、费用和满分;每科可独立选择固定分、排名前百分比或不设单科线,并汇总总分
|
||||||
- 支持固定总分线、总分得分率、排名前百分比、单科均达线及不判定五类合格策略
|
- 支持固定总分线、总成绩排名前百分比、单科均达线及不判定四类整场合格策略
|
||||||
- 通知发布、草稿、撤回及首页置顶
|
- 通知发布、草稿、撤回及首页置顶
|
||||||
- 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编
|
- 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编
|
||||||
- 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则
|
- 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则
|
||||||
- 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排
|
- 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排
|
||||||
- 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现
|
- 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现
|
||||||
- 按科目满分校验单科成绩、按得分率计算等级,并在全部科目发布后汇总总分与合格结果
|
- 每科可独立设置固定及格分、排名前百分比或不设单科线;成绩等级按同场同科排名百分位自动计算
|
||||||
|
- 默认排名等级区间为前 10% A+、前 25% A、前 50% B+、前 70% B、前 90% C、其余 D;同分共享名次
|
||||||
|
- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账
|
||||||
|
- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论
|
||||||
|
- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库
|
||||||
- 管理员与考生接口权限隔离
|
- 管理员与考生接口权限隔离
|
||||||
|
|
||||||
### 系统能力
|
### 系统能力
|
||||||
@@ -80,7 +84,7 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `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 <CSV路径> src/data/china-regions.mjs`。
|
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||||
|
|
||||||
@@ -155,7 +159,7 @@ npm start
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。
|
测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ document.addEventListener('click', async event => {
|
|||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (target.dataset.template === '1') query.set('template', '1');
|
if (target.dataset.template === '1') query.set('template', '1');
|
||||||
if (target.dataset.batchId) query.set('batchId', target.dataset.batchId);
|
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}`;
|
window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -184,6 +185,14 @@ document.addEventListener('click', async event => {
|
|||||||
document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click();
|
document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click();
|
||||||
return;
|
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') {
|
if (action === 'generate-admit') {
|
||||||
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
||||||
if (registration.admitCard) return openAdmitPreview(registration);
|
if (registration.admitCard) return openAdmitPreview(registration);
|
||||||
@@ -215,7 +224,7 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'status-filter') {
|
if (action === 'status-filter') {
|
||||||
target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
|
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); }
|
} catch (error) { toast('操作未完成', error.message); }
|
||||||
});
|
});
|
||||||
@@ -246,11 +255,15 @@ document.addEventListener('change', event => {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
toast('正在导入 Excel', `${file.name} · 逐行校验并写入,错误会标出具体行`);
|
toast(resource === 'results' ? '正在生成导入预览' : '正在导入 Excel', resource === 'results' ? `${file.name} · 当前不会写入数据库` : `${file.name} · 逐行校验并写入,错误会标出具体行`);
|
||||||
const result = await api(`/api/admin/excel/${resource}`, {
|
const result = await api(`/api/admin/excel/${resource}`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer()
|
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'; });
|
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; });
|
result.quotas.forEach(item => { const field = document.querySelector(`[data-class-id="${item.classId}"]`); if (field) field.value = item.count; });
|
||||||
toast('班级配额已填入', `已读取 ${result.quotas.length} 个班级,请核对后提交审批`);
|
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-school]')?.classList.toggle('hidden', event.target.value === 'super');
|
||||||
form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class');
|
form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class');
|
||||||
}
|
}
|
||||||
if (event.target.matches('[data-action="result-registration"]')) {
|
if (event.target.matches('[data-action="result-exam"]')) {
|
||||||
const option = event.target.selectedOptions[0];
|
const form = event.target.closest('form');
|
||||||
const select = document.querySelector('#resultSubject');
|
const exam = state.pageData?.exams?.find(item => item.id === event.target.value);
|
||||||
const subjects = option?.dataset.subjects ? JSON.parse(option.dataset.subjects) : [];
|
const subjectSelect = form?.querySelector('[name="subjectId"]');
|
||||||
select.innerHTML = `<option value="">请选择科目</option>${subjects.map(subject => `<option value="${h(subject.id)}" data-full-score="${h(subject.fullScore)}" data-pass-score="${h(subject.passScore)}">${h(subject.name)} · 满分 ${h(subject.fullScore)}</option>`).join('')}`;
|
const candidateSelect = form?.querySelector('[name="registrationId"]');
|
||||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
if (subjectSelect) subjectSelect.innerHTML = `<option value="">请选择科目</option>${(exam?.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('')}`;
|
||||||
|
if (candidateSelect) { candidateSelect.innerHTML = '<option value="">请先选择科目</option>'; candidateSelect.disabled = true; }
|
||||||
|
subjectSelect?.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
if (event.target.matches('[data-action="result-subject"]')) {
|
if (event.target.matches('[data-action="result-subject"]')) {
|
||||||
const form = event.target.closest('form');
|
const form = event.target.closest('form');
|
||||||
const option = event.target.selectedOptions[0];
|
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 fullScore = Number(option?.dataset.fullScore || 0);
|
||||||
const passScore = Number(option?.dataset.passScore || 0);
|
const passText = option?.dataset.passText || '';
|
||||||
const scoreInput = form?.querySelector('[name="score"]');
|
const scoreInput = form?.querySelector('[name="score"]');
|
||||||
if (scoreInput) scoreInput.max = fullScore || '';
|
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 = `<option value="">${subjectId ? '请选择考生' : '请先选择科目'}</option>${registrations.map(item => `<option value="${h(item.id)}">${h(item.candidateName)} · ${h(item.candidateNumber)} · ${h(item.className)}</option>`).join('')}`;
|
||||||
|
}
|
||||||
const label = form?.querySelector('[data-score-label]');
|
const label = form?.querySelector('[data-score-label]');
|
||||||
const hint = form?.querySelector('[data-score-hint]');
|
const hint = form?.querySelector('[data-score-hint]');
|
||||||
if (label) label.textContent = fullScore ? `成绩(0—${fullScore})` : '成绩';
|
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"]')) {
|
if (event.target.matches('[name="passPolicy"]')) {
|
||||||
refreshExamScoringForm(event.target.closest('form'));
|
refreshExamScoringForm(event.target.closest('form'));
|
||||||
}
|
}
|
||||||
@@ -409,7 +441,7 @@ document.addEventListener('submit', async event => {
|
|||||||
: body.businessType === 'candidate_account_batch'
|
: body.businessType === 'candidate_account_batch'
|
||||||
? `/api/admin/candidate-account-batches/${body.businessId}`
|
? `/api/admin/candidate-account-batches/${body.businessId}`
|
||||||
: `/api/admin/score-appeals/${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();
|
closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute();
|
||||||
} else if (kind === 'flow-transfer') {
|
} else if (kind === 'flow-transfer') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
@@ -428,7 +460,8 @@ document.addEventListener('submit', async event => {
|
|||||||
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
||||||
name: row.querySelector('[name="subjectName"]').value.trim(),
|
name: row.querySelector('[name="subjectName"]').value.trim(),
|
||||||
fullScore: Number(row.querySelector('[name="subjectFullScore"]').value),
|
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,
|
date: row.querySelector('[name="subjectDate"]').value,
|
||||||
start: row.querySelector('[name="subjectStart"]').value,
|
start: row.querySelector('[name="subjectStart"]').value,
|
||||||
end: row.querySelector('[name="subjectEnd"]').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;
|
const body = formObject(form); body.published = form.published.checked;
|
||||||
await api('/api/admin/results', { method: 'POST', body });
|
await api('/api/admin/results', { method: 'POST', body });
|
||||||
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
|
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); }
|
} catch (error) { toast('操作未完成', error.message); }
|
||||||
finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } }
|
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 isBatch = instance.businessType === 'candidate_account_batch';
|
||||||
const isAppeal = instance.businessType === 'score_appeal';
|
const isAppeal = instance.businessType === 'score_appeal';
|
||||||
const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length;
|
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 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 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 change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null;
|
||||||
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}</small></span>`).join('')}</div></section>` : '';
|
const changeSnapshot = change ? `<section class="flow-center-snapshot"><header><div><span>申请快照</span><h3>${h(change.name)} · ${h(change.code)}</h3></div><b>${change.rooms.length} 个考场</b></header><dl><div><dt>地址</dt><dd>${h(change.address)}</dd></div><div><dt>负责人</dt><dd>${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}</dd></div><div><dt>开放时间</dt><dd>${h(change.gateOpenTime || '未填写')}</dd></div><div><dt>档案状态</dt><dd>${change.centerStatus === 'active' ? '启用' : '停用'}</dd></div></dl><div>${change.rooms.map(room => `<span><strong>${h(room.name)}</strong><small>${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}</small></span>`).join('')}</div></section>` : '';
|
||||||
const batch = instance.accountBatch;
|
const batch = instance.accountBatch;
|
||||||
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
|
const batchSnapshot = batch ? `<section class="flow-batch-snapshot"><header><div><span>班级配额</span><h3>${batch.totalCount} 个待建账户</h3></div><b>${batch.quotas.length} 个班级</b></header><div>${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><small>${item.count} 人</small></span>`).join('')}</div><p>最终批准时才生成固定报名号和随机初始密码。</p></section>` : '';
|
||||||
|
const appeal = instance.appealResult;
|
||||||
|
const appealSnapshot = appeal ? `<section class="flow-appeal-snapshot"><header><div><span>复议成绩快照</span><h3>${h(appeal.examName)} · ${h(appeal.subjectName)}</h3></div><b>${h(appeal.score)} / ${h(appeal.fullScore)}</b></header><dl><div><dt>当前等级</dt><dd>${h(appeal.grade)}</dd></div><div><dt>本科排名</dt><dd>第 ${h(appeal.rank)} / ${h(appeal.cohortSize)} 名(前 ${h(appeal.rankPercent)}%)</dd></div><div><dt>单科规则</dt><dd>${h(appeal.passText)}</dd></div><div><dt>当前结论</dt><dd>${appeal.qualified == null ? '不判定' : appeal.qualified ? '达线' : '未达线'}</dd></div></dl></section>` : '';
|
||||||
|
const reviewedScoreField = finalAppealStep ? `<label><span>复核后分数(批准时必填)</span><input name="reviewedScore" type="number" min="0" max="${h(appeal?.fullScore || 0)}" step="0.5" value="${h(appeal?.score ?? '')}"></label>` : '';
|
||||||
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}${batchSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">${finalBatchStep ? '最终批准并生成账号' : '确认处理'}</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
setModal(`<div class="modal-head"><div><span>WORKFLOW TRACE</span><h2>${h(instance.workflowName)}</h2><p>${h(subject)} · ${h(subjectDetail)}</p></div><button data-action="close-modal">×</button></div>${changeSnapshot}${batchSnapshot}<div class="flow-detail-track">${instance.steps.map(step => `<div class="${step.position < instance.currentStep || instance.status === 'approved' ? 'done' : step.position === instance.currentStep && instance.status === 'pending' ? 'current' : ''}"><i>${step.position}</i><span><strong>${h(step.name)}</strong><small>${h(statusLabels[step.adminLevel])}</small></span></div>`).join('')}</div><section class="flow-history"><h3>流程轨迹</h3>${history || '<p>暂无操作</p>'}</section>${canProcess ? `<form class="modal-form compact-flow-form" data-form="flow-process"><input type="hidden" name="businessType" value="${h(instance.businessType)}"><input type="hidden" name="businessId" value="${h(instance.businessId)}"><div class="field-row"><label><span>处理结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回申请</option></select></label><label><span>处理意见</span><input name="reviewNote" placeholder="填写核验说明"></label></div><button class="solid-button" type="submit">${finalBatchStep ? '最终批准并生成账号' : '确认处理'}</button></form><form class="modal-form compact-flow-form" data-form="flow-transfer"><input type="hidden" name="id" value="${h(instance.id)}"><div class="field-row"><label><span>转交给同级管理员</span><select name="assigneeId" required>${available.filter(item => item.id !== instance.assignee?.id).map(item => `<option value="${h(item.id)}">${h(item.displayName)}</option>`).join('')}</select></label><label><span>转交说明</span><input name="note"></label></div><button class="ghost-button" type="submit" ${available.filter(item => item.id !== instance.assignee?.id).length ? '' : 'disabled'}>转交流程</button></form>` : '<div class="read-only-callout">当前流程未分配给你,只能查看轨迹。</div>'}${state.pageData.canSupervise ? `<form class="modal-form supervisor-form" data-form="flow-supervise"><input type="hidden" name="id" value="${h(instance.id)}"><h3>超级管理员监督调整</h3><div class="field-row"><label><span>调整到步骤</span><select name="currentStep">${instance.steps.map(step => `<option value="${step.position}" ${step.position === instance.currentStep ? 'selected' : ''}>${step.position}. ${h(step.name)}</option>`).join('')}</select></label><label><span>指定责任人(可留空自动选择)</span><select name="assigneeId"><option value="">自动选择</option>${state.pageData.availableAdmins.map(item => `<option value="${h(item.id)}">${h(item.displayName)} · ${h(statusLabels[item.adminLevel])}</option>`).join('')}</select></label></div><label><span>监督说明</span><input name="note" placeholder="说明修改或退回原因"></label><button class="ghost-button" type="submit">监督调整</button></form>` : ''}`);
|
||||||
|
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) {
|
function openCandidateReview(id) {
|
||||||
@@ -523,18 +574,47 @@ function dateTimeLocal(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function examSubjectEditor(subject = {}) {
|
function examSubjectEditor(subject = {}) {
|
||||||
return `<article class="exam-subject-editor"><header><span>科目明细</span><button type="button" data-action="remove-exam-subject">移除</button></header><div class="exam-subject-grid"><label class="subject-name-field"><span>科目名称 *</span><input name="subjectName" required value="${h(subject.name || '')}" placeholder="例如:语文"></label><label><span>满分 *</span><input name="subjectFullScore" type="number" min="0.5" max="1000" step="0.5" required value="${h(subject.fullScore ?? 100)}"></label><label><span>单科合格分 *</span><input name="subjectPassScore" type="number" min="0" max="1000" step="0.5" required value="${h(subject.passScore ?? 60)}"></label><label><span>考试日期 *</span><input name="subjectDate" type="date" required value="${h(subject.date || '')}"></label><label><span>开始 *</span><input name="subjectStart" type="time" required value="${h(subject.start || '09:00')}"></label><label><span>结束 *</span><input name="subjectEnd" type="time" required value="${h(subject.end || '11:00')}"></label><label><span>费用</span><input name="subjectFee" type="number" min="0" step="0.01" value="${h(subject.fee ?? 0)}"></label></div></article>`;
|
const passRule = subject.passRule === 'score_ratio' ? 'rank_percent' : (subject.passRule || 'fixed_score');
|
||||||
|
const passValue = subject.passValue ?? subject.passScore ?? 60;
|
||||||
|
return `<article class="exam-subject-editor"><header><span>科目明细</span><button type="button" data-action="remove-exam-subject">移除</button></header><div class="exam-subject-grid"><label class="subject-name-field"><span>科目名称 *</span><input name="subjectName" required value="${h(subject.name || '')}" placeholder="例如:语文"></label><label><span>满分 *</span><input name="subjectFullScore" type="number" min="0.5" max="1000" step="0.5" required value="${h(subject.fullScore ?? 100)}"></label><label><span>单科及格线方式 *</span><select name="subjectPassRule"><option value="fixed_score" ${passRule === 'fixed_score' ? 'selected' : ''}>固定分</option><option value="rank_percent" ${passRule === 'rank_percent' ? 'selected' : ''}>按排名比例</option><option value="none" ${passRule === 'none' ? 'selected' : ''}>不设单科线</option></select></label><label data-subject-pass-value><span data-subject-pass-label>规则数值 *</span><span class="unit-input"><input name="subjectPassValue" type="number" min="0" max="1000" step="0.1" required value="${h(passValue)}"><em data-subject-pass-unit>分</em></span></label><label><span>考试日期 *</span><input name="subjectDate" type="date" required value="${h(subject.date || '')}"></label><label><span>开始 *</span><input name="subjectStart" type="time" required value="${h(subject.start || '09:00')}"></label><label><span>结束 *</span><input name="subjectEnd" type="time" required value="${h(subject.end || '11:00')}"></label><label><span>费用</span><input name="subjectFee" type="number" min="0" step="0.01" value="${h(subject.fee ?? 0)}"></label></div><p class="subject-pass-preview" data-subject-pass-preview></p></article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function refreshExamScoringForm(form) {
|
||||||
if (!form?.matches('[data-form="exam-form"]')) return;
|
if (!form?.matches('[data-form="exam-form"]')) return;
|
||||||
const rows = [...form.querySelectorAll('.exam-subject-editor')];
|
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 total = rows.reduce((sum, row) => sum + Number(row.querySelector('[name="subjectFullScore"]')?.value || 0), 0);
|
||||||
const totalElement = form.querySelector('[data-exam-total]');
|
const totalElement = form.querySelector('[data-exam-total]');
|
||||||
const countElement = form.querySelector('[data-exam-subject-count]');
|
const countElement = form.querySelector('[data-exam-subject-count]');
|
||||||
if (totalElement) totalElement.textContent = total;
|
if (totalElement) totalElement.textContent = total;
|
||||||
if (countElement) countElement.textContent = rows.length;
|
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 valueField = form.querySelector('[data-pass-value-field]');
|
||||||
const valueInput = form.querySelector('[name="passValue"]');
|
const valueInput = form.querySelector('[name="passValue"]');
|
||||||
const unit = form.querySelector('[data-pass-unit]');
|
const unit = form.querySelector('[data-pass-unit]');
|
||||||
@@ -549,10 +629,9 @@ function refreshExamScoringForm(form) {
|
|||||||
if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%';
|
if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%';
|
||||||
const hints = {
|
const hints = {
|
||||||
fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。',
|
fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。',
|
||||||
score_ratio: '按“实得总分 ÷ 所报科目满分”判断,适合允许选科的考试。',
|
|
||||||
rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。',
|
rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。',
|
||||||
subject_scores: '每个科目都必须达到上方配置的单科合格分。',
|
subject_scores: '每个科目都必须达到上方配置的单科合格分。',
|
||||||
none: '只展示成绩、总分和得分率,不显示合格或未合格。'
|
none: '只展示成绩、总分和排名,不显示合格或未合格。'
|
||||||
};
|
};
|
||||||
if (hint) hint.textContent = hints[policy];
|
if (hint) hint.textContent = hints[policy];
|
||||||
}
|
}
|
||||||
@@ -561,7 +640,7 @@ function openExamForm(exam = null) {
|
|||||||
const editing = Boolean(exam);
|
const editing = Boolean(exam);
|
||||||
if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿');
|
if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿');
|
||||||
const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }];
|
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(`<div class="modal-head"><div><span>${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'}</span><h2>${editing ? '编辑考试草稿' : '创建考试与科目'}</h2><p>先定义科目计分,再选择整场考试的合格判定方式。</p></div><button type="button" data-action="close-modal" aria-label="关闭弹窗">×</button></div><form class="modal-form exam-config-form" data-form="exam-form">${editing ? `<input type="hidden" name="id" value="${h(exam.id)}">` : ''}<section class="exam-form-section"><header><span>01</span><div><h3>基本安排</h3><p>考试名称、开放窗口与考点信息</p></div></header><div class="field-row"><label><span>考试名称 *</span><input name="name" required value="${h(exam?.name)}" placeholder="例如:2027 年春季学业考试"></label><label><span>考试代码</span><input name="code" value="${h(exam?.code)}" placeholder="自动生成或手动填写"></label></div><label><span>考试说明</span><textarea name="description" rows="2" placeholder="报名对象、考试范围等">${h(exam?.description)}</textarea></label><div class="field-row"><label><span>报名开始 *</span><input name="registrationStart" type="datetime-local" value="${dateTimeLocal(exam?.registrationStart)}" required></label><label><span>报名结束 *</span><input name="registrationEnd" type="datetime-local" value="${dateTimeLocal(exam?.registrationEnd)}" required></label></div><div class="field-row"><label><span>考试开始 *</span><input name="examStart" type="datetime-local" value="${dateTimeLocal(exam?.examStart)}" required></label><label><span>考试结束 *</span><input name="examEnd" type="datetime-local" value="${dateTimeLocal(exam?.examEnd)}" required></label></div><div class="field-row"><label><span>准考证下载开始 *</span><input name="admitDownloadStart" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadStart)}" required></label><label><span>准考证下载结束 *</span><input name="admitDownloadEnd" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadEnd)}" required></label></div></section><section class="exam-form-section subject-config-section"><header><span>02</span><div><h3>科目与总分</h3><p>每科独立设置满分、合格分、时间和费用</p></div><aside><small><b data-exam-subject-count>${subjects.length}</b> 科</small><strong><b data-exam-total>0</b> 分</strong></aside></header><div data-exam-subjects>${subjects.map(examSubjectEditor).join('')}</div><button class="add-subject-button" type="button" data-action="add-exam-subject">${icons.plus} 添加科目</button></section><section class="exam-form-section pass-policy-section"><header><span>03</span><div><h3>合格线策略</h3><p>系统会在所有报考科目成绩发布后自动判定</p></div></header><div class="pass-policy-grid"><label><span>判定方式 *</span><select name="passPolicy"><option value="score_ratio" ${passPolicy === 'score_ratio' ? 'selected' : ''}>按总分得分率</option><option value="fixed_score" ${passPolicy === 'fixed_score' ? 'selected' : ''}>固定总分线</option><option value="rank_percent" ${passPolicy === 'rank_percent' ? 'selected' : ''}>总成绩排名前百分比</option><option value="subject_scores" ${passPolicy === 'subject_scores' ? 'selected' : ''}>所有单科均达线</option><option value="none" ${passPolicy === 'none' ? 'selected' : ''}>不判定合格</option></select></label><label data-pass-value-field><span>策略数值 *</span><span class="unit-input"><input name="passValue" type="number" min="0" max="100" step="0.1" value="${h(exam?.passValue ?? 60)}" required><em data-pass-unit>%</em></span></label></div><p class="pass-policy-hint" data-pass-hint></p></section><section class="exam-form-section"><header><span>04</span><div><h3>发布设置</h3><p>草稿可继续维护,发布后对考生开放</p></div></header><div class="field-row"><label><span>考点说明</span><input name="location" value="${h(exam?.location)}" placeholder="例如:全市指定考点"></label><label><span>${editing ? '保存状态' : '创建状态'}</span><select name="status"><option value="draft">保存为草稿</option><option value="published">立即发布</option></select></label></div></section><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存考试' : '创建考试'}</button></div></form>`);
|
setModal(`<div class="modal-head"><div><span>${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'}</span><h2>${editing ? '编辑考试草稿' : '创建考试与科目'}</h2><p>先定义科目计分,再选择整场考试的合格判定方式。</p></div><button type="button" data-action="close-modal" aria-label="关闭弹窗">×</button></div><form class="modal-form exam-config-form" data-form="exam-form">${editing ? `<input type="hidden" name="id" value="${h(exam.id)}">` : ''}<section class="exam-form-section"><header><span>01</span><div><h3>基本安排</h3><p>考试名称、开放窗口与考点信息</p></div></header><div class="field-row"><label><span>考试名称 *</span><input name="name" required value="${h(exam?.name)}" placeholder="例如:2027 年春季学业考试"></label><label><span>考试代码</span><input name="code" value="${h(exam?.code)}" placeholder="自动生成或手动填写"></label></div><label><span>考试说明</span><textarea name="description" rows="2" placeholder="报名对象、考试范围等">${h(exam?.description)}</textarea></label><div class="field-row"><label><span>报名开始 *</span><input name="registrationStart" type="datetime-local" value="${dateTimeLocal(exam?.registrationStart)}" required></label><label><span>报名结束 *</span><input name="registrationEnd" type="datetime-local" value="${dateTimeLocal(exam?.registrationEnd)}" required></label></div><div class="field-row"><label><span>考试开始 *</span><input name="examStart" type="datetime-local" value="${dateTimeLocal(exam?.examStart)}" required></label><label><span>考试结束 *</span><input name="examEnd" type="datetime-local" value="${dateTimeLocal(exam?.examEnd)}" required></label></div><div class="field-row"><label><span>准考证下载开始 *</span><input name="admitDownloadStart" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadStart)}" required></label><label><span>准考证下载结束 *</span><input name="admitDownloadEnd" type="datetime-local" value="${dateTimeLocal(exam?.admitDownloadEnd)}" required></label></div></section><section class="exam-form-section subject-config-section"><header><span>02</span><div><h3>科目与总分</h3><p>每科独立设置满分、合格分、时间和费用</p></div><aside><small><b data-exam-subject-count>${subjects.length}</b> 科</small><strong><b data-exam-total>0</b> 分</strong></aside></header><div data-exam-subjects>${subjects.map(examSubjectEditor).join('')}</div><button class="add-subject-button" type="button" data-action="add-exam-subject">${icons.plus} 添加科目</button></section><section class="exam-form-section pass-policy-section"><header><span>03</span><div><h3>合格线策略</h3><p>系统会在所有报考科目成绩发布后自动判定</p></div></header><div class="pass-policy-grid"><label><span>判定方式 *</span><select name="passPolicy"><option value="score_ratio" ${passPolicy === 'score_ratio' ? 'selected' : ''}>按总分得分率</option><option value="fixed_score" ${passPolicy === 'fixed_score' ? 'selected' : ''}>固定总分线</option><option value="rank_percent" ${passPolicy === 'rank_percent' ? 'selected' : ''}>总成绩排名前百分比</option><option value="subject_scores" ${passPolicy === 'subject_scores' ? 'selected' : ''}>所有单科均达线</option><option value="none" ${passPolicy === 'none' ? 'selected' : ''}>不判定合格</option></select></label><label data-pass-value-field><span>策略数值 *</span><span class="unit-input"><input name="passValue" type="number" min="0" max="100" step="0.1" value="${h(exam?.passValue ?? 60)}" required><em data-pass-unit>%</em></span></label></div><p class="pass-policy-hint" data-pass-hint></p></section><section class="exam-form-section"><header><span>04</span><div><h3>发布设置</h3><p>草稿可继续维护,发布后对考生开放</p></div></header><div class="field-row"><label><span>考点说明</span><input name="location" value="${h(exam?.location)}" placeholder="例如:全市指定考点"></label><label><span>${editing ? '保存状态' : '创建状态'}</span><select name="status"><option value="draft">保存为草稿</option><option value="published">立即发布</option></select></label></div></section><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存考试' : '创建考试'}</button></div></form>`);
|
||||||
refreshExamScoringForm(modalRoot.querySelector('[data-form="exam-form"]'));
|
refreshExamScoringForm(modalRoot.querySelector('[data-form="exam-form"]'));
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-14
@@ -57,7 +57,7 @@ function buildSeedOperations(state) {
|
|||||||
const nullable = value => value == null || value === '' ? null : value;
|
const nullable = value => value == null || value === '' ? null : value;
|
||||||
|
|
||||||
add(
|
add(
|
||||||
'UPDATE schema_metadata SET schema_version = 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,
|
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||||
state.meta?.createdAt || new Date().toISOString()
|
state.meta?.createdAt || new Date().toISOString()
|
||||||
);
|
);
|
||||||
@@ -129,15 +129,16 @@ function buildSeedOperations(state) {
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
||||||
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '',
|
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '',
|
||||||
exam.passPolicy || 'score_ratio', 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(
|
exam.subjects.forEach((subject, index) => add(
|
||||||
`INSERT INTO exam_subjects (
|
`INSERT INTO exam_subjects (
|
||||||
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position
|
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
|
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),
|
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) {
|
function stateFromRows(rows) {
|
||||||
const subjectsByExam = new Map();
|
const subjectsByExam = new Map();
|
||||||
for (const row of rows.subjects) {
|
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 = {
|
const subject = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -349,8 +353,10 @@ function stateFromRows(rows) {
|
|||||||
start: row.start_time,
|
start: row.start_time,
|
||||||
end: row.end_time,
|
end: row.end_time,
|
||||||
fee: Number(row.fee),
|
fee: Number(row.fee),
|
||||||
fullScore: Number(row.full_score ?? 150),
|
fullScore,
|
||||||
passScore: Number(row.pass_score ?? 90),
|
passRule,
|
||||||
|
passValue,
|
||||||
|
passScore: passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null,
|
||||||
order: Number(row.position)
|
order: Number(row.position)
|
||||||
};
|
};
|
||||||
const subjects = subjectsByExam.get(row.exam_id) || [];
|
const subjects = subjectsByExam.get(row.exam_id) || [];
|
||||||
@@ -521,7 +527,7 @@ function stateFromRows(rows) {
|
|||||||
admitDownloadStart: row.admit_download_start,
|
admitDownloadStart: row.admit_download_start,
|
||||||
admitDownloadEnd: row.admit_download_end,
|
admitDownloadEnd: row.admit_download_end,
|
||||||
location: row.location,
|
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),
|
passValue: Number(row.pass_value ?? 60),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
@@ -1008,6 +1014,12 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
|
status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
|
||||||
business.status, optional(business.reviewNote), optional(business.reviewedAt), business.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));
|
if (log) operations.push(auditOperation(log));
|
||||||
await transaction(operations);
|
await transaction(operations);
|
||||||
@@ -1298,11 +1310,11 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
)];
|
)];
|
||||||
exam.subjects.forEach((subject, index) => operations.push(operation(
|
exam.subjects.forEach((subject, index) => operations.push(operation(
|
||||||
`INSERT INTO exam_subjects (
|
`INSERT INTO exam_subjects (
|
||||||
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position
|
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
|
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),
|
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));
|
operations.push(auditOperation(log));
|
||||||
await transaction(operations);
|
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));
|
operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id));
|
||||||
exam.subjects.forEach((subject, index) => operations.push(operation(
|
exam.subjects.forEach((subject, index) => operations.push(operation(
|
||||||
`INSERT INTO exam_subjects (
|
`INSERT INTO exam_subjects (
|
||||||
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position
|
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
|
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),
|
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));
|
operations.push(auditOperation(log));
|
||||||
@@ -1370,6 +1382,26 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
optional(result.updatedAt), optional(result.publishedAt), result.id
|
optional(result.updatedAt), optional(result.publishedAt), result.id
|
||||||
);
|
);
|
||||||
await transaction([resultOperation, auditOperation(log)]);
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,12 +72,17 @@ const resourceSpecs = {
|
|||||||
results: {
|
results: {
|
||||||
title: '考试成绩台账', sheet: '成绩',
|
title: '考试成绩台账', sheet: '成绩',
|
||||||
columns: [
|
columns: [
|
||||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['examCode', '考试代码*', 20, 'EX-2026-AUT'],
|
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['candidateName', '姓名(只读参考)', 16, ''],
|
||||||
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''], ['passScore', '单科合格线(只读参考)', 20, ''], ['score', '成绩*', 12, 120],
|
['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''],
|
||||||
['grade', '等级', 12, 'A'], ['published', '发布状态*', 14, '发布']
|
['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: ['发布', '不发布'] },
|
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: {
|
admit_cards: {
|
||||||
title: '准考证信息台账', sheet: '准考证信息',
|
title: '准考证信息台账', sheet: '准考证信息',
|
||||||
@@ -149,7 +154,7 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
|
|||||||
const lastColumn = spec.columns.length;
|
const lastColumn = spec.columns.length;
|
||||||
sheet.columns = spec.columns.map(([key, , width]) => ({ key, width }));
|
sheet.columns = spec.columns.map(([key, , width]) => ({ key, width }));
|
||||||
spec.columns.forEach(([key], index) => {
|
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);
|
sheet.mergeCells(1, 1, 1, lastColumn);
|
||||||
const titleCell = sheet.getCell(1, 1);
|
const titleCell = sheet.getCell(1, 1);
|
||||||
|
|||||||
+177
-22
@@ -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) {
|
function examResultSummary(db, registration) {
|
||||||
const exam = db.exams.find(item => item.id === registration.examId);
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
if (!exam) return null;
|
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 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 fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||||||
const scoreRatio = fullScore ? total / fullScore * 100 : 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);
|
const value = Number(exam.passValue ?? 60);
|
||||||
let qualified = null;
|
let qualified = null;
|
||||||
let rank = null;
|
let rank = null;
|
||||||
let cohortSize = null;
|
let cohortSize = null;
|
||||||
|
|
||||||
if (complete && policy === 'fixed_score') qualified = total >= value;
|
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 => {
|
||||||
if (complete && policy === 'subject_scores') qualified = subjects.every(subject => Number(resultsBySubject.get(subject.id).score) >= Number(subject.passScore));
|
const subjectResult = resultsBySubject.get(subject.id);
|
||||||
|
return subjectPassEvaluation(db, subjectResult, subject).qualified !== false;
|
||||||
|
});
|
||||||
if (complete && policy === 'none') qualified = null;
|
if (complete && policy === 'none') qualified = null;
|
||||||
if (complete && policy === 'rank_percent') {
|
if (complete && policy === 'rank_percent') {
|
||||||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||||||
@@ -432,12 +480,24 @@ function excelRowsForResource(db, user, resource, searchParams) {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
if (resource === 'results') {
|
if (resource === 'results') {
|
||||||
|
const examId = cleanText(searchParams.get('examId'), 64);
|
||||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
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 registration = db.registrations.find(item => item.id === result.registrationId);
|
||||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
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') {
|
if (resource === 'admit_cards') {
|
||||||
@@ -452,6 +512,112 @@ function excelImportError(row, message) {
|
|||||||
return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 });
|
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) {
|
async function importExcelResource(db, user, resource, rows) {
|
||||||
if (resource === 'classes') {
|
if (resource === 'classes') {
|
||||||
if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 });
|
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 };
|
return { count: groups.size };
|
||||||
}
|
}
|
||||||
if (resource === 'results') {
|
if (resource === 'results') {
|
||||||
if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 });
|
return commitResultImport(db, user, rows);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 });
|
throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -648,12 +798,17 @@ const routeContext = {
|
|||||||
publicExam,
|
publicExam,
|
||||||
examRegistrationView,
|
examRegistrationView,
|
||||||
examResultSummary,
|
examResultSummary,
|
||||||
|
subjectPassText,
|
||||||
|
subjectPassEvaluation,
|
||||||
|
resultRankInfo,
|
||||||
logAction,
|
logAction,
|
||||||
excelResourceNames,
|
excelResourceNames,
|
||||||
excelRowsForResource,
|
excelRowsForResource,
|
||||||
admissionRowsForRegistrations,
|
admissionRowsForRegistrations,
|
||||||
centerMaterialRows,
|
centerMaterialRows,
|
||||||
importExcelResource,
|
importExcelResource,
|
||||||
|
prepareResultImport,
|
||||||
|
commitResultImport,
|
||||||
admitCardHtml,
|
admitCardHtml,
|
||||||
admitCardsHtml,
|
admitCardsHtml,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function createAdminViews(context) {
|
|||||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
|
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: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||||
organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'],
|
organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'],
|
||||||
@@ -80,7 +80,7 @@ export function createAdminViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminExams(exams) {
|
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) {
|
function adminNotices(notices) {
|
||||||
@@ -109,9 +109,20 @@ export function createAdminViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminResults(data) {
|
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 preview = state.resultImportPreview;
|
||||||
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>`;
|
const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => item.registrationCount) || data.exams[0];
|
||||||
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}`;
|
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) {
|
function adminUsers(data) {
|
||||||
|
|||||||
@@ -147,10 +147,12 @@ export function createCandidateViews(context) {
|
|||||||
const { results, summaries = [] } = data;
|
const { results, summaries = [] } = data;
|
||||||
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
|
||||||
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
|
const grouped = Object.groupBy ? Object.groupBy(results, item => item.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 summary = summaries.find(item => item.examId === items[0].examId);
|
||||||
const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线';
|
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 scores = items.map(item => {
|
||||||
const appeal = item.appeal;
|
const appeal = item.appeal;
|
||||||
const latestAction = appeal?.actions?.at(-1);
|
const latestAction = appeal?.actions?.at(-1);
|
||||||
@@ -159,9 +161,10 @@ export function createCandidateViews(context) {
|
|||||||
: appeal?.status === 'approved'
|
: appeal?.status === 'approved'
|
||||||
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
|
? `<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>`;
|
: `${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('');
|
}).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>`;
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ export const state = {
|
|||||||
permissions: [],
|
permissions: [],
|
||||||
scopeLabel: '',
|
scopeLabel: '',
|
||||||
pageData: null,
|
pageData: null,
|
||||||
|
resultExamFilter: '',
|
||||||
|
resultImportPreview: null,
|
||||||
loading: false
|
loading: false
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -29,11 +29,11 @@ export function passPolicyText(exam) {
|
|||||||
const value = Number(exam?.passValue ?? 60);
|
const value = Number(exam?.passValue ?? 60);
|
||||||
return {
|
return {
|
||||||
fixed_score: `总分达到 ${value} 分`,
|
fixed_score: `总分达到 ${value} 分`,
|
||||||
score_ratio: `得分率达到 ${value}%`,
|
score_ratio: `总成绩排名前 ${value}%`,
|
||||||
rank_percent: `总成绩排名前 ${value}%`,
|
rank_percent: `总成绩排名前 ${value}%`,
|
||||||
subject_scores: '所有报考科目均达单科线',
|
subject_scores: '所有报考科目均达单科线',
|
||||||
none: '仅发布成绩,不判定合格'
|
none: '仅发布成绩,不判定合格'
|
||||||
}[exam?.passPolicy || 'score_ratio'];
|
}[exam?.passPolicy || 'rank_percent'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(value, withTime = false) {
|
export function formatDate(value, withTime = false) {
|
||||||
|
|||||||
+5
-5
@@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
return {
|
return {
|
||||||
meta: { version: 10, createdAt: nowIso() },
|
meta: { version: 12, createdAt: nowIso() },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: {
|
organization: {
|
||||||
name: '海州市教育考试中心',
|
name: '海州市教育考试中心',
|
||||||
@@ -53,10 +53,10 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
|
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',
|
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',
|
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: [
|
subjects: [
|
||||||
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', 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, 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_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_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 },
|
{ 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',
|
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',
|
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',
|
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: [
|
registrations: [
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ export function createMysqlAdapter(context) {
|
|||||||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL',
|
'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 registration_number VARCHAR(120) NULL',
|
||||||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) 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 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 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_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 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_name VARCHAR(100) NULL',
|
||||||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) 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].schema_version = 10;
|
||||||
metadataRows[0].app_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) {
|
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||||
const extension = seed();
|
const extension = seed();
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
@@ -305,8 +319,8 @@ export function createMysqlAdapter(context) {
|
|||||||
if (Number(metadataRows[0]?.app_version || 1) < 7) {
|
if (Number(metadataRows[0]?.app_version || 1) < 7) {
|
||||||
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
|
await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1');
|
||||||
}
|
}
|
||||||
if (Number(metadataRows[0]?.schema_version || 1) < 10) {
|
if (Number(metadataRows[0]?.schema_version || 1) < 12) {
|
||||||
await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1');
|
await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!existing.length) {
|
if (!existing.length) {
|
||||||
@@ -316,7 +330,7 @@ export function createMysqlAdapter(context) {
|
|||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
const [insert] = await connection.execute(`
|
const [insert] = await connection.execute(`
|
||||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
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()]);
|
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||||
if (insert.affectedRows === 1) {
|
if (insert.affectedRows === 1) {
|
||||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const sqliteSchema = `
|
|||||||
admit_download_start TEXT NOT NULL,
|
admit_download_start TEXT NOT NULL,
|
||||||
admit_download_end TEXT NOT NULL,
|
admit_download_end TEXT NOT NULL,
|
||||||
location 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,
|
pass_value REAL NOT NULL DEFAULT 60,
|
||||||
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
|
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
@@ -125,6 +125,8 @@ export const sqliteSchema = `
|
|||||||
fee REAL NOT NULL DEFAULT 0,
|
fee REAL NOT NULL DEFAULT 0,
|
||||||
full_score REAL NOT NULL DEFAULT 150,
|
full_score REAL NOT NULL DEFAULT 150,
|
||||||
pass_score REAL NOT NULL DEFAULT 90,
|
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,
|
position INTEGER NOT NULL,
|
||||||
UNIQUE (exam_id, position)
|
UNIQUE (exam_id, position)
|
||||||
) STRICT;
|
) STRICT;
|
||||||
@@ -207,7 +209,7 @@ export const sqliteSchema = `
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||||||
subject_id TEXT NOT NULL REFERENCES exam_subjects(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,
|
grade TEXT NOT NULL,
|
||||||
published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)),
|
published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)),
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
@@ -541,7 +543,7 @@ export const mysqlSchema = [
|
|||||||
admit_download_start VARCHAR(35) NOT NULL,
|
admit_download_start VARCHAR(35) NOT NULL,
|
||||||
admit_download_end VARCHAR(35) NOT NULL,
|
admit_download_end VARCHAR(35) NOT NULL,
|
||||||
location VARCHAR(200) 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,
|
pass_value DOUBLE NOT NULL DEFAULT 60,
|
||||||
status ENUM('draft', 'published', 'closed') NOT NULL,
|
status ENUM('draft', 'published', 'closed') NOT NULL,
|
||||||
created_at VARCHAR(35) NOT NULL,
|
created_at VARCHAR(35) NOT NULL,
|
||||||
@@ -559,6 +561,8 @@ export const mysqlSchema = [
|
|||||||
fee DOUBLE NOT NULL DEFAULT 0,
|
fee DOUBLE NOT NULL DEFAULT 0,
|
||||||
full_score DOUBLE NOT NULL DEFAULT 150,
|
full_score DOUBLE NOT NULL DEFAULT 150,
|
||||||
pass_score DOUBLE NOT NULL DEFAULT 90,
|
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,
|
position INT UNSIGNED NOT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_subjects_exam_position (exam_id, position),
|
UNIQUE KEY uq_subjects_exam_position (exam_id, position),
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ export function createSqliteAdapter(context) {
|
|||||||
]);
|
]);
|
||||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||||||
ensureColumns('exams', [
|
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', [
|
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', [
|
ensureColumns('test_centers', [
|
||||||
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
||||||
@@ -194,6 +195,43 @@ export function createSqliteAdapter(context) {
|
|||||||
`).run();
|
`).run();
|
||||||
connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').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) {
|
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||||
const extension = seed();
|
const extension = seed();
|
||||||
connection.exec('BEGIN IMMEDIATE');
|
connection.exec('BEGIN IMMEDIATE');
|
||||||
@@ -344,8 +382,8 @@ export function createSqliteAdapter(context) {
|
|||||||
if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
|
if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
|
||||||
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
|
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
|
||||||
}
|
}
|
||||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 10) {
|
if (existingSystem && Number(existingSystem.schema_version || 1) < 12) {
|
||||||
connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run();
|
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()) {
|
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||||||
@@ -354,7 +392,7 @@ export function createSqliteAdapter(context) {
|
|||||||
try {
|
try {
|
||||||
connection.prepare(`
|
connection.prepare(`
|
||||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
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());
|
`).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);
|
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||||
connection.exec('COMMIT');
|
connection.exec('COMMIT');
|
||||||
|
|||||||
+107
-16
@@ -33,6 +33,10 @@ export function createAdminRoutes(context) {
|
|||||||
parseCenterChange,
|
parseCenterChange,
|
||||||
maskId,
|
maskId,
|
||||||
publicExam,
|
publicExam,
|
||||||
|
examResultSummary,
|
||||||
|
subjectPassText,
|
||||||
|
resultRankInfo,
|
||||||
|
subjectPassEvaluation,
|
||||||
examRegistrationView,
|
examRegistrationView,
|
||||||
logAction,
|
logAction,
|
||||||
excelResourceNames,
|
excelResourceNames,
|
||||||
@@ -40,6 +44,8 @@ export function createAdminRoutes(context) {
|
|||||||
admissionRowsForRegistrations,
|
admissionRowsForRegistrations,
|
||||||
centerMaterialRows,
|
centerMaterialRows,
|
||||||
importExcelResource,
|
importExcelResource,
|
||||||
|
prepareResultImport,
|
||||||
|
commitResultImport,
|
||||||
admitCardHtml,
|
admitCardHtml,
|
||||||
admitCardsHtml,
|
admitCardsHtml,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -56,7 +62,8 @@ export function createAdminRoutes(context) {
|
|||||||
permissionsByLevel
|
permissionsByLevel
|
||||||
} = context;
|
} = 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) {
|
function normalizeSubjects(input, examStart) {
|
||||||
const source = Array.isArray(input) ? input : String(input || '').split(/[,,]/);
|
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);
|
const name = cleanText(structured ? item.name : item, 50);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const fullScore = Number(structured ? item.fullScore : 150);
|
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 {
|
return {
|
||||||
id: uid('sub'),
|
id: uid('sub'),
|
||||||
name,
|
name,
|
||||||
@@ -73,7 +84,9 @@ export function createAdminRoutes(context) {
|
|||||||
end: cleanText(structured ? item.end : '', 5) || '11:00',
|
end: cleanText(structured ? item.end : '', 5) || '11:00',
|
||||||
fee: Number(structured ? item.fee ?? 0 : 0),
|
fee: Number(structured ? item.fee ?? 0 : 0),
|
||||||
fullScore,
|
fullScore,
|
||||||
passScore: Number(structured ? item.passScore : fullScore * .6),
|
passRule,
|
||||||
|
passValue,
|
||||||
|
passScore,
|
||||||
order: index + 1
|
order: index + 1
|
||||||
};
|
};
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
@@ -82,12 +95,14 @@ export function createAdminRoutes(context) {
|
|||||||
function validateExamScoring(subjects, passPolicy, passValue) {
|
function validateExamScoring(subjects, passPolicy, passValue) {
|
||||||
if (!subjects.length) return '请至少添加一个考试科目';
|
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.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 (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内';
|
||||||
if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略';
|
if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略';
|
||||||
const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0);
|
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 (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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +143,10 @@ export function createAdminRoutes(context) {
|
|||||||
const resource = excelMatch[1];
|
const resource = excelMatch[1];
|
||||||
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出');
|
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出');
|
||||||
const rows = await parseWorkbook(resource, await readBodyBuffer(request));
|
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);
|
const result = await importExcelResource(db, user, resource, rows);
|
||||||
return sendJson(response, 200, { ok: true, ...result });
|
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 appealRegistration = appealResult ? db.registrations.find(item => item.id === appealResult.registrationId) : null;
|
||||||
const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null;
|
const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null;
|
||||||
const appealSubject = appealExam?.subjects.find(item => item.id === appealResult?.subjectId);
|
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 {
|
return {
|
||||||
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
|
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
|
||||||
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
|
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
|
||||||
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
|
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
|
||||||
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
|
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
|
||||||
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0,
|
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);
|
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, '该流程当前未分配给你,可由当前处理人转交');
|
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
|
||||||
const note = cleanText(body.reviewNote, 300);
|
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 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') {
|
if (body.status === 'rejected') {
|
||||||
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
||||||
} else if (instance.currentStep < workflow.steps.length) {
|
} 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;
|
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
|
||||||
} else {
|
} else {
|
||||||
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
|
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 log = logAction(db, user, body.status === 'approved' ? (finalStep ? '复议终审并更新成绩' : '处理成绩复议') : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}${finalStep && body.status === 'approved' ? ` · ${result.score} 分` : ''}`);
|
||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
await database.processWorkflow(instance, action, finalStep && body.status === 'approved' ? result : null, log);
|
||||||
const log = logAction(db, user, body.status === 'approved' ? '处理成绩复议' : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}`);
|
const rank = resultRankInfo(db, result);
|
||||||
await database.processWorkflow(instance, action, null, log);
|
const pass = subjectPassEvaluation(db, result, subject);
|
||||||
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
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)?$/);
|
const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/);
|
||||||
if (request.method === 'POST' && arrangementMatch) {
|
if (request.method === 'POST' && arrangementMatch) {
|
||||||
@@ -783,7 +831,8 @@ export function createAdminRoutes(context) {
|
|||||||
const name = cleanText(body.name, 100);
|
const name = cleanText(body.name, 100);
|
||||||
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
|
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
|
||||||
const subjects = normalizeSubjects(body.subjects, body.examStart);
|
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 passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60);
|
||||||
const scoringError = validateExamScoring(subjects, passPolicy, passValue);
|
const scoringError = validateExamScoring(subjects, passPolicy, passValue);
|
||||||
if (scoringError) return sendError(response, 400, scoringError);
|
if (scoringError) return sendError(response, 400, scoringError);
|
||||||
@@ -804,7 +853,10 @@ export function createAdminRoutes(context) {
|
|||||||
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
|
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
|
||||||
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
|
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); });
|
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);
|
if (body.passValue != null) exam.passValue = Number(body.passValue);
|
||||||
let replaceSubjects = false;
|
let replaceSubjects = false;
|
||||||
if (body.subjects != null) {
|
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 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 registration = db.registrations.find(item => item.id === result.registrationId);
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
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 exam = db.exams.find(item => item.id === registration?.examId);
|
||||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
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 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 result = results.find(item => item.id === instance.businessId);
|
||||||
const workflow = workflowView(db, instance);
|
const workflow = workflowView(db, instance);
|
||||||
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
|
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 (request.method === 'POST' && pathname === '/api/admin/results') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
@@ -884,8 +975,8 @@ export function createAdminRoutes(context) {
|
|||||||
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
|
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
|
||||||
db.results.push(result);
|
db.results.push(result);
|
||||||
}
|
}
|
||||||
const ratio = score / subject.fullScore;
|
Object.assign(result, { score, published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? (result.publishedAt || nowIso()) : null });
|
||||||
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 });
|
result.grade = result.published ? resultRankInfo(db, result, score).grade : '待发布';
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
|
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
|
||||||
await database.saveResult(result, isNew, log);
|
await database.saveResult(result, isNew, log);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export function createCandidateRoutes(context) {
|
|||||||
publicExam,
|
publicExam,
|
||||||
examRegistrationView,
|
examRegistrationView,
|
||||||
examResultSummary,
|
examResultSummary,
|
||||||
|
subjectPassText,
|
||||||
|
resultRankInfo,
|
||||||
|
subjectPassEvaluation,
|
||||||
logAction,
|
logAction,
|
||||||
excelResourceNames,
|
excelResourceNames,
|
||||||
excelRowsForResource,
|
excelRowsForResource,
|
||||||
@@ -128,7 +131,15 @@ export function createCandidateRoutes(context) {
|
|||||||
const subject = exam.subjects.find(item => item.id === result.subjectId);
|
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 appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id);
|
||||||
const appeal = appealInstance ? workflowView(db, appealInstance) : null;
|
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);
|
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
||||||
return sendJson(response, 200, { ok: true, results, summaries });
|
return sendJson(response, 200, { ok: true, results, summaries });
|
||||||
|
|||||||
+102
@@ -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; }
|
.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 */
|
/* 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-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; }
|
.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; }
|
||||||
|
|||||||
+28
-5
@@ -94,12 +94,14 @@ try {
|
|||||||
const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version;
|
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 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 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();
|
inspector.close();
|
||||||
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
||||||
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
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(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
|
||||||
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.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');
|
const publicHome = await anonymous.request('/api/public/home');
|
||||||
assert.equal(publicHome.response.status, 200);
|
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(),
|
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(),
|
admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(),
|
||||||
location: '测试考点', status: 'published', subjects: [
|
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: '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.response.status, 201);
|
||||||
assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试');
|
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 exam = createExam.data.exam;
|
||||||
const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } });
|
const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } });
|
||||||
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
|
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
|
||||||
@@ -519,6 +523,16 @@ try {
|
|||||||
const refreshedHome = await anonymous.request('/api/public/home');
|
const refreshedHome = await anonymous.request('/api/public/home');
|
||||||
assert.ok(refreshedHome.data.notices.some(item => item.title === '系统测试成绩发布通知'), '管理员发布通知后首页应可见');
|
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 } });
|
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(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, '成绩不得超过该科配置的满分');
|
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);
|
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
||||||
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
||||||
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
|
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');
|
const classResults = await classAdmin.request('/api/admin/results');
|
||||||
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
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, '班级管理员不得录入或发布成绩');
|
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: '学校完成成绩复核' } });
|
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.response.status, 200);
|
||||||
assert.equal(schoolApproved.data.workflow.currentStepDetail.adminLevel, 'super', '校级复核后应进入考试中心终审');
|
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.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');
|
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.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 workflowDefinitions = await admin.request('/api/admin/workflows');
|
||||||
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');
|
||||||
|
|||||||
Reference in New Issue
Block a user