From ca675d322608aa50de2450259cf59687d4b2f336 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 19:01:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9F=A5=E8=AF=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 19 ++++++++ server.mjs | 96 +++++++++++++++++++++++++++++-------- src/client/admin-views.mjs | 82 +++++++++++++++++++++---------- src/client/state.mjs | 1 + src/routes/admin.routes.mjs | 71 +++++++++++++++++++-------- styles.css | 40 ++++++++++++++++ 6 files changed, 243 insertions(+), 66 deletions(-) diff --git a/app.js b/app.js index d897a2c..862d4c6 100644 --- a/app.js +++ b/app.js @@ -620,6 +620,15 @@ document.addEventListener('click', async event => { if (action === 'result-exam-filter') { state.resultExamFilter = target.dataset.id; state.resultSubjectFilter = ''; + ['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => { + if (state.tablePages[key]) state.tablePages[key].page = 1; + }); + return renderRoute(); + } + if (action === 'table-page') { + const key = target.dataset.tableKey; + const current = state.tablePages[key] || { page: 1, pageSize: 50 }; + state.tablePages[key] = { ...current, page: Math.max(1, Number(target.dataset.page) || 1) }; return renderRoute(); } if (action === 'cancel-result-import') { @@ -699,6 +708,12 @@ document.addEventListener('input', event => { }); document.addEventListener('change', event => { + if (event.target.matches('[data-action="table-page-size"]')) { + const key = event.target.dataset.tableKey; + state.tablePages[key] = { page: 1, pageSize: Number(event.target.value) || 50 }; + renderRoute(); + return; + } if (event.target.matches('[data-placement-select]')) updatePlacementSelection(); if (event.target.matches('[data-placement-select-all]')) { const table = document.getElementById(event.target.dataset.target); @@ -710,10 +725,14 @@ document.addEventListener('change', event => { if (event.target.matches('[data-action="result-bulk-exam"]')) { state.resultExamFilter = event.target.value; state.resultSubjectFilter = ''; + ['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => { + if (state.tablePages[key]) state.tablePages[key].page = 1; + }); renderRoute(); } if (event.target.matches('[data-action="result-bulk-subject"]')) { state.resultSubjectFilter = event.target.value; + if (state.tablePages.resultEntryTable) state.tablePages.resultEntryTable.page = 1; renderRoute(); } if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger')); diff --git a/server.mjs b/server.mjs index 9b36e69..cb01874 100644 --- a/server.mjs +++ b/server.mjs @@ -357,12 +357,46 @@ function gradeForRank(rank, cohortSize) { return 'D'; } +const resultIndexCache = new WeakMap(); +const examSummaryCache = new WeakMap(); + +function resultIndexes(db) { + let indexes = resultIndexCache.get(db); + if (indexes) return indexes; + const publishedScoresBySubject = new Map(); + const publishedByRegistration = new Map(); + for (const result of db.results) { + if (!result.published) continue; + if (!publishedScoresBySubject.has(result.subjectId)) publishedScoresBySubject.set(result.subjectId, []); + publishedScoresBySubject.get(result.subjectId).push(Number(result.score)); + if (!publishedByRegistration.has(result.registrationId)) publishedByRegistration.set(result.registrationId, []); + publishedByRegistration.get(result.registrationId).push(result); + } + for (const scores of publishedScoresBySubject.values()) scores.sort((left, right) => left - right); + indexes = { publishedScoresBySubject, publishedByRegistration }; + resultIndexCache.set(db, indexes); + return indexes; +} + +function countScoresGreaterThan(sortedScores, score) { + let low = 0; + let high = sortedScores.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (sortedScores[middle] <= score) low = middle + 1; + else high = middle; + } + return sortedScores.length - low; +} + function resultRankInfo(db, result, scoreOverride = result?.score) { if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' }; const score = Number(scoreOverride); - const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published); - const cohortSize = peers.length + 1; - const rank = 1 + peers.filter(item => Number(item.score) > score).length; + const scores = resultIndexes(db).publishedScoresBySubject.get(result.subjectId) || []; + const cohortSize = scores.length + (result.published ? 0 : 1); + let greater = countScoresGreaterThan(scores, score); + if (result.published && Number(result.score) > score) greater -= 1; + const rank = 1 + Math.max(0, greater); const rankPercent = Number((rank / cohortSize * 100).toFixed(2)); return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) }; } @@ -379,15 +413,43 @@ function subjectPassEvaluation(db, result, subject, scoreOverride = result?.scor return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null }; } +function examSummaryIndexes(db) { + let indexes = examSummaryCache.get(db); + if (indexes) return indexes; + const examById = new Map(db.exams.map(exam => [exam.id, exam])); + const publishedByRegistration = resultIndexes(db).publishedByRegistration; + const baseByRegistration = new Map(); + const totalsByCohort = new Map(); + for (const registration of db.registrations) { + if (registration.status !== 'approved') continue; + const exam = examById.get(registration.examId); + if (!exam) continue; + const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); + const published = publishedByRegistration.get(registration.id) || []; + 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 subjectKey = [...registration.subjectIds].sort().join('|'); + const cohortKey = `${exam.id}\u0000${subjectKey}`; + const base = { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey }; + baseByRegistration.set(registration.id, base); + if (complete) { + if (!totalsByCohort.has(cohortKey)) totalsByCohort.set(cohortKey, []); + totalsByCohort.get(cohortKey).push(total); + } + } + for (const totals of totalsByCohort.values()) totals.sort((left, right) => left - right); + indexes = { baseByRegistration, totalsByCohort }; + examSummaryCache.set(db, indexes); + return indexes; +} + 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 summaryIndexes = examSummaryIndexes(db); + const base = summaryIndexes.baseByRegistration.get(registration.id); + if (!base) return null; + const { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey } = base; const scoreRatio = fullScore ? total / fullScore * 100 : 0; const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'); const value = Number(exam.passValue ?? 60); @@ -402,17 +464,9 @@ function examResultSummary(db, registration) { }); 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); + const totals = summaryIndexes.totalsByCohort.get(cohortKey) || []; cohortSize = totals.length; - rank = 1 + totals.filter(item => item > total).length; + rank = 1 + countScoresGreaterThan(totals, total); qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100)); } diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 0cd9dbb..1393899 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -56,7 +56,9 @@ export function createAdminViews(context) { app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); try { const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page; - const data = page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}`); + const query = page === 'results' ? `?examId=${encodeURIComponent(state.resultExamFilter || 'current')}` : ''; + const data = page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`); + if (page === 'results' && data.selectedExamId) state.resultExamFilter = data.selectedExamId; state.pageData = data; const content = { dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data), @@ -106,10 +108,12 @@ export function createAdminViews(context) { } function adminCandidates(data) { - const candidates = data.candidates || []; + const allCandidates = data.candidates || []; + const candidatePage = paged(allCandidates, 'candidateTable'); + const candidates = candidatePage.items; const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('candidates.review'); const classById = new Map((data.classes || []).map(item => [item.id, item])); - const optionList = getter => [...new Set(candidates.map(getter).filter(Boolean))] + const optionList = getter => [...new Set(allCandidates.map(getter).filter(Boolean))] .sort((left, right) => left.localeCompare(right, 'zh-CN')) .map(value => ``).join(''); const archiveConsole = state.user.adminLevel === 'school' ? (() => { @@ -128,11 +132,29 @@ export function createAdminViews(context) { return `
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h([grade, className].filter(Boolean).join(' · '))}
${exams.length ? `${h(examNames.slice(0, 2).join('、'))}${exams.length > 2 ? ` 等 ${exams.length} 场` : ''}${h(exams.flatMap(registration => registration.subjects || []).map(subject => subject.name).slice(0, 5).join('、') || '尚未选择科目')}` : '暂无考试报名'}
${item.accountArchived ? '已归档' : item.mustChangePassword ? '待首次改密' : item.profileCompleted ? '正常' : '待补全资料'}${item.accountArchived ? `${formatDate(item.archivedAt, true)} · ${h(item.archivedByName || '校方')}` : h(item.workflow?.assignee?.displayName || '')}${item.profileCompleted ? badge(item.status) : '未完成'}
${state.user.adminLevel === 'super' ? `` : ''}
`; }).join(''); const bulkToolbar = canReview ? `
已选择 0 名当前可处理考生
` : ''; - const examOptions = [...new Set(candidates.flatMap(item => (item.registrations || []).map(registration => registration.exam?.name).filter(Boolean)))] + const examOptions = [...new Set(allCandidates.flatMap(item => (item.registrations || []).map(registration => registration.exam?.name).filter(Boolean)))] .sort((left, right) => left.localeCompare(right, 'zh-CN')) .map(value => ``).join(''); const filters = `
`; - return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}
${bulkToolbar}
${filters}
${rows || ''}
报名号 / 考生证件号码学校 / 年级 / 班级关联考试 / 科目账户状态资料状态操作
当前范围暂无考生
`; + return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}
${bulkToolbar}
${filters}
${rows || ''}
报名号 / 考生证件号码学校 / 年级 / 班级关联考试 / 科目账户状态资料状态操作
当前范围暂无考生
${pagination(candidatePage)}
`; + } + + function paged(items, key, defaultPageSize = 50) { + const current = state.tablePages[key] || {}; + const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize; + const total = items.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages); + state.tablePages[key] = { page, pageSize }; + return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key }; + } + + function pagination(meta) { + if (!meta || meta.total <= meta.pageSize) return ''; + const start = (meta.page - 1) * meta.pageSize + 1; + const end = Math.min(meta.total, meta.page * meta.pageSize); + const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages); + return ``; } function adminRegistrations(registrations) { @@ -147,26 +169,32 @@ export function createAdminViews(context) { return `${selectable ? `` : ''}
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h([reg.schoolName, reg.gradeName, reg.className].filter(Boolean).join(' · '))}
${h(reg.exam.name)}
${reg.subjects.map(subject => `${h(subject.name)}`).join('') || '未选择科目'}
${h(reg.subjects.length)} 科 · ${money(reg.amountDue || 0)}
${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.exam.archivedAt ? 'archived' : reg.status)}${reg.exam.archivedAt ? '只读封存' : ``}`; }).join(''); const table = (items, id, selectable = false) => `
${selectable ? '' : ''}${rows(items, selectable) || ``}
考生考试 / 科目账户报名号当前流程缴费状态操作
当前范围暂无报名记录
`; - const current = registrations.filter(reg => !reg.exam.archivedAt); - const archived = registrations.filter(reg => reg.exam.archivedAt); + const allCurrent = registrations.filter(reg => !reg.exam.archivedAt); + const allArchived = registrations.filter(reg => reg.exam.archivedAt); + const currentPage = paged(allCurrent, 'registrationTable'); + const archivedPage = paged(allArchived, 'archivedRegistrationTable'); + const current = currentPage.items; + const archived = archivedPage.items; const subjectOptions = [...new Set(registrations.flatMap(item => item.subjects.map(subject => subject.name)))].sort((left, right) => left.localeCompare(right, 'zh-CN')).map(value => ``).join(''); const bulkToolbar = canReview ? `
已选择 0 条当前可处理报名
` : ''; - return `
${bulkToolbar}
${table(current, 'registrationTable', Boolean(canReview))}
${archived.length ? `
归档考试报名记录${archived.length} 条 · 流程与报名信息已冻结${archived.length}${table(archived, 'archivedRegistrationTable')}
` : ''}`; + return `
${bulkToolbar}
${table(current, 'registrationTable', Boolean(canReview))}${pagination(currentPage)}
${allArchived.length ? `
归档考试报名记录${allArchived.length} 条 · 流程与报名信息已冻结${allArchived.length}${table(archived, 'archivedRegistrationTable')}${pagination(archivedPage)}
` : ''}`; } function adminPayments(data) { - const registrations = data.registrations || []; - const paid = registrations.filter(item => item.paymentStatus === 'paid'); - const unpaid = registrations.filter(item => item.paymentStatus === 'unpaid'); - const totalDue = registrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); + const allRegistrations = data.registrations || []; + const paymentPage = paged(allRegistrations, 'paymentTable'); + const registrations = paymentPage.items; + const paid = allRegistrations.filter(item => item.paymentStatus === 'paid'); + const unpaid = allRegistrations.filter(item => item.paymentStatus === 'unpaid'); + const totalDue = allRegistrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); const totalPaid = paid.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); - const optionList = (getter) => [...new Set(registrations.map(getter).filter(Boolean))] + const optionList = (getter) => [...new Set(allRegistrations.map(getter).filter(Boolean))] .sort((left, right) => left.localeCompare(right, 'zh-CN')) .map(value => ``).join(''); const rows = registrations.map(item => `${data.canUpdatePayment ? `` : ''}
${h((item.candidate?.name || '?').slice(0, 1))}
${h(item.candidate?.name || '未知考生')}${h(item.registrationNumber || '')}
${h(item.schoolName)}${h([item.gradeName, item.className].filter(Boolean).join(' · '))}${h(item.exam?.name || '')}
${item.subjects.map(subject => `${h(subject.name)}`).join('')}
${money(item.amountDue || 0)}${badge(item.paymentStatus)}${item.paidAt ? `${formatDate(item.paidAt, true)}${h(item.paidByName || '管理员')}` : ''}${data.canUpdatePayment && !item.exam?.archivedAt ? `` : item.exam?.archivedAt ? '只读封存' : ''}`).join(''); const filters = `
`; const bulkToolbar = data.canUpdatePayment ? `
已选择 0 条缴费记录
` : ''; - return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${registrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${bulkToolbar}
${filters}
${data.canUpdatePayment ? '' : ''}${rows || ``}
考生 / 报名号学校 / 年级 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
`; + return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${allRegistrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${bulkToolbar}
${filters}
${data.canUpdatePayment ? '' : ''}${rows || ``}
考生 / 报名号学校 / 年级 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
${pagination(paymentPage)}
`; } function adminExams(exams) { @@ -205,11 +233,12 @@ export function createAdminViews(context) { return `
SOURCE SCHOOL CERTIFICATION

${h(data.school?.name)}资格确认簿

先按姓名、报名号、确认状态或特长类型筛选,再逐人确认或多选批量设置。每场考试全部确认后立即自动公示。

${data.exams.map((item, index) => { const status = item.qualificationStatus; const tableId = `qualificationTable-${index}`; + const qualificationPage = paged(status.rows, tableId); const specialties = [...new Set(status.rows.map(row => row.specialtyLabel).filter(Boolean))].sort((left, right) => left.localeCompare(right, 'zh-CN')); const filters = `
`; const bulk = `
已选 0 人
`; - const rows = status.rows.map(row => `${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}`).join(''); - return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}${filters}${bulk}
${rows || ''}
选择报名号 / 姓名特长类型指标分配资格确认时间保存
本校暂无资料已完善的在册考生
`; + const rows = qualificationPage.items.map(row => `${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}`).join(''); + return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}${filters}${bulk}
${rows || ''}
选择报名号 / 姓名特长类型指标分配资格确认时间保存
本校暂无资料已完善的在册考生
${pagination(qualificationPage)}
`; }).join('')}`; } @@ -221,6 +250,7 @@ export function createAdminViews(context) { function adminAdmit(data) { const approved = data.registrations || []; + const admitPage = paged(approved, 'admitTable'); const selectedExam = data.exams.find(exam => !exam.archivedAt && exam.approvedCount > 0) || data.exams.find(exam => !exam.archivedAt) || data.exams[0]; const selectedPlan = selectedExam?.plan; const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code; @@ -229,7 +259,7 @@ export function createAdminViews(context) { const planCards = data.canArrange && data.plans.length ? `
${data.plans.map(plan => `
${h(scopeName(plan.mixingScope))}

${h(plan.examName)}

${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合

${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%

${plan.warnings.length ? `
    ${plan.warnings.map(item => `
  • ${h(item)}
  • `).join('')}
` : '

容量、档案与时间冲突检查通过

'}
`).join('')}
` : ''; const exportExam = data.exams.find(exam => exam.arrangedCount > 0) || data.exams[0]; const exportPanel = exportExam ? `

