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 => ``).join('')}`; + select.innerHTML = `${subjects.map(subject => ``).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(``); + const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }]; + const passPolicy = exam?.passPolicy || 'score_ratio'; + setModal(``); + 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.code)}${badge(exam.registrationState)}
${exam.registrationCount || 0} 人已报名

${h(exam.name)}

${h(exam.description)}

报名期限
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点安排
${h(exam.location)}
选择报考科目可多选
${exam.subjects.map(subject => ``).join('') || '

科目安排尚未发布

'}
${exam.registration ? `
${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目${badge(exam.registration.status)}
` : `
已选 0合计 ¥0.00
`}
`).join('')}
`; + return `
${data.exams.map(exam => `
${h(exam.code)}${badge(exam.registrationState)}
${exam.registrationCount || 0} 人已报名

${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 => ``).join('') || '

科目安排尚未发布

'}
${exam.registration ? `
${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目${badge(exam.registration.status)}
` : `
已选 0满分 0 · ¥0.00
`}
`).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
下载后请使用 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]) => `
${h(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
${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(items[0].examCode)}

${h(examName)}

发布时间 ${formatDate(items[0].publishedAt, true)}
当前总分${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)}
`; + 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))}
`; } 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, '班级管理员不得录入或发布成绩');