diff --git a/app.js b/app.js index 862d4c6..8223db4 100644 --- a/app.js +++ b/app.js @@ -75,6 +75,9 @@ function requireLogin() { state.permissions = []; state.scopeLabel = ''; state.pageData = null; + state.resultExamFilter = ''; + state.resultSubjectFilter = ''; + state.resultExamCatalog = null; state.authNotice = '登录状态已失效,请重新登录。'; navigate('login'); } @@ -300,7 +303,7 @@ document.addEventListener('click', async event => { } if (action === 'logout') { await api('/api/auth/logout', { method: 'POST' }); - state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; + state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; state.resultExamFilter = ''; state.resultSubjectFilter = ''; state.resultExamCatalog = null; await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return; } if (action === 'open-notice') { diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index 1393899..ee104ff 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -56,9 +56,12 @@ 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 query = page === 'results' ? `?examId=${encodeURIComponent(state.resultExamFilter || 'current')}` : ''; - const data = page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`); + const query = page === 'results' && state.resultExamFilter ? `?examId=${encodeURIComponent(state.resultExamFilter)}` : ''; + const data = page === 'results' && !state.resultExamFilter + ? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } } + : page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`); if (page === 'results' && data.selectedExamId) state.resultExamFilter = data.selectedExamId; + if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams; state.pageData = data; const content = { dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data), @@ -74,7 +77,8 @@ export function createAdminViews(context) { function mountPreferenceLedger(data) { const container = app.querySelector('.portal-content'); if (!container || !data.preferences?.length) return; - container.insertAdjacentHTML('beforeend', `

考生志愿只读台账

仅超级管理员可查看;系统不提供任何管理员修改入口。

${data.preferences.length} 份
${data.preferences.map(item => ``).join('')}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('
')}
${formatDate(item.payload.submittedAt, true)}
`); + const preferencePage = paged(data.preferences, 'adminPreferenceTable'); + container.insertAdjacentHTML('beforeend', `

考生志愿只读台账

仅超级管理员可查看;系统不提供任何管理员修改入口。

${data.preferences.length} 份
${preferencePage.items.map(item => ``).join('')}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('
')}
${formatDate(item.payload.submittedAt, true)}
${pagination(preferencePage)}
`); } function adminDashboard(data) { @@ -219,12 +223,15 @@ export function createAdminViews(context) { const pendingPlans = data.plans.filter(item => item.status === 'pending'); const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }; + const accountPage = paged(data.schoolAccounts, 'admissionAccountTable', 20); + const planPage = paged(data.plans, 'adminAdmissionPlanTable', 20); + const placementPage = paged(data.placements, 'adminPlacementTable'); const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
` : ''}
`; - const accountRows = data.schoolAccounts.map(account => `
${h((account.displayName || '招').slice(0, 1))}
${h(account.displayName)}${h(account.id)}
${h(account.username)}${h(account.schoolName || '未绑定')}${h(account.schoolCode || '')}${account.active ? '已启用' : '已停用'}${formatDate(account.createdAt, true)}
`).join(''); - const accounts = `

招生学校账户管理

创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。

${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个
已建账户保留历史记录,不提供物理删除
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
`; + const accountRows = accountPage.items.map(account => `
${h((account.displayName || '招').slice(0, 1))}
${h(account.displayName)}${h(account.id)}
${h(account.username)}${h(account.schoolName || '未绑定')}${h(account.schoolCode || '')}${account.active ? '已启用' : '已停用'}${formatDate(account.createdAt, true)}
`).join(''); + const accounts = `

招生学校账户管理

创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。

${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个
已建账户保留历史记录,不提供物理删除
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
${pagination(accountPage)}
`; const planForm = `

代招生校上传计划

每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。

${admissionCategoriesEditor(h, data.sourceSchools)}
`; - const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
`; - const placements = `

投档与退档监督

志愿只读,管理员均无修改入口
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; + const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; + const placements = `

投档与退档监督

志愿只读,管理员均无修改入口
${placementPage.items.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
${pagination(placementPage)}
`; return `
ADMISSION COMMAND

中考招生录取控制台

学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
${settings}
${accounts}${planForm}${plans}${placements}`; } @@ -272,8 +279,13 @@ export function createAdminViews(context) { function adminResults(data) { const preview = state.resultImportPreview; - const activeExam = data.exams.find(item => item.id === state.resultExamFilter) || data.exams.find(item => !item.archivedAt && item.registrationCount) || data.exams.find(item => !item.archivedAt) || data.exams[0]; - if (activeExam) state.resultExamFilter = activeExam.id; + const activeExam = state.resultExamFilter ? data.exams.find(item => item.id === state.resultExamFilter) : null; + if (!activeExam) { + const examButton = exam => ``; + const currentExams = data.exams.filter(exam => !exam.archivedAt); + const archivedExams = data.exams.filter(exam => exam.archivedAt); + return `
${currentExams.map(examButton).join('')}
${archivedExams.length ? `
历史归档考试 ${archivedExams.length} 场 · 选择后读取
${archivedExams.map(examButton).join('')}
` : ''}${emptyState('请先选择考试', '未选择考试时不会读取成绩、考生录分名单或成绩复议数据。')}`; + } 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; diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs index e964199..323b00d 100644 --- a/src/client/admission-views.mjs +++ b/src/client/admission-views.mjs @@ -23,8 +23,27 @@ export function createAdmissionViews(context) { return `
ADMISSION OFFICE

${h(data.school.name)}

学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。

本校工作入口

${data.exams.length} 场启用志愿
`; } + 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 plans(data) { - return `

提交本校招生计划

按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。

${admissionCategoriesEditor(h, data.sourceSchools)}
${data.plans.map(plan => ``).join('') || ''}
考试类别计划指标分配状态审核意见
${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${h(plan.payload.reviewNote || '等待审核')}
尚未提交计划
`; + const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20); + return `

提交本校招生计划

按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。

${admissionCategoriesEditor(h, data.sourceSchools)}
${planPage.items.map(plan => ``).join('') || ''}
考试类别计划指标分配状态审核意见
${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${h(plan.payload.reviewNote || '等待审核')}
尚未提交计划
${pagination(planPage)}
`; } function placements(data) { @@ -32,8 +51,9 @@ export function createAdmissionViews(context) { const exams = [...new Map(data.placements.map(item => [item.examId, item.examName])).entries()]; const categories = [...new Set(data.placements.map(item => item.payload.categoryName).filter(Boolean))]; const pendingCount = data.placements.filter(item => item.status === 'school_review').length; - const rows = data.placements.map(item => `${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${h(item.examName)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.candidate.specialtyCertificate || '')}${h(item.candidate.policyEligibility || '')}${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('
')}投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}${h(item.payload.categoryName)}第 ${h(item.payload.preferenceOrder)} 志愿${badge(item.status)}${item.status === 'school_review' ? `
` : `${h(item.payload.schoolDecisionNote || '已处理')}`}`).join(''); - return `${exportBar}

本校投档审核台账

可搜索、筛选和多选批量处理;仅待审核记录可被选中。

${pendingCount} 人待审 / 共 ${data.placements.length} 人
已选 0 人
${rows || ''}
选择考生 / 考试资格当次成绩投档类别状态单人审核
暂无投档考生
`; + const placementPage = paged(data.placements, 'placementReviewTable'); + const rows = placementPage.items.map(item => `${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${h(item.examName)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.candidate.specialtyCertificate || '')}${h(item.candidate.policyEligibility || '')}${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('
')}投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}${h(item.payload.categoryName)}第 ${h(item.payload.preferenceOrder)} 志愿${badge(item.status)}${item.status === 'school_review' ? `
` : `${h(item.payload.schoolDecisionNote || '已处理')}`}`).join(''); + return `${exportBar}

本校投档审核台账

可搜索、筛选和多选批量处理;仅待审核记录可被选中。

${pendingCount} 人待审 / 共 ${data.placements.length} 人
已选 0 人
${rows || ''}
选择考生 / 考试资格当次成绩投档类别状态单人审核
暂无投档考生
${pagination(placementPage)}
`; } return { renderAdmission }; } diff --git a/src/client/state.mjs b/src/client/state.mjs index 0da73ac..3fb33f3 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -11,6 +11,7 @@ export const state = { pageData: null, resultExamFilter: '', resultSubjectFilter: '', + resultExamCatalog: null, resultImportPreview: null, tablePages: {}, loading: false diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index e9c6f19..194707d 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -174,7 +174,14 @@ export function createAdminRoutes(context) { if (!pathname.startsWith('/api/admin/')) return false; const user = await requireUser(request, response, 'admin'); if (!user) return true; - const db = await readDb(); + if (request.method === 'GET' && pathname === '/api/admin/results') { + const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId'); + if (!requestedExamId) { + if (!requirePermission(user, response, 'results.read')) return true; + return sendJson(response, 200, { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: [], resultCache: { enabled: cache.enabled, status: cache.status } }); + } + } + const db = request.authDb || await readDb(); if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) { return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用'); } @@ -262,21 +269,25 @@ export function createAdminRoutes(context) { if (pathname === '/api/admin/admissions' && request.method === 'GET') { if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据'); - const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] })); - const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', examName: db.exams.find(item => item.id === plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) })); + const examById = new Map(db.exams.map(item => [item.id, item])); + const schoolById = new Map(db.schools.map(item => [item.id, item])); + const userById = new Map(db.users.map(item => [item.id, item])); + const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item])); + const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) })); + const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) })); const placements = admissionRecords(db, 'placement').map(placement => { - const account = db.users.find(item => item.id === placement.userId) || {}; - const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {}; + const account = userById.get(placement.userId) || {}; + const profile = profileByUserId.get(placement.userId) || {}; const qualification = resolveProfileSpecialty(profile); - return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' }; + return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: schoolById.get(placement.schoolId)?.name || '' }; }); const preferences = admissionRecords(db, 'preference').map(preference => { - const account = db.users.find(item => item.id === preference.userId) || {}; - const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {}; - return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) }; + const account = userById.get(preference.userId) || {}; + const profile = profileByUserId.get(preference.userId) || {}; + return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: schoolById.get(choice.schoolId)?.name || '' })) }; }); const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => { - const school = db.schools.find(entry => entry.id === item.schoolId); + const school = schoolById.get(item.schoolId); return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' }; }); return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) }); @@ -1441,83 +1452,67 @@ export function createAdminRoutes(context) { } if (request.method === 'GET' && pathname === '/api/admin/results') { if (!requirePermission(user, response, 'results.read')) return true; + const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId'); + const selectedExam = db.exams.find(item => item.id === requestedExamId); + if (!selectedExam) return sendError(response, 404, '请选择有效的考试后再读取成绩'); const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item)); - const approved = scopedRegistrations.filter(item => item.status === 'approved'); - const registrationById = new Map(scopedRegistrations.map(item => [item.id, item])); + const selectedScopedRegistrations = scopedRegistrations.filter(item => item.examId === selectedExam.id); + if (user.adminLevel !== 'super' && !selectedScopedRegistrations.length) return sendError(response, 404, '该考试不在当前管理范围内'); + const approved = selectedScopedRegistrations.filter(item => item.status === 'approved'); + const selectedRegistrationIds = new Set(selectedScopedRegistrations.map(item => item.id)); + const examRegistrations = db.registrations.filter(item => item.examId === selectedExam.id && item.status === 'approved'); + const examRegistrationIds = new Set(examRegistrations.map(item => item.id)); + const examResults = db.results.filter(result => examRegistrationIds.has(result.registrationId)); + const scoreDb = { ...db, registrations: examRegistrations, results: examResults }; + const registrationById = new Map(selectedScopedRegistrations.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 rawScopedResults = examResults.filter(result => selectedRegistrationIds.has(result.registrationId)); + const results = rawScopedResults.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 subject = selectedExam.subjects.find(item => item.id === result.subjectId); + const pass = subjectPassEvaluation(scoreDb, result, subject); return { ...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: classById.get(profile?.classId)?.name || profile?.grade || '', - examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name, + examId: selectedExam.id, examCode: selectedExam.code, examName: selectedExam.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 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 exams = db.exams.map(exam => { - const examRegistrations = approved.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 = resultCountByExam.get(exam.id) || 0; + if (exam.id !== selectedExam.id) return publicExam(exam); + const summaries = approved.map(item => examResultSummary(scoreDb, item)).filter(Boolean); + const enrolledSubjects = approved.reduce((sum, item) => sum + item.subjectIds.length, 0); + const scored = rawScopedResults.length; return { - ...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored, - published: publishedCountByExam.get(exam.id) || 0, missing: Math.max(0, enrolledSubjects - scored), + ...publicExam(exam), registrationCount: approved.length, enrolledSubjects, scored, + published: rawScopedResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored), complete: summaries.filter(item => item.complete).length, qualified: summaries.filter(item => item.complete && item.qualified === true).length, unqualified: summaries.filter(item => item.complete && item.qualified === false).length, - appeals: appealCountByExam.get(exam.id) || 0 + appeals: appeals.length }; - }).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 registrations = user.adminLevel === 'super' ? approved.map(item => { const view = examRegistrationView(db, item); 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: 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, selectedExamId, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } }); + return sendJson(response, 200, { ok: true, selectedExamId: selectedExam.id, 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/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs index ca7f1f2..63f29ad 100644 --- a/src/routes/admission.routes.mjs +++ b/src/routes/admission.routes.mjs @@ -22,7 +22,7 @@ export function createAdmissionRoutes(context) { if (!pathname.startsWith('/api/admission/')) return false; const user = await requireUser(request, response, 'admission_school'); if (!user) return true; - const db = await readDb(); + const db = request.authDb || await readDb(); const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool); if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校'); @@ -53,16 +53,25 @@ export function createAdmissionRoutes(context) { return sendJson(response, existing ? 200 : 201, { ok: true, plan }); } if (request.method === 'GET' && pathname === '/api/admission/placements') { + const accountById = new Map(db.users.map(item => [item.id, item])); + const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item])); + const examById = new Map(db.exams.map(item => [item.id, item])); + const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item])); + const publishedResultsByRegistration = new Map(); + for (const result of db.results) { + if (!result.published) continue; + const rows = publishedResultsByRegistration.get(result.registrationId) || []; + rows.push(result); + publishedResultsByRegistration.set(result.registrationId, rows); + } const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => { - const account = db.users.find(entry => entry.id === item.userId) || {}; - const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {}; - const registration = db.registrations.find(entry => entry.examId === item.examId && entry.userId === item.userId); - const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => { - const exam = db.exams.find(entry => entry.id === item.examId); - return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }; - }); + const account = accountById.get(item.userId) || {}; + const profile = profileByUserId.get(item.userId) || {}; + const exam = examById.get(item.examId); + const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`); + const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score })); const qualification = resolveProfileSpecialty(profile); - return { ...item, examName: db.exams.find(exam => exam.id === item.examId)?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results }; + return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results }; }); const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final')); return sendJson(response, 200, { ok: true, school, placements, completedExams }); diff --git a/src/security/session.mjs b/src/security/session.mjs index ce106d3..2da6a19 100644 --- a/src/security/session.mjs +++ b/src/security/session.mjs @@ -19,6 +19,7 @@ export function createSessionManager({ sessions, readDb, sendError }) { return null; } const db = await readDb(); + request.authDb = db; const user = db.users.find(item => item.id === session.userId) || null; return user?.active === false || user?.archivedAt ? null : normalizeUser(user); } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index c759650..e68342f 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -994,11 +994,14 @@ try { assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览'); assert.equal(stagedPreview.data.preview, true); assert.equal(stagedPreview.data.summary.valid, 1); - assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库'); + const unselectedResults = await admin.request('/api/admin/results'); + assert.deepEqual(unselectedResults.data.results, [], '未选择考试时不得读取成绩数据'); + assert.deepEqual(unselectedResults.data.registrations, [], '未选择考试时不得读取录分名单'); + assert.ok(!(await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库'); const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } }); assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩'); assert.equal(stagedCommit.data.count, 1); - assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库'); + assert.ok((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库'); const bulkDraftResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: false, rows: [{ registrationId, score: 125.5 }] } }); assert.equal(bulkDraftResult.response.status, 200, '名单式录分应支持按科目批量暂存'); assert.equal(bulkDraftResult.data.published, false); @@ -1007,12 +1010,12 @@ try { assert.equal(publishResult.data.published, true); 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 adminResults = await admin.request('/api/admin/results'); + const adminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`); assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0'); const featureScoreUpdate = await admin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 87.5 }] } }); assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能按考试名单批量登记特征分'); assert.equal(featureScoreUpdate.data.count, 1); - assert.equal((await admin.request('/api/admin/results')).data.registrations.find(item => item.id === registrationId).featureScore, 87.5, '批量保存后应立即回填特征分'); + assert.equal((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.registrations.find(item => item.id === registrationId).featureScore, 87.5, '批量保存后应立即回填特征分'); assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分'); assert.equal((await classAdmin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 10 }] } })).response.status, 403, '班级管理员不得批量登记特征分'); assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用'); @@ -1040,7 +1043,7 @@ try { assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿'); assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定'); assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改'); - const classResults = await classAdmin.request('/api/admin/results'); + const classResults = await classAdmin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`); 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, '班级管理员不得录入或发布成绩'); @@ -1121,7 +1124,7 @@ try { assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交'); assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩'); assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议'); - const archivedAdminResults = await admin.request('/api/admin/results'); + const archivedAdminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`); assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态'); const archivedCandidateResults = await candidate.request('/api/candidate/results'); assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区');