${data.canArrange ? '准考证与考务材料' : h(data.scopeLabel)}

批量准考证为 A4 横向打印文件;“准考证信息”是逐考生、逐科目的 Excel 台账。

${approved.filter(item => item.admitCard).length} 份范围内准考证
${data.canExportCenterMaterials ? '' : ''}
` : emptyState('当前范围还没有考试报名', '待报名审核通过并完成整场编排后,可在这里批量下载。'); - const rows = approved.map(reg => { + const rows = admitPage.items.map(reg => { const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item])); const subjects = reg.subjects.map(subject => { const assignment = assignments.get(subject.id); @@ -237,7 +267,7 @@ export function createAdminViews(context) { }).join(''); return `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${h(reg.exam.code)}${h(reg.admitCard?.number || '待编排')}${h(reg.admitCard?.testCenter || '—')}${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')}
${subjects}
${reg.admitCard ? `` : '—'}`; }).join(''); - return `${form}${ruleCards}${planCards}${exportPanel}

考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。

${rows || ''}
考生考试准考证号固定考点 / 地址分科考试考场 / 物理场地 / 座位操作
暂无已通过的考试报名
`; + return `${form}${ruleCards}${planCards}${exportPanel}

考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。

${rows || ''}
考生考试准考证号固定考点 / 地址分科考试考场 / 物理场地 / 座位操作
暂无已通过的考试报名
${pagination(admitPage)}
`; } function adminResults(data) { @@ -247,21 +277,24 @@ export function createAdminViews(context) { const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0]; if (activeSubject) state.resultSubjectFilter = activeSubject.id; const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results; + const resultPage = paged(examResults, 'resultTable'); const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []); const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; const subjectRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id && item.subjectIds.includes(activeSubject?.id)); + const resultEntryPage = paged(subjectRegistrations, 'resultEntryTable'); const subjectResults = new Map(examResults.filter(item => item.subjectId === activeSubject?.id).map(item => [item.registrationId, item])); const recordedCount = subjectRegistrations.filter(item => subjectResults.has(item.id)).length; const publishedCount = subjectRegistrations.filter(item => subjectResults.get(item.id)?.published).length; - const entryRows = subjectRegistrations.map((registration, index) => { + const entryRows = resultEntryPage.items.map((registration, index) => { const result = subjectResults.get(registration.id); const status = result?.published ? 'published' : result ? 'draft' : 'missing'; return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${result ? badge(result.published ? 'published' : 'draft') : '未录入'}${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}`; }).join(''); const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
SCORE ENTRY ROSTER

