diff --git a/README.md b/README.md
index 50eb5cd..a496534 100644
--- a/README.md
+++ b/README.md
@@ -44,10 +44,11 @@
- 考务指标与审计日志
- 考生资料审核、通过或退回修改
- 考试报名及科目审核
-- 创建考试、配置报名/考试/准考证时间与多个科目
+- 创建考试并结构化配置科目日期、时间、费用、满分与单科合格分,自动汇总总分
+- 支持固定总分线、总分得分率、排名前百分比、单科均达线及不判定五类合格策略
- 通知发布、草稿、撤回及首页置顶
- 为审核通过的报名生成准考证号、考点、考场和座位
-- 单科成绩录入、等级计算与发布控制
+- 按科目满分校验单科成绩、按得分率计算等级,并在全部科目发布后汇总总分与合格结果
- 管理员与考生接口权限隔离
### 系统能力
diff --git a/app.js b/app.js
index e39be0c..c5e35de 100644
--- a/app.js
+++ b/app.js
@@ -3,7 +3,7 @@ import { createAdminViews } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-views.mjs';
import { createPublicViews } from './src/client/public-views.mjs';
import { state } from './src/client/state.mjs';
-import { badge, dateRange, formatDate, h, icons, money, statusLabels } from './src/client/ui.mjs';
+import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
const app = document.querySelector('#app');
const modalRoot = document.querySelector('#modalRoot');
@@ -34,7 +34,7 @@ function renderError(error) {
app.innerHTML = `! 页面暂时无法加载 ${h(error.message)}
重新加载 `;
}
-const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, statusLabels, icons, api, renderError, emptyState };
+const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser });
@@ -146,6 +146,21 @@ document.addEventListener('click', async event => {
if (list.children.length <= 1) return toast('至少保留一步', '审批流程不能为空');
target.closest('.workflow-step-row').remove(); return;
}
+ if (action === 'add-exam-subject') {
+ const form = target.closest('form');
+ const date = form?.examStart?.value?.slice(0, 10) || '';
+ form?.querySelector('[data-exam-subjects]')?.insertAdjacentHTML('beforeend', examSubjectEditor({ date }));
+ refreshExamScoringForm(form);
+ return;
+ }
+ if (action === 'remove-exam-subject') {
+ const form = target.closest('form');
+ const list = target.closest('[data-exam-subjects]');
+ if (list.children.length <= 1) return toast('至少保留一个科目', '考试计划必须包含科目');
+ target.closest('.exam-subject-editor').remove();
+ refreshExamScoringForm(form);
+ return;
+ }
if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id));
if (action === 'review-candidate') return openCandidateReview(target.dataset.id);
if (action === 'review-registration') return openRegistrationReview(target.dataset.id);
@@ -194,8 +209,10 @@ document.addEventListener('input', event => {
form.querySelector('[data-subject-count]').textContent = checked.length;
const exam = state.pageData.exams.find(item => item.id === form.examId.value);
const fee = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fee || 0), 0);
- form.querySelector('[data-subject-fee]').textContent = `合计 ${money(fee)}`;
+ const fullScore = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fullScore || 0), 0);
+ form.querySelector('[data-subject-fee]').textContent = `满分 ${fullScore} · ${money(fee)}`;
}
+ if (event.target.closest('[data-exam-subjects]') || event.target.matches('[name="passValue"]')) refreshExamScoringForm(event.target.closest('form'));
});
document.addEventListener('change', event => {
@@ -239,7 +256,23 @@ document.addEventListener('change', event => {
const option = event.target.selectedOptions[0];
const select = document.querySelector('#resultSubject');
const subjects = option?.dataset.subjects ? JSON.parse(option.dataset.subjects) : [];
- select.innerHTML = `请选择科目 ${subjects.map(subject => `${h(subject.name)} `).join('')}`;
+ select.innerHTML = `请选择科目 ${subjects.map(subject => `${h(subject.name)} · 满分 ${h(subject.fullScore)} `).join('')}`;
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ if (event.target.matches('[data-action="result-subject"]')) {
+ const form = event.target.closest('form');
+ const option = event.target.selectedOptions[0];
+ const fullScore = Number(option?.dataset.fullScore || 0);
+ const passScore = Number(option?.dataset.passScore || 0);
+ const scoreInput = form?.querySelector('[name="score"]');
+ if (scoreInput) scoreInput.max = fullScore || '';
+ const label = form?.querySelector('[data-score-label]');
+ const hint = form?.querySelector('[data-score-hint]');
+ if (label) label.textContent = fullScore ? `成绩(0—${fullScore})` : '成绩';
+ if (hint) hint.textContent = fullScore ? `本科满分 ${fullScore} 分,单科合格线 ${passScore} 分。` : '选择科目后显示满分与单科合格线。';
+ }
+ if (event.target.matches('[name="passPolicy"]')) {
+ refreshExamScoringForm(event.target.closest('form'));
}
});
@@ -358,7 +391,17 @@ document.addEventListener('submit', async event => {
await api('/api/admin/notices', { method: 'POST', body });
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute();
} else if (kind === 'exam-form') {
- const body = formObject(form); body.subjects = body.subjects.split(/[,,]/).map(item => item.trim()).filter(Boolean);
+ const body = formObject(form);
+ body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
+ name: row.querySelector('[name="subjectName"]').value.trim(),
+ fullScore: Number(row.querySelector('[name="subjectFullScore"]').value),
+ passScore: Number(row.querySelector('[name="subjectPassScore"]').value),
+ date: row.querySelector('[name="subjectDate"]').value,
+ start: row.querySelector('[name="subjectStart"]').value,
+ end: row.querySelector('[name="subjectEnd"]').value,
+ fee: Number(row.querySelector('[name="subjectFee"]').value || 0)
+ }));
+ body.passValue = ['subject_scores', 'none'].includes(body.passPolicy) ? 0 : Number(body.passValue);
['registrationStart','registrationEnd','examStart','examEnd','admitDownloadStart','admitDownloadEnd'].forEach(field => body[field] = new Date(body[field]).toISOString());
const editing = Boolean(body.id);
await api(editing ? `/api/admin/exams/${body.id}` : '/api/admin/exams', { method: editing ? 'PATCH' : 'POST', body });
@@ -438,10 +481,48 @@ function dateTimeLocal(value) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
+function examSubjectEditor(subject = {}) {
+ return `科目名称 * 满分 * 单科合格分 * 考试日期 * 开始 * 结束 * 费用
`;
+}
+
+function refreshExamScoringForm(form) {
+ if (!form?.matches('[data-form="exam-form"]')) return;
+ const rows = [...form.querySelectorAll('.exam-subject-editor')];
+ const total = rows.reduce((sum, row) => sum + Number(row.querySelector('[name="subjectFullScore"]')?.value || 0), 0);
+ const totalElement = form.querySelector('[data-exam-total]');
+ const countElement = form.querySelector('[data-exam-subject-count]');
+ if (totalElement) totalElement.textContent = total;
+ if (countElement) countElement.textContent = rows.length;
+ const policy = form.querySelector('[name="passPolicy"]')?.value || 'score_ratio';
+ const valueField = form.querySelector('[data-pass-value-field]');
+ const valueInput = form.querySelector('[name="passValue"]');
+ const unit = form.querySelector('[data-pass-unit]');
+ const hint = form.querySelector('[data-pass-hint]');
+ const hiddenValue = ['subject_scores', 'none'].includes(policy);
+ if (valueField) valueField.classList.toggle('hidden', hiddenValue);
+ if (valueInput) {
+ valueInput.disabled = hiddenValue;
+ valueInput.max = policy === 'fixed_score' ? String(total || 1000) : '100';
+ valueInput.min = policy === 'fixed_score' ? '0' : '0.1';
+ }
+ if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%';
+ const hints = {
+ fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。',
+ score_ratio: '按“实得总分 ÷ 所报科目满分”判断,适合允许选科的考试。',
+ rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。',
+ subject_scores: '每个科目都必须达到上方配置的单科合格分。',
+ none: '只展示成绩、总分和得分率,不显示合格或未合格。'
+ };
+ if (hint) hint.textContent = hints[policy];
+}
+
function openExamForm(exam = null) {
const editing = Boolean(exam);
if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿');
- setModal(`
${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'} ${editing ? '编辑考试草稿' : '创建考试与科目'} ${editing ? '补全考试信息和科目后,可保存草稿或直接发布。' : '创建后可选择直接发布或先保存为草稿。'}
× `);
+ const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }];
+ const passPolicy = exam?.passPolicy || 'score_ratio';
+ setModal(`${editing ? 'EDIT EXAM DRAFT' : 'NEW EXAM'} ${editing ? '编辑考试草稿' : '创建考试与科目'} 先定义科目计分,再选择整场考试的合格判定方式。
× ${editing ? ` ` : ''} `);
+ refreshExamScoringForm(modalRoot.querySelector('[data-form="exam-form"]'));
}
function openAdmitPreview(reg) {
diff --git a/database.mjs b/database.mjs
index 810c369..fa92e5d 100644
--- a/database.mjs
+++ b/database.mjs
@@ -118,18 +118,19 @@ function buildSeedOperations(state) {
add(
`INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
- admit_download_start, admit_download_end, location, status, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ admit_download_start, admit_download_end, location, pass_policy, pass_value, status, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '',
- exam.status, exam.createdAt
+ exam.passPolicy || 'score_ratio', Number(exam.passValue ?? 60), exam.status, exam.createdAt
);
exam.subjects.forEach((subject, index) => add(
`INSERT INTO exam_subjects (
- id, exam_id, name, subject_date, start_time, end_time, fee, position
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
- subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1)
+ 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)
));
}
@@ -303,6 +304,8 @@ function stateFromRows(rows) {
start: row.start_time,
end: row.end_time,
fee: Number(row.fee),
+ fullScore: Number(row.full_score ?? 150),
+ passScore: Number(row.pass_score ?? 90),
order: Number(row.position)
};
const subjects = subjectsByExam.get(row.exam_id) || [];
@@ -437,6 +440,8 @@ function stateFromRows(rows) {
admitDownloadStart: row.admit_download_start,
admitDownloadEnd: row.admit_download_end,
location: row.location,
+ passPolicy: row.pass_policy || 'score_ratio',
+ passValue: Number(row.pass_value ?? 60),
status: row.status,
createdAt: row.created_at,
subjects: subjectsByExam.get(row.id) || []
@@ -1110,18 +1115,19 @@ function createRepository({ client, location, read, transaction, close }) {
const operations = [operation(
`INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
- admit_download_start, admit_download_end, location, status, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ admit_download_start, admit_download_end, location, pass_policy, pass_value, status, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
- exam.location || '', exam.status, exam.createdAt
+ exam.location || '', exam.passPolicy, exam.passValue, exam.status, exam.createdAt
)];
exam.subjects.forEach((subject, index) => operations.push(operation(
`INSERT INTO exam_subjects (
- id, exam_id, name, subject_date, start_time, end_time, fee, position
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
- subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1)
+ subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore),
+ Number(subject.passScore), Number(subject.order || index + 1)
)));
operations.push(auditOperation(log));
await transaction(operations);
@@ -1132,20 +1138,21 @@ function createRepository({ client, location, read, transaction, close }) {
`UPDATE exams SET
code = ?, name = ?, description = ?, registration_start = ?, registration_end = ?,
exam_start = ?, exam_end = ?, admit_download_start = ?, admit_download_end = ?,
- location = ?, status = ? WHERE id = ?`,
+ location = ?, pass_policy = ?, pass_value = ?, status = ? WHERE id = ?`,
exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
- exam.location || '', exam.status, exam.id
+ exam.location || '', exam.passPolicy, exam.passValue, exam.status, exam.id
)
];
if (replaceSubjects) {
operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id));
exam.subjects.forEach((subject, index) => operations.push(operation(
`INSERT INTO exam_subjects (
- id, exam_id, name, subject_date, start_time, end_time, fee, position
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, position
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
- subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1)
+ subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore),
+ Number(subject.passScore), Number(subject.order || index + 1)
)));
}
operations.push(auditOperation(log));
diff --git a/excel.mjs b/excel.mjs
index 9dcd15a..c8ba406 100644
--- a/excel.mjs
+++ b/excel.mjs
@@ -65,7 +65,7 @@ const resourceSpecs = {
title: '考试成绩台账', sheet: '成绩',
columns: [
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['examCode', '考试代码*', 20, 'EX-2026-AUT'],
- ['subjectName', '科目*', 16, '语文'], ['score', '成绩*', 12, 120],
+ ['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''], ['passScore', '单科合格线(只读参考)', 20, ''], ['score', '成绩*', 12, 120],
['grade', '等级', 12, 'A'], ['published', '发布状态*', 14, '发布']
],
validations: { published: ['发布', '不发布'] },
diff --git a/server.mjs b/server.mjs
index 01d5112..b85dc60 100644
--- a/server.mjs
+++ b/server.mjs
@@ -247,10 +247,62 @@ function publicExam(exam) {
const end = new Date(exam.registrationEnd).getTime();
return {
...exam,
+ totalScore: exam.subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0),
registrationState: now < start ? 'upcoming' : now > end ? 'closed' : 'open'
};
}
+function examResultSummary(db, registration) {
+ const exam = db.exams.find(item => item.id === registration.examId);
+ if (!exam) return null;
+ const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
+ const published = db.results.filter(result => result.registrationId === registration.id && result.published);
+ const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
+ const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
+ const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
+ const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
+ const scoreRatio = fullScore ? total / fullScore * 100 : 0;
+ const policy = exam.passPolicy || 'score_ratio';
+ const value = Number(exam.passValue ?? 60);
+ let qualified = null;
+ let rank = null;
+ let cohortSize = null;
+
+ if (complete && policy === 'fixed_score') qualified = total >= value;
+ if (complete && policy === 'score_ratio') qualified = scoreRatio >= value;
+ if (complete && policy === 'subject_scores') qualified = subjects.every(subject => Number(resultsBySubject.get(subject.id).score) >= Number(subject.passScore));
+ if (complete && policy === 'none') qualified = null;
+ if (complete && policy === 'rank_percent') {
+ const subjectKey = [...registration.subjectIds].sort().join('|');
+ const totals = db.registrations
+ .filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey)
+ .map(item => {
+ const itemResults = db.results.filter(result => result.registrationId === item.id && result.published);
+ if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null;
+ return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0);
+ })
+ .filter(item => item != null);
+ cohortSize = totals.length;
+ rank = 1 + totals.filter(item => item > total).length;
+ qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
+ }
+
+ return {
+ examId: exam.id,
+ complete,
+ publishedSubjects: published.length,
+ subjectCount: subjects.length,
+ total,
+ fullScore,
+ scoreRatio: Number(scoreRatio.toFixed(2)),
+ passPolicy: policy,
+ passValue: value,
+ qualified,
+ rank,
+ cohortSize
+ };
+}
+
function examRegistrationView(db, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id));
@@ -313,7 +365,8 @@ function excelRowsForResource(db, user, resource, searchParams) {
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration?.examId);
- return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: exam?.subjects.find(item => item.id === result.subjectId)?.name || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' };
+ 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 ? '发布' : '不发布' };
});
}
return [];
@@ -427,11 +480,12 @@ async function importExcelResource(db, user, resource, rows) {
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 > 150) throw excelImportError(row, '报名号、考试、科目或成绩无效');
+ 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 };
- Object.assign(result, { score, grade: cleanText(row.grade, 10) || (score >= 120 ? 'A' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null });
+ 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);
}
@@ -484,6 +538,7 @@ const routeContext = {
maskId,
publicExam,
examRegistrationView,
+ examResultSummary,
logAction,
excelResourceNames,
excelRowsForResource,
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index cf08fab..ed0e548 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -7,6 +7,7 @@ export function createAdminViews(context) {
dateRange,
badge,
money,
+ passPolicyText,
statusLabels,
icons,
api,
@@ -77,7 +78,7 @@ export function createAdminViews(context) {
}
function adminExams(exams) {
- return `${exams.map(exam => `
${h(exam.code)} ${badge(exam.status)}${h(exam.name)} ${h(exam.description)}
报名时间 ${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间 ${dateRange(exam.examStart, exam.examEnd)}
考点 ${h(exam.location)} ${exam.subjects.map(subject => `${h(subject.name)} ${h(subject.date)} ${h(subject.start)} `).join('') || '科目待配置 '}
`).join('')}
`;
+ return `${exams.map(exam => `
${h(exam.code)} ${badge(exam.status)}${h(exam.name)} ${h(exam.description)}
考试总分 ${h(exam.totalScore)} 分 合格规则 ${h(passPolicyText(exam))}
报名时间 ${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间 ${dateRange(exam.examStart, exam.examEnd)}
考点 ${h(exam.location)} ${exam.subjects.map(subject => `${h(subject.name)} ${h(subject.date)} ${h(subject.start)} · ${h(subject.fullScore)} 分 `).join('') || '科目待配置 '}
`).join('')}
`;
}
function adminNotices(notices) {
@@ -90,7 +91,7 @@ export function createAdminViews(context) {
}
function adminResults(data) {
- const entry = state.user.adminLevel === 'super' ? `` : '';
+ const entry = state.user.adminLevel === 'super' ? `` : '';
return `${excelToolbar('results', { importable: state.user.adminLevel === 'super', label: '成绩台账' })}${entry}
${state.user.adminLevel === 'super' ? '最近成绩' : '范围内成绩'} ${data.results.length} 条记录 ${data.results.slice(0, 50).map(result => `${h((result.candidateName || '?').slice(0,1))} ${h(result.candidateName)} · ${h(result.subjectName)} ${h(result.examName)}
${h(result.score)} ${badge(result.published ? 'published' : 'draft')}
`).join('') || '还没有成绩记录
'} `;
}
diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs
index 841ce2c..8c3cd95 100644
--- a/src/client/candidate-views.mjs
+++ b/src/client/candidate-views.mjs
@@ -7,6 +7,7 @@ export function createCandidateViews(context) {
dateRange,
badge,
money,
+ passPolicyText,
statusLabels,
icons,
api,
@@ -91,7 +92,7 @@ export function createCandidateViews(context) {
const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
- results: () => candidateResults(data.results), notices: () => candidateNotices(data.notices)
+ results: () => candidateResults(data), notices: () => candidateNotices(data.notices)
}[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
} catch (error) { renderError(error); }
@@ -117,7 +118,7 @@ export function createCandidateViews(context) {
}
function candidateExams(data) {
- return `${data.exams.map(exam => `
${h(exam.name)} ${h(exam.description)}
报名期限 ${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间 ${dateRange(exam.examStart, exam.examEnd)}
考点安排 ${h(exam.location)} 选择报考科目 可多选
${exam.subjects.map(subject => `
${h(subject.name.slice(0, 1))} ${h(subject.name)} ${h(subject.date)} · ${h(subject.start)} ${money(subject.fee)} `).join('') || '
科目安排尚未发布
'}
${exam.registration ? `${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目 ${badge(exam.registration.status)}
` : `已选 0 科 合计 ¥0.00
${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]} `}`).join('')}
`;
+ return `${data.exams.map(exam => `
${h(exam.name)} ${h(exam.description)}
报名期限 ${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间 ${dateRange(exam.examStart, exam.examEnd)}
计分规则 总分 ${h(exam.totalScore)} · ${h(passPolicyText(exam))}
考点安排 ${h(exam.location)} 选择报考科目 可多选
${exam.subjects.map(subject => `
${h(subject.name.slice(0, 1))} ${h(subject.name)} ${h(subject.date)} · ${h(subject.start)} · 满分 ${h(subject.fullScore)} ${money(subject.fee)} `).join('') || '
科目安排尚未发布
'}
${exam.registration ? `${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目 ${badge(exam.registration.status)}
` : `已选 0 科 满分 0 · ¥0.00
${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]} `}`).join('')}
`;
}
function candidateRegistrations(registrations) {
@@ -129,10 +130,11 @@ export function createCandidateViews(context) {
return cards.length ? `${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `
${h(reg.exam.code)} ${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}${h(reg.exam.name)} 准考证号 ${h(reg.admitCard.number)}
考点 ${h(reg.admitCard.testCenter)}
考场 / 座位 ${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}
下载时间 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} ADMISSION CARD ${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'} 下载后请使用 A4 纸打印
`; }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态');
}
- function candidateResults(results) {
+ function candidateResults(data) {
+ const { results, summaries = [] } = data;
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examName) : results.reduce((acc, item) => ((acc[item.examName] ||= []).push(item), acc), {});
- return `${Object.entries(grouped).map(([examName, items]) => `
${items.map(item => `
${h(item.subjectName)} ${h(item.score)} ${h(item.grade)} 满分 150 `).join('')}
`).join('')}
`;
+ return `${Object.entries(grouped).map(([examName, items]) => { const summary = summaries.find(item => item.examId === items[0].examId); const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线'; const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `第 ${summary.rank} / ${summary.cohortSize} 名` : summary ? `得分率 ${summary.scoreRatio}%` : ''; return `
当前总分 ${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')} 合格判定 ${h(stateText)} ${h(detail)}
${items.map(item => `
${h(item.subjectName)} ${h(item.score)} ${h(item.grade)} 满分 ${h(item.fullScore)} · 单科线 ${h(item.passScore)} `).join('')}
`; }).join('')}
`;
}
function candidateNotices(notices) {
diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs
index fb48001..45b1ead 100644
--- a/src/client/public-views.mjs
+++ b/src/client/public-views.mjs
@@ -7,6 +7,7 @@ export function createPublicViews(context) {
dateRange,
badge,
money,
+ passPolicyText,
statusLabels,
icons,
api,
@@ -49,7 +50,7 @@ export function createPublicViews(context) {
}
function renderPublicExam(exam) {
- return `${h(exam.code)} ${badge(exam.registrationState)}${h(exam.name)} ${h(exam.description)}
报名 ${dateRange(exam.registrationStart, exam.registrationEnd)}考试 ${dateRange(exam.examStart, exam.examEnd)}
${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名 ${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow} `;
+ return `${h(exam.code)} ${badge(exam.registrationState)}${h(exam.name)} ${h(exam.description)}
报名 ${dateRange(exam.registrationStart, exam.registrationEnd)}考试 ${dateRange(exam.examStart, exam.examEnd)}总分 ${h(exam.totalScore)} 分 · ${h(passPolicyText(exam))}
${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名 ${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow} `;
}
function renderAuth(kind) {
diff --git a/src/client/ui.mjs b/src/client/ui.mjs
index d1d5b5a..2597060 100644
--- a/src/client/ui.mjs
+++ b/src/client/ui.mjs
@@ -25,6 +25,17 @@ export function h(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
}
+export function passPolicyText(exam) {
+ const value = Number(exam?.passValue ?? 60);
+ return {
+ fixed_score: `总分达到 ${value} 分`,
+ score_ratio: `得分率达到 ${value}%`,
+ rank_percent: `总成绩排名前 ${value}%`,
+ subject_scores: '所有报考科目均达单科线',
+ none: '仅发布成绩,不判定合格'
+ }[exam?.passPolicy || 'score_ratio'];
+}
+
export function formatDate(value, withTime = false) {
if (!value) return '待定';
const date = new Date(value);
diff --git a/src/data/seed.mjs b/src/data/seed.mjs
index 9c66498..2793d11 100644
--- a/src/data/seed.mjs
+++ b/src/data/seed.mjs
@@ -51,15 +51,15 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
- location: '海州市各指定考点', status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
+ location: '海州市各指定考点', passPolicy: 'score_ratio', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
subjects: [
- { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30 },
- { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30 },
- { id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 },
- { id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25 },
- { id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30 },
- { id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25 },
- { id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25 }
+ { id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passScore: 90 },
+ { id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passScore: 90 },
+ { id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
+ { id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
+ { id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30, fullScore: 150, passScore: 90 },
+ { id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25, fullScore: 100, passScore: 60 },
+ { id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25, fullScore: 100, passScore: 60 }
]
},
{
@@ -67,7 +67,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
- location: '考点待公布', status: 'draft', createdAt: nowIso(), subjects: []
+ location: '考点待公布', passPolicy: 'score_ratio', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: []
}
],
registrations: [
diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs
index adad765..96861b7 100644
--- a/src/database/mysql-adapter.mjs
+++ b/src/database/mysql-adapter.mjs
@@ -56,6 +56,10 @@ export function createMysqlAdapter(context) {
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE',
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL',
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
+ "ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'score_ratio'",
+ 'ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_value DOUBLE NOT NULL DEFAULT 60',
+ 'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS full_score DOUBLE NOT NULL DEFAULT 150',
+ 'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_score DOUBLE NOT NULL DEFAULT 90',
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL',
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL',
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL',
diff --git a/src/database/schema.mjs b/src/database/schema.mjs
index f0804a2..0d39f18 100644
--- a/src/database/schema.mjs
+++ b/src/database/schema.mjs
@@ -103,6 +103,8 @@ export const sqliteSchema = `
admit_download_start TEXT NOT NULL,
admit_download_end TEXT NOT NULL,
location TEXT NOT NULL,
+ pass_policy TEXT NOT NULL DEFAULT 'score_ratio' CHECK (pass_policy IN ('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none')),
+ pass_value REAL NOT NULL DEFAULT 60,
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
created_at TEXT NOT NULL
) STRICT;
@@ -115,6 +117,8 @@ export const sqliteSchema = `
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
fee REAL NOT NULL DEFAULT 0,
+ full_score REAL NOT NULL DEFAULT 150,
+ pass_score REAL NOT NULL DEFAULT 90,
position INTEGER NOT NULL,
UNIQUE (exam_id, position)
) STRICT;
@@ -466,6 +470,8 @@ export const mysqlSchema = [
admit_download_start VARCHAR(35) NOT NULL,
admit_download_end VARCHAR(35) NOT NULL,
location VARCHAR(200) NOT NULL,
+ pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'score_ratio',
+ pass_value DOUBLE NOT NULL DEFAULT 60,
status ENUM('draft', 'published', 'closed') NOT NULL,
created_at VARCHAR(35) NOT NULL,
PRIMARY KEY (id),
@@ -480,6 +486,8 @@ export const mysqlSchema = [
start_time VARCHAR(20) NOT NULL,
end_time VARCHAR(20) NOT NULL,
fee DOUBLE NOT NULL DEFAULT 0,
+ full_score DOUBLE NOT NULL DEFAULT 150,
+ pass_score DOUBLE NOT NULL DEFAULT 90,
position INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_subjects_exam_position (exam_id, position),
diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs
index 0854292..bcd56b1 100644
--- a/src/database/sqlite-adapter.mjs
+++ b/src/database/sqlite-adapter.mjs
@@ -37,6 +37,12 @@ export function createSqliteAdapter(context) {
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0']
]);
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
+ ensureColumns('exams', [
+ ['pass_policy', "TEXT NOT NULL DEFAULT 'score_ratio'"], ['pass_value', 'REAL NOT NULL DEFAULT 60']
+ ]);
+ ensureColumns('exam_subjects', [
+ ['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90']
+ ]);
ensureColumns('test_centers', [
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT']
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 849e28f..8f98b7d 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -49,6 +49,41 @@ export function createAdminRoutes(context) {
permissionsByLevel
} = context;
+ const passPolicies = new Set(['fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none']);
+
+ function normalizeSubjects(input, examStart) {
+ const source = Array.isArray(input) ? input : String(input || '').split(/[,,]/);
+ return source.map((item, index) => {
+ const structured = item && typeof item === 'object';
+ const name = cleanText(structured ? item.name : item, 50);
+ if (!name) return null;
+ const fullScore = Number(structured ? item.fullScore : 150);
+ return {
+ id: uid('sub'),
+ name,
+ date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10),
+ start: cleanText(structured ? item.start : '', 5) || '09:00',
+ end: cleanText(structured ? item.end : '', 5) || '11:00',
+ fee: Number(structured ? item.fee : 0),
+ fullScore,
+ passScore: Number(structured ? item.passScore : fullScore * .6),
+ order: index + 1
+ };
+ }).filter(Boolean);
+ }
+
+ function validateExamScoring(subjects, passPolicy, passValue) {
+ if (!subjects.length) return '请至少添加一个考试科目';
+ if (subjects.some(item => !Number.isFinite(item.fullScore) || item.fullScore <= 0 || item.fullScore > 1000)) return '科目满分必须大于 0 且不超过 1000';
+ if (subjects.some(item => !Number.isFinite(item.passScore) || item.passScore < 0 || item.passScore > item.fullScore)) return '单科合格分必须在 0 与该科满分之间';
+ if (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内';
+ if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略';
+ const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0);
+ if (passPolicy === 'fixed_score' && (!Number.isFinite(passValue) || passValue < 0 || passValue > totalScore)) return `固定合格线必须在 0 与总分 ${totalScore} 之间`;
+ if (['score_ratio', 'rank_percent'].includes(passPolicy) && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '百分比必须大于 0 且不超过 100';
+ return '';
+ }
+
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
@@ -607,10 +642,12 @@ export function createAdminRoutes(context) {
const body = await readJson(request);
const name = cleanText(body.name, 100);
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
- const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/);
- const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
- if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目');
- const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
+ const subjects = normalizeSubjects(body.subjects, body.examStart);
+ const passPolicy = passPolicies.has(body.passPolicy) ? body.passPolicy : 'score_ratio';
+ const passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60);
+ const scoringError = validateExamScoring(subjects, passPolicy, passValue);
+ if (scoringError) return sendError(response, 400, scoringError);
+ const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), passPolicy, passValue, status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() };
const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`);
await database.createExam(exam, log);
return sendJson(response, 201, { ok: true, exam });
@@ -623,19 +660,20 @@ export function createAdminRoutes(context) {
if (!exam) return sendError(response, 404, '考试不存在');
const originalStatus = exam.status;
const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'];
- const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null;
+ const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null || body.passPolicy != null || body.passValue != null;
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); });
+ if (body.passPolicy != null && passPolicies.has(body.passPolicy)) exam.passPolicy = body.passPolicy;
+ if (body.passValue != null) exam.passValue = Number(body.passValue);
let replaceSubjects = false;
if (body.subjects != null) {
if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目');
- const subjectNames = Array.isArray(body.subjects) ? body.subjects : String(body.subjects || '').split(/[,,]/);
- const names = subjectNames.map(item => cleanText(typeof item === 'string' ? item : item.name, 30)).filter(Boolean);
- if (!names.length) return sendError(response, 400, '请至少添加一个考试科目');
- exam.subjects = names.map((name, index) => ({ id: uid('sub'), name, date: String(exam.examStart).slice(0, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 }));
+ exam.subjects = normalizeSubjects(body.subjects, exam.examStart);
replaceSubjects = true;
}
+ const scoringError = validateExamScoring(exam.subjects, exam.passPolicy, Number(exam.passValue));
+ if (scoringError) return sendError(response, 400, scoringError);
if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布');
const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`);
@@ -693,16 +731,17 @@ export function createAdminRoutes(context) {
const exam = db.exams.find(item => item.id === registration.examId);
if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目');
const score = Number(body.score);
- if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间');
+ const subject = exam.subjects.find(item => item.id === body.subjectId);
+ if (!Number.isFinite(score) || score < 0 || score > subject.fullScore) return sendError(response, 400, `成绩必须在 0—${subject.fullScore} 之间`);
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId);
const isNew = !result;
if (!result) {
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
db.results.push(result);
}
- Object.assign(result, { score, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
+ const ratio = score / subject.fullScore;
+ Object.assign(result, { score, grade: cleanText(body.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
- const subject = exam.subjects.find(item => item.id === body.subjectId);
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
await database.saveResult(result, isNew, log);
return sendJson(response, 200, { ok: true, result });
diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs
index 604220c..02e31dc 100644
--- a/src/routes/candidate.routes.mjs
+++ b/src/routes/candidate.routes.mjs
@@ -31,6 +31,7 @@ export function createCandidateRoutes(context) {
maskId,
publicExam,
examRegistrationView,
+ examResultSummary,
logAction,
excelResourceNames,
excelRowsForResource,
@@ -121,9 +122,10 @@ export function createCandidateRoutes(context) {
const registration = registrations.find(reg => reg.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration.examId);
const subject = exam.subjects.find(item => item.id === result.subjectId);
- return { ...result, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId };
+ return { ...result, examId: exam.id, examName: exam.name, examCode: exam.code, subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, passScore: subject?.passScore || 90 };
});
- return sendJson(response, 200, { ok: true, results });
+ const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
+ return sendJson(response, 200, { ok: true, results, summaries });
}
const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/);
if (request.method === 'GET' && admitMatch) {
diff --git a/styles.css b/styles.css
index e8d5591..c7bae13 100644
--- a/styles.css
+++ b/styles.css
@@ -225,6 +225,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.public-exam-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }.public-exam-card header { display: flex; justify-content: space-between; }
.public-exam-card h3 { margin: 19px 0 7px; font-family: "STKaiti",serif; font-size: 22px; font-weight: 400; }.public-exam-card > p { min-height: 42px; margin: 0; color: #7d8597; font-size: 10px; line-height: 1.8; }
.exam-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; }.exam-meta span { display: grid; gap: 4px; padding: 10px; border-radius: 7px; background: #f7f8fb; color: #657087; font-size: 9px; }.exam-meta b { color: #9ba2b0; font-size: 8px; font-weight: 500; }
+.exam-meta span:last-child:nth-child(3) { grid-column:1/-1; }
.public-exam-card footer { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--line); }.public-exam-card footer > span { color: #8b93a4; font-size: 9px; }.public-exam-card footer button { display: flex; align-items: center; gap: 6px; border: 0; color: var(--navy); background: transparent; font-size: 10px; font-weight: 700; }.public-exam-card footer svg { width: 14px; }
.service-flow { padding: 85px max(24px,calc((100% - 1180px)/2)); color: #fff; background: var(--navy); }
.section-heading.light .overline { color: #7182b2; }.section-heading.light h2 { color: #fff; }.section-heading.light > p { color: #95a2c5; }
@@ -252,15 +253,59 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; }
.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; }
.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; }
+.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
+.exam-score-band > span { min-height:64px; display:flex; align-items:baseline; gap:5px; padding:13px 22px; }
+.exam-score-band > span + span { display:grid; align-content:center; gap:5px; border-left:1px solid rgba(255,255,255,.12); }
+.exam-score-band small { color:#8491b5; font-size:7px; }
+.exam-score-band strong { margin-left:auto; font-family:Georgia,serif; font-size:27px; font-weight:400; }
+.exam-score-band em { color:#aab5d2; font-size:7px; font-style:normal; }
+.exam-score-band b { color:#e4e9f5; font-size:8px; font-weight:500; }
+.score-rule-hint { margin:0; padding:9px 11px; border-radius:7px; color:#6d7890; background:#f3f6fb; font-size:8px; }
/* Modals and feedback */
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; }.notice-content p { margin:0 0 13px; color:#525d73; font-size:11px; line-height:2; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
+.modal-card:has(.exam-config-form) { width:min(980px,100%); }
+.exam-config-form { gap:18px; }
+.exam-form-section { display:grid; gap:14px; padding:17px; border:1px solid var(--line); border-radius:11px; background:#fbfcfe; }
+.exam-form-section > header { display:flex; align-items:center; gap:11px; }
+.exam-form-section > header > span { width:27px; height:27px; display:grid; place-items:center; flex:0 0 auto; border-radius:7px; color:#fff; background:var(--navy); font-family:Consolas,monospace; font-size:8px; }
+.exam-form-section > header h3 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }
+.exam-form-section > header p { margin:3px 0 0; color:#8b94a5; font-size:7px; }
+.exam-form-section > header aside { display:flex; align-items:baseline; gap:15px; margin-left:auto; }
+.exam-form-section > header aside small { color:#8490a5; font-size:8px; }
+.exam-form-section > header aside strong { color:var(--navy); font-family:Georgia,serif; font-size:22px; font-weight:400; }
+.subject-config-section > div { display:grid; gap:10px; }
+.exam-subject-editor { border:1px solid #dce3ef; border-radius:9px; background:#fff; overflow:hidden; }
+.exam-subject-editor > header { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:#f1f4fa; }
+.exam-subject-editor > header span { color:#65728c; font-size:8px; font-weight:700; }
+.exam-subject-editor > header button { border:0; color:#a74c45; background:transparent; font-size:7px; }
+.exam-subject-grid { display:grid; grid-template-columns:1.35fr repeat(6,1fr); gap:9px; padding:12px; }
+.exam-subject-grid label { min-width:0; }
+.exam-subject-grid input { min-height:40px; padding:8px 9px; }
+.add-subject-button { min-height:40px; display:flex; align-items:center; justify-content:center; gap:7px; border:1px dashed #aab8d0; border-radius:8px; color:#506488; background:#f6f8fc; font-size:9px; font-weight:700; }
+.add-subject-button svg { width:14px; fill:none; stroke:currentColor; stroke-width:1.7; }
+.pass-policy-grid { display:grid; grid-template-columns:1.4fr .6fr; gap:13px; }
+.unit-input { position:relative; display:block; }
+.unit-input input { padding-right:40px; }
+.unit-input em { position:absolute; top:50%; right:13px; color:#8490a4; font-size:9px; font-style:normal; transform:translateY(-50%); }
+.pass-policy-hint { margin:0; padding:11px 13px; border-left:3px solid var(--jade); border-radius:7px; color:#627066; background:#edf6f2; font-size:8px; line-height:1.7; }
+.result-summary { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--line); background:#f5f7fb; }
+.result-summary > span { min-height:72px; display:flex; align-items:baseline; gap:7px; padding:15px 23px; }
+.result-summary > span + span { border-left:1px solid var(--line); }
+.result-summary small { margin-right:auto; color:#8992a4; font-size:8px; }
+.result-summary strong { color:var(--navy); font-family:Georgia,"STKaiti",serif; font-size:20px; font-weight:400; }
+.result-summary strong em,.result-summary > span > em { color:#8b94a5; font-size:8px; font-style:normal; }
+.result-summary.qualified { background:#eef8f3; }
+.result-summary.qualified > span:last-child strong { color:#237358; }
+.result-summary.unqualified { background:#fff5f2; }
+.result-summary.unqualified > span:last-child strong { color:#a24f43; }
.panel-title h2 { flex:0 0 auto; white-space:nowrap; }
.audit-feed > .panel-title { min-height:58px; display:flex; grid-template-columns:none; align-items:center; justify-content:space-between; gap:0; padding:0 19px; }
@keyframes fadeIn{from{opacity:0}}@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.98)}}
@media (max-width: 1120px) {
.hero-grid { grid-template-columns:1fr 430px; gap:40px; }.hero-copy h1 { font-size:52px; }.summary-grid,.admin-metrics { grid-template-columns:1fr 1fr; }.candidate-grid,.admin-dashboard-grid { grid-template-columns:1fr; }.score-grid { grid-template-columns:repeat(3,1fr); }.score-grid article:nth-child(3) { border-right:0; }.score-grid article:nth-child(n+4) { border-top:1px solid var(--line); }.results-admin-grid { grid-template-columns:1fr; }
+ .exam-subject-grid { grid-template-columns:1.3fr repeat(3,1fr); }.exam-subject-grid label:nth-child(n+5) { grid-column:auto; }
}
@media (max-width: 850px) {
.public-nav { width:calc(100% - 28px); }.public-nav nav { position:absolute; top:75px; left:14px; right:14px; display:none; flex-direction:column; gap:0; margin:0; padding:9px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow); }.public-nav nav.open { display:flex; }.public-nav nav a { padding:12px; }.mobile-menu { display:grid; }.nav-actions > .text-button { display:none; }.hero { min-height:auto; }.hero-grid { grid-template-columns:1fr; gap:55px; padding:60px 0 75px; }.hero-ticket { width:min(540px,100%); }.notice-layout,.public-exam-grid { grid-template-columns:1fr; }.flow-track { grid-template-columns:1fr 1fr; gap:30px; }.flow-track::before { display:none; }.auth-page { grid-template-columns:1fr; }.auth-story { min-height:360px; padding:35px 35px 40px; }.auth-story .overline { margin-top:55px; }.auth-story h1 { font-size:42px; }.auth-quote { display:none; }.portal-sidebar { transform:translateX(-100%); transition:.25s; box-shadow:18px 0 55px rgba(12,22,48,.2); }.portal-sidebar.open { transform:none; }.portal-brand > button { display:block; }.portal-main { margin-left:0; }.sidebar-toggle { display:grid; }.portal-topbar { padding:0 18px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { display:none; }.portal-content { padding:22px 18px; }.apply-card-main,.registration-info { grid-template-columns:1fr; }.apply-copy,.registration-info > dl { border-right:0; border-bottom:1px solid var(--line); }.admit-ticket { grid-template-columns:1fr 170px; }.admit-ticket::before,.admit-ticket::after { right:158px; }.admin-exam-grid { grid-template-columns:1fr; }
@@ -268,6 +313,12 @@ button:disabled { cursor: not-allowed; opacity: .5; }
@media (max-width: 620px) {
.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; }
}
+@media (max-width: 620px) {
+ .exam-score-band { grid-template-columns:1fr; }.exam-score-band > span + span { border-top:1px solid rgba(255,255,255,.12); border-left:0; }
+ .exam-form-section { padding:13px; }.exam-form-section > header { align-items:flex-start; }.exam-form-section > header aside { display:grid; gap:2px; }
+ .exam-subject-grid,.pass-policy-grid,.result-summary { grid-template-columns:1fr; }
+ .exam-subject-grid .subject-name-field { grid-column:auto; }.result-summary > span + span { border-top:1px solid var(--line); border-left:0; }
+}
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; transition-duration:.01ms !important; } }
/* Role scopes, numbering and workflow studio */
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index 5b7fc9d..d09f74e 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -305,7 +305,10 @@ try {
});
const editedDraft = await admin.request(`/api/admin/exams/${draftResponse.data.exam.id}`, {
method: 'PATCH',
- body: { name: '已完善考试草稿', location: '测试中心', status: 'draft', subjects: ['语文', '数学'] }
+ body: { name: '已完善考试草稿', location: '测试中心', status: 'draft', passPolicy: 'rank_percent', passValue: 30, subjects: [
+ { name: '语文', date: new Date(now + 120 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 20, fullScore: 120, passScore: 72 },
+ { name: '数学', date: new Date(now + 121 * hour).toISOString().slice(0, 10), start: '14:00', end: '16:00', fee: 20, fullScore: 180, passScore: 108 }
+ ] }
});
assert.equal(editedDraft.response.status, 200, '管理员应可继续编辑考试草稿');
assert.deepEqual(editedDraft.data.exam.subjects.map(subject => subject.name), ['语文', '数学'], '草稿编辑应保存科目配置');
@@ -313,6 +316,10 @@ try {
const persistedDraft = refreshedAdminExams.data.exams.find(item => item.id === draftResponse.data.exam.id);
assert.equal(persistedDraft.name, '已完善考试草稿', '草稿修改应持久化');
assert.equal(persistedDraft.subjects.length, 2, '草稿科目修改应持久化');
+ assert.equal(persistedDraft.totalScore, 300, '考试总分应由结构化科目满分自动汇总');
+ assert.equal(persistedDraft.passPolicy, 'rank_percent', '考试应持久化排名百分比合格策略');
+ assert.equal(persistedDraft.passValue, 30, '合格策略数值应持久化');
+ assert.equal(persistedDraft.subjects[1].passScore, 108, '每科应独立保存满分与单科合格分');
const beforeApproval = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id] } });
assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试');
@@ -401,8 +408,14 @@ try {
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } });
assert.equal(publishResult.response.status, 200);
+ assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
+ assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
const results = await candidate.request('/api/candidate/results');
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
+ const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
+ assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
+ assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
+ assert.equal(resultSummary.qualified, true, '全部科目发布后应按得分率策略自动判定合格');
const classResults = await classAdmin.request('/api/admin/results');
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');