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} 份 `);
+ const preferencePage = paged(data.preferences, 'adminPreferenceTable');
+ container.insertAdjacentHTML('beforeend', `考生志愿只读台账
仅超级管理员可查看;系统不提供任何管理员修改入口。
${data.preferences.length} 份 ${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} 个已建账户保留历史记录,不提供物理删除
`;
+ 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} 个已建账户保留历史记录,不提供物理删除
${pagination(accountPage)}`;
const planForm = `代招生校上传计划
每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。
`;
- const plans = `${pendingPlans.length} 份待审 `;
- const placements = ``;
+ const plans = `${pendingPlans.length} 份待审 ${pagination(planPage)}`;
+ const placements = `投档与退档监督
志愿只读,管理员均无修改入口${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 `提交本校招生计划
按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。
`;
+ const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
+ return `提交本校招生计划
按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。
`;
}
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} 人 `;
+ 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} 人 ${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), '考生历史成绩应标记为归档并进入折叠区');