按名单录入单科成绩

选定考试和科目后,直接在完整考生名单中录分;暂存不会向考生发布。

应录人数${subjectRegistrations.length}已录入${recordedCount}已暂存${Math.max(0, recordedCount - publishedCount)}已发布${publishedCount}

${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}

${entryRows || ''}
序号考生报名号准考证号成绩(0—${h(activeSubject?.fullScore || '—')})状态最近保存
该科目暂无已通过报名的考生
尚无未保存修改按 Ctrl / ⌘ + S 可快速暂存;发布前必须补齐本科学目全部考生成绩。
` : ''; const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id); + const featurePage = paged(featureRegistrations, 'featureScoreTable'); const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length; - const featureRows = featureRegistrations.map((registration, index) => { + const featureRows = featurePage.items.map((registration, index) => { const featureScore = Number(registration.featureScore || 0); const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`; return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${h(registration.specialtyLabel || '普通生')}${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}`; @@ -278,9 +311,9 @@ export function createAdminViews(context) { : ''; const toolbar = `
${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '名单模板已预填本场全部考生;导入后先预览,确认才写入数据库'}
${cacheButton}${activeExam?.archivedAt ? '' : ''}${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '' : ''}
`; const metrics = `
报名考生${activeExam?.registrationCount ?? 0}本场已通过报名
录入进度${activeExam?.scored ?? 0} / ${activeExam?.enrolledSubjects ?? 0}剩余 ${activeExam?.missing ?? 0} 科次
已发布${activeExam?.published ?? 0}草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条
成绩已出齐${activeExam?.complete ?? 0}
整场合格率${passRate == null ? '—' : `${passRate}%`}按本场排名或所设规则判定
成绩复议${examAppeals.length}当前考试累计
`; - const ledger = `
${examResults.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
`; + const ledger = `
${resultPage.items.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
${pagination(resultPage)}
`; const archiveLock = activeExam?.archivedAt ? `
${icons.check}
本场考试已归档${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。
` : ''; - return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${featureEntry}${ledger}${appealLedger}`; + return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${pagination(resultEntryPage)}${featureEntry}${pagination(featurePage)}${ledger}${appealLedger}`; } function adminUsers(data) { @@ -328,15 +361,16 @@ export function createAdminViews(context) { const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' }; const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领', score_appeal: '考生成绩复议' }; const visibleTypes = [...new Set(data.instances.map(instance => instance.businessType))]; + const workflowPage = paged(data.instances, 'workflowBoard', 20); const toolbar = `
`; - return `${toolbar}
${data.instances.map(instance => { + return `${toolbar}
${workflowPage.items.map(instance => { const isCenter = instance.businessType === 'center_change'; const isBatch = instance.businessType === 'candidate_account_batch'; const isAppeal = instance.businessType === 'score_appeal'; const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName; const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
`; - }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
`; + }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
${pagination(workflowPage)}`; } return { renderAdmin, workflowStepEditor }; diff --git a/src/client/state.mjs b/src/client/state.mjs index 926ff52..0da73ac 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -12,5 +12,6 @@ export const state = { resultExamFilter: '', resultSubjectFilter: '', resultImportPreview: null, + tablePages: {}, loading: false }; diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index dbb83e6..e9c6f19 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1442,53 +1442,82 @@ export function createAdminRoutes(context) { if (request.method === 'GET' && pathname === '/api/admin/results') { if (!requirePermission(user, response, 'results.read')) return true; const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); - const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => { - const registration = db.registrations.find(item => item.id === result.registrationId); - const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); - const account = db.users.find(item => item.id === registration?.userId); - const exam = db.exams.find(item => item.id === registration?.examId); + const approved = scopedRegistrations.filter(item => item.status === 'approved'); + const registrationById = new Map(scopedRegistrations.map(item => [item.id, item])); + const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item])); + const accountById = new Map(db.users.map(item => [item.id, item])); + const examById = new Map(db.exams.map(item => [item.id, item])); + const classById = new Map(db.classes.map(item => [item.id, item])); + const resultCountByExam = new Map(); + const publishedCountByExam = new Map(); + const rawScopedResults = db.results.filter(result => registrationById.has(result.registrationId)); + for (const result of rawScopedResults) { + const examId = registrationById.get(result.registrationId)?.examId; + resultCountByExam.set(examId, (resultCountByExam.get(examId) || 0) + 1); + if (result.published) publishedCountByExam.set(examId, (publishedCountByExam.get(examId) || 0) + 1); + } + const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId'); + const visibleExamIds = new Set(approved.map(item => item.examId)); + const fallbackExam = db.exams.find(item => !item.archivedAt && visibleExamIds.has(item.id)) + || db.exams.find(item => visibleExamIds.has(item.id)) + || (user.adminLevel === 'super' ? db.exams.find(item => !item.archivedAt) || db.exams[0] : null); + const selectedExamId = db.exams.some(item => item.id === requestedExamId && (visibleExamIds.has(item.id) || user.adminLevel === 'super')) + ? requestedExamId + : fallbackExam?.id || ''; + const restrictToSelectedExam = Boolean(requestedExamId); + const selectedRegistrationIds = new Set(scopedRegistrations.filter(item => !restrictToSelectedExam || item.examId === selectedExamId).map(item => item.id)); + const results = rawScopedResults.filter(result => selectedRegistrationIds.has(result.registrationId)).map(result => { + const registration = registrationById.get(result.registrationId); + const profile = profileByUserId.get(registration?.userId); + const account = accountById.get(registration?.userId); + const exam = examById.get(registration?.examId); const subject = exam?.subjects.find(item => item.id === result.subjectId); const pass = subjectPassEvaluation(db, result, subject); - const rank = resultRankInfo(db, result); return { - ...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, + ...result, grade: result.published ? pass.grade : result.grade, rank: pass.rank, cohortSize: pass.cohortSize, rankPercent: pass.rankPercent, candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '', - schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '', + schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '', examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name, fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore, passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified }; }); - const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => { - const result = results.find(item => item.id === instance.businessId); + const resultById = new Map(results.map(result => [result.id, result])); + const resultExamIdById = new Map(rawScopedResults.map(result => [result.id, registrationById.get(result.registrationId)?.examId])); + const appealCountByExam = new Map(); + for (const instance of db.workflowInstances) { + if (instance.businessType !== 'score_appeal' || !resultExamIdById.has(instance.businessId)) continue; + const examId = resultExamIdById.get(instance.businessId); + appealCountByExam.set(examId, (appealCountByExam.get(examId) || 0) + 1); + } + const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && resultById.has(instance.businessId)).map(instance => { + const result = resultById.get(instance.businessId); const workflow = workflowView(db, instance); return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' }; }); - const approved = scopedRegistrations.filter(item => item.status === 'approved'); const exams = db.exams.map(exam => { const examRegistrations = approved.filter(item => item.examId === exam.id); - const examResults = results.filter(item => item.examId === exam.id); const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean); const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0); - const scored = examResults.length; + const scored = resultCountByExam.get(exam.id) || 0; return { ...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored, - published: examResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored), + published: publishedCountByExam.get(exam.id) || 0, missing: Math.max(0, enrolledSubjects - scored), complete: summaries.filter(item => item.complete).length, qualified: summaries.filter(item => item.complete && item.qualified === true).length, unqualified: summaries.filter(item => item.complete && item.qualified === false).length, - appeals: appeals.filter(item => item.result?.examId === exam.id).length + appeals: appealCountByExam.get(exam.id) || 0 }; - }).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super'); - const registrations = user.adminLevel === 'super' ? approved.map(item => { + }).filter(exam => exam.registrationCount || (resultCountByExam.get(exam.id) || 0) > 0 || user.adminLevel === 'super'); + const registrations = user.adminLevel === 'super' ? approved.filter(item => !restrictToSelectedExam || item.examId === selectedExamId).map(item => { const view = examRegistrationView(db, item); - const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId); - const account = db.users.find(accountItem => accountItem.id === item.userId); + const profile = profileByUserId.get(item.userId); + const account = accountById.get(item.userId); const specialty = resolveProfileSpecialty(profile || {}); - return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' }; + return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' }; }) : []; - return sendJson(response, 200, { ok: true, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } }); + return sendJson(response, 200, { ok: true, selectedExamId, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } }); } if (request.method === 'POST' && pathname === '/api/admin/results/import') { if (!requirePermission(user, response, '*')) return true; diff --git a/styles.css b/styles.css index e9fbb0e..f43ed2a 100644 --- a/styles.css +++ b/styles.css @@ -840,3 +840,43 @@ body.review-subpage-open { overflow:hidden; } @media (max-width:800px) { .admission-account-management > form { grid-template-columns:1fr; }.placement-bulk-toolbar,.placement-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.placement-bulk-toolbar button { width:100%; }.placement-status-pills,.account-status-pills { margin-right:18px; margin-left:18px; } } +.table-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-top: 1px solid var(--line, #e5e7eb); + color: var(--muted, #64748b); + font-size: 13px; +} + +.table-pagination > div { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.table-pagination button, +.table-pagination select { + min-width: 34px; + height: 34px; + padding: 0 10px; + border: 1px solid var(--line, #dbe2ea); + border-radius: 8px; + background: #fff; + color: inherit; + font: inherit; +} + +.table-pagination button:not(:disabled) { cursor: pointer; } +.table-pagination button:hover:not(:disabled) { border-color: #1f6f5f; color: #1f6f5f; } +.table-pagination button.active { border-color: #1f6f5f; background: #1f6f5f; color: #fff; } +.table-pagination button:disabled { opacity: .45; } +.table-pagination label { display: inline-flex; align-items: center; gap: 6px; margin-left: 6px; } +.table-pagination i { font-style: normal; padding: 0 2px; } + +@media (max-width: 720px) { + .table-pagination { align-items: flex-start; flex-direction: column; } +}