diff --git a/app.js b/app.js
index 3f8c7a6..f4c7bdb 100644
--- a/app.js
+++ b/app.js
@@ -158,6 +158,21 @@ function updateQualificationSelection(container) {
if (count) count.textContent = `已选 ${selected.length} 人`;
}
+function updatePlacementSelection() {
+ const table = document.getElementById('placementReviewTable');
+ if (!table) return;
+ const selectable = [...table.querySelectorAll('[data-placement-select]:not(:disabled)')];
+ const visible = selectable.filter(input => !input.closest('tr').hidden);
+ const selected = selectable.filter(input => input.checked);
+ const selectAll = document.querySelector('[data-placement-select-all][data-target="placementReviewTable"]');
+ if (selectAll) {
+ selectAll.checked = visible.length > 0 && visible.every(input => input.checked);
+ selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked;
+ }
+ const count = document.querySelector('[data-placement-selected-count]');
+ if (count) count.textContent = `已选 ${selected.length} 人`;
+}
+
function applyTableFilters(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
@@ -174,6 +189,7 @@ function applyTableFilters(tableId) {
if (tableId === 'registrationTable') updateRegistrationSelection();
if (tableId === 'candidateTable') updateCandidateSelection();
if (tableId === 'paymentTable') updatePaymentSelection();
+ if (tableId === 'placementReviewTable') updatePlacementSelection();
if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger'));
}
@@ -360,6 +376,20 @@ document.addEventListener('click', async event => {
toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`);
return renderRoute();
}
+ if (action === 'bulk-placement-review') {
+ const ids = [...document.querySelectorAll('#placementReviewTable [data-placement-select]:checked')].map(input => input.value);
+ const decision = target.dataset.decision;
+ if (!ids.length) return toast('请先选择待审核考生', '可勾选单人,或选择当前筛选结果');
+ let note = '';
+ if (decision === 'withdraw') {
+ note = window.prompt(`将为所选 ${ids.length} 名考生申请退档,请填写统一的特殊理由(至少 8 个字)`, '') ?? '';
+ if (!note) return;
+ if (note.trim().length < 8) return toast('退档理由至少需要 8 个字');
+ } else if (!window.confirm(`确认批量接收所选 ${ids.length} 名投档考生吗?`)) return;
+ const result = await api('/api/admission/placements/bulk', { method: 'POST', body: { ids, decision, note } });
+ toast(decision === 'accept' ? '批量接收完成' : '批量退档申请已提交', `已处理 ${result.count} 名考生`);
+ return renderRoute();
+ }
if (action === 'toggle-admin-account') {
const active = target.dataset.active === 'true';
if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return;
@@ -373,6 +403,19 @@ document.addEventListener('click', async event => {
setModal(`
ADMIN PASSWORD RESET管理员临时密码已生成
请通过线下安全渠道交给管理员,本页面关闭后不再展示。
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}旧密码和现有登录会话均已失效。
`);
return;
}
+ if (action === 'toggle-admission-account') {
+ const active = target.dataset.active === 'true';
+ if (!active && !window.confirm('确认停用该招生学校账户?现有登录会话会立即失效,历史审核记录继续保留。')) return;
+ await api(`/api/admin/admission-school-accounts/${target.dataset.id}`, { method: 'PATCH', body: { active } });
+ toast(active ? '招生学校账户已启用' : '招生学校账户已停用');
+ return renderRoute();
+ }
+ if (action === 'reset-admission-account-password') {
+ if (!window.confirm('确认重置该招生学校账户密码?旧密码和现有登录会话会立即失效。')) return;
+ const data = await api(`/api/admin/admission-school-accounts/${target.dataset.id}/reset-password`, { method: 'POST' });
+ setModal(`ADMISSION ACCOUNT RESET招生学校临时密码已生成
请通过线下安全渠道交给招生学校,本页面关闭后不再展示。
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}账户已同步启用,旧密码和现有会话均已失效。
`);
+ return;
+ }
if (action === 'add-admission-category') {
const sources = state.pageData?.sourceSchools || [];
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
@@ -635,6 +678,14 @@ document.addEventListener('input', event => {
});
document.addEventListener('change', event => {
+ if (event.target.matches('[data-placement-select]')) updatePlacementSelection();
+ if (event.target.matches('[data-placement-select-all]')) {
+ const table = document.getElementById(event.target.dataset.target);
+ table?.querySelectorAll('[data-placement-select]:not(:disabled)').forEach(input => {
+ if (!input.closest('tr').hidden) input.checked = event.target.checked;
+ });
+ updatePlacementSelection();
+ }
if (event.target.matches('[data-action="result-bulk-exam"]')) {
state.resultExamFilter = event.target.value;
state.resultSubjectFilter = '';
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index 22d4d58..ea8b310 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -191,11 +191,12 @@ export function createAdminViews(context) {
const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending');
const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' };
const settings = `考试志愿设置
成绩发布后按场次开放,并持续向考生展示录取进度。
${selected ? `` : ''}`;
- const accounts = `招生学校账号
只可绑定已在学校管理中标记为“招生校”的学校。
${data.schoolAccounts.length} 个 `;
+ 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 planForm = `代招生校上传计划
每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。
`;
const plans = `${pendingPlans.length} 份待审 `;
const placements = ``;
- 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}`;
+ 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}`;
}
function adminIndicatorQualifications(data) {
diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs
index 098dc33..37b3650 100644
--- a/src/client/admission-views.mjs
+++ b/src/client/admission-views.mjs
@@ -29,7 +29,11 @@ export function createAdmissionViews(context) {
function placements(data) {
const exportBar = data.completedExams?.length ? `FINAL ROSTER正式录取考生信息 Excel仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。
` : '';
- return `${exportBar}本校投档名单
显示投档所需的考生信息与当次成绩,不包含其余志愿。
${data.placements.length} 人 `;
+ 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} 人 `;
}
return { renderAdmission };
}
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index f90e8d3..46d4caa 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -225,7 +225,10 @@ export function createAdminRoutes(context) {
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 schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser);
+ const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => {
+ const school = db.schools.find(entry => entry.id === 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) });
}
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
@@ -240,6 +243,30 @@ export function createAdminRoutes(context) {
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
return sendJson(response, 201, { ok: true, account: safeUser(account) });
}
+ const admissionAccountMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)$/);
+ if (admissionAccountMatch && request.method === 'PATCH') {
+ if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护招生学校账号');
+ const target = db.users.find(item => item.id === admissionAccountMatch[1] && item.role === 'admission_school');
+ if (!target) return sendError(response, 404, '招生学校账号不存在');
+ const body = await readJson(request);
+ target.active = body.active == null ? target.active : Boolean(body.active);
+ target.displayName = cleanText(body.displayName || target.displayName, 80);
+ await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`));
+ if (!target.active) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
+ return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
+ }
+ const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
+ if (admissionAccountResetMatch && request.method === 'POST') {
+ if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置招生学校账号密码');
+ const target = db.users.find(item => item.id === admissionAccountResetMatch[1] && item.role === 'admission_school');
+ if (!target) return sendError(response, 404, '招生学校账号不存在');
+ const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
+ target.passwordHash = hashPassword(temporaryPassword);
+ target.active = true;
+ await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`));
+ for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
+ return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
+ }
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
if (settingMatch && request.method === 'PUT') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs
index 09066e1..ca7f1f2 100644
--- a/src/routes/admission.routes.mjs
+++ b/src/routes/admission.routes.mjs
@@ -62,7 +62,7 @@ export function createAdmissionRoutes(context) {
return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score };
});
const qualification = resolveProfileSpecialty(profile);
- return { ...item, 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: 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 };
});
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 });
@@ -93,6 +93,26 @@ export function createAdmissionRoutes(context) {
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
}
+ if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
+ const body = await readJson(request);
+ const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
+ const decision = cleanText(body.decision, 30);
+ const note = cleanText(body.note, 500);
+ if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
+ if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
+ if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
+ const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
+ if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
+ const now = nowIso();
+ for (const placement of placements) {
+ placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
+ placement.payload.schoolDecisionNote = note;
+ if (decision === 'withdraw') placement.payload.withdrawalReason = note;
+ placement.updatedAt = now;
+ }
+ await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length} 人`));
+ return sendJson(response, 200, { ok: true, count: placements.length, decision });
+ }
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
if (request.method === 'PATCH' && placementMatch) {
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
diff --git a/styles.css b/styles.css
index 75e58d0..af4556e 100644
--- a/styles.css
+++ b/styles.css
@@ -631,11 +631,13 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); }
.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; }
.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); }
+.admission-admin-grid.single { grid-template-columns:1fr; }
.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; }
.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; }
.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; }
.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences span { display:flex; gap:10px; padding:10px 12px; border-radius:8px; background:#f5f7fa; }.locked-preferences b { color:#287486; }
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
+.placement-review-ledger { margin-bottom:16px; overflow:hidden; }.placement-review-ledger .data-toolbar { flex-wrap:wrap; }.placement-review-ledger table { min-width:1250px; }.placement-review-ledger td > small,.placement-review-ledger td > strong { display:block; margin-top:3px; }.placement-status-pills,.account-status-pills { margin:0 24px 12px; }.placement-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:0 24px 16px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; }.placement-bulk-toolbar > label,.placement-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.placement-bulk-toolbar input { width:17px; height:17px; }.placement-bulk-toolbar strong { margin-right:5px; color:#287486; }.placement-bulk-toolbar button { min-height:40px; }
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
@@ -688,6 +690,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */
.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; }
+.admission-account-management { margin-bottom:16px; }.admission-account-management > form { grid-template-columns:minmax(240px,1fr) minmax(380px,1.4fr) minmax(220px,.8fr) auto; align-items:end; }.admission-account-management > form > .field-row { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { min-width:170px; }.account-management-divider { display:flex; align-items:center; justify-content:space-between; padding:15px 24px; border-top:1px solid #e1e7ee; border-bottom:1px solid #e1e7ee; background:#f7f9fc; }.account-management-divider strong { color:#263a54; }.account-management-divider span { color:#8390a3; font-size:12px; }.admission-account-management .data-toolbar { padding-top:16px; padding-bottom:12px; }.admission-account-management table { min-width:980px; }
.admission-settings-panel .panel-title,.admission-account-panel .panel-title,.admission-plan-console .panel-title { min-height:88px; height:auto; padding:20px 24px; background:linear-gradient(100deg,#fff,#f6f9fc); }
.admission-settings-panel .panel-title h2,.admission-account-panel .panel-title h2,.admission-plan-console .panel-title h2 { font:400 23px/1.2 STKaiti,KaiTi,serif; }
.admission-settings-panel .panel-title p,.admission-account-panel .panel-title p,.admission-plan-console .panel-title p { margin:7px 0 0; color:#69778c; font-size:13px; line-height:1.6; }
@@ -828,3 +831,9 @@ body.review-subpage-open { overflow:hidden; }
@media (max-width:650px) {
.result-workbench-selectors,.result-workbench-summary { grid-template-columns:1fr; }.result-workbench-summary > span { padding:6px 0; border-right:0; border-bottom:1px solid #e2e8f0; }.result-workbench-actions { align-items:stretch; flex-direction:column; }.result-workbench-actions > div { margin:0 0 5px; }.result-workbench-actions button { width:100%; }
}
+@media (max-width:1100px) {
+ .admission-account-management > form { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { width:100%; }
+}
+@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; }
+}
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index 7d374e7..a856377 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -156,6 +156,7 @@ try {
const clientSources = await Promise.all([
'app.js',
'src/client/admin-views.mjs',
+ 'src/client/admission-views.mjs',
'src/client/candidate-views.mjs',
'src/client/public-views.mjs'
].map(file => readFile(resolve(root, file), 'utf8')));
@@ -170,6 +171,9 @@ try {
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
+ assert.match(appSource, /data-action="bulk-placement-review"/, '招生学校投档审核应提供批量处理入口');
+ assert.match(appSource, /data-action="toggle-admission-account"/, '超级管理员应有招生学校账户启停入口');
+ assert.match(appSource, /data-action="reset-admission-account-password"/, '超级管理员应有招生学校账户密码重置入口');
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
const { DatabaseSync } = await import('node:sqlite');
@@ -620,6 +624,53 @@ try {
assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
+
+ const placementCandidates = qualificationExam.qualificationStatus.rows.slice(0, 3);
+ assert.equal(placementCandidates.length, 3, '批量投档测试至少需要三名候选考生');
+ const placementIds = placementCandidates.map((row, index) => `placement_bulk_test_${index + 1}`);
+ const placementCreatedAt = new Date().toISOString();
+ const placementWriter = new DatabaseSync(testDb);
+ const insertPlacement = placementWriter.prepare(`
+ INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at)
+ VALUES (?, 'placement', ?, ?, ?, 'school_review', ?, ?, ?)
+ `);
+ for (const [index, row] of placementCandidates.entries()) {
+ insertPlacement.run(
+ placementIds[index], exam.id, row.userId, admissionOnlySchool.data.school.id,
+ JSON.stringify({ categoryCode: index === 2 ? 'arts' : 'general', categoryName: index === 2 ? '美术特长生' : '普通生', totalScore: 620 - index, preferenceOrder: index + 1 }),
+ placementCreatedAt, placementCreatedAt
+ );
+ }
+ placementWriter.close();
+
+ const placementReviewLedger = await admissionSchoolClient.request('/api/admission/placements');
+ const seededPlacements = placementReviewLedger.data.placements.filter(item => placementIds.includes(item.id));
+ assert.equal(seededPlacements.length, 3, '招生学校投档台账应返回可供筛选、搜索的完整候选记录');
+ assert.ok(seededPlacements.every(item => item.examName === exam.name && item.candidate.registrationNumber), '投档台账应包含考试名称和考生报名号');
+ const bulkAccept = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept', note: '批量核验通过' } });
+ assert.equal(bulkAccept.response.status, 200, '招生学校应能批量接收多名投档考生');
+ assert.equal(bulkAccept.data.count, 2, '批量接收应返回实际处理人数');
+ const shortBulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '材料不符' } });
+ assert.equal(shortBulkWithdrawal.response.status, 400, '批量退档必须填写充分的特殊理由');
+ const bulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '专项资格证明材料复核不通过' } });
+ assert.equal(bulkWithdrawal.response.status, 200, '招生学校应能批量申请退档');
+ const processedPlacementLedger = await admissionSchoolClient.request('/api/admission/placements');
+ assert.ok(processedPlacementLedger.data.placements.filter(item => placementIds.slice(0, 2).includes(item.id)).every(item => item.status === 'admitted'), '批量接收后所选记录应全部进入拟录取状态');
+ assert.equal(processedPlacementLedger.data.placements.find(item => item.id === placementIds[2]).status, 'withdrawal_pending', '批量退档后记录应进入上级审核状态');
+ assert.equal((await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept' } })).response.status, 409, '已处理记录不得被重复批量审核');
+
+ const admissionAccountLedger = await admin.request('/api/admin/admissions');
+ const managedAdmissionAccount = admissionAccountLedger.data.schoolAccounts.find(item => item.id === admissionAccount.data.account.id);
+ assert.equal(managedAdmissionAccount.schoolName, '海州市招生实验学校', '招生学校账户台账应显示绑定学校');
+ assert.equal(managedAdmissionAccount.active, true, '招生学校账户台账应返回真实启停状态');
+ const disabledAdmissionAccount = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: false } });
+ assert.equal(disabledAdmissionAccount.data.account.active, false, '超级管理员应能停用招生学校账户');
+ assert.equal((await admissionSchoolClient.request('/api/admission/context')).response.status, 401, '停用招生学校账户应立即使既有会话失效');
+ assert.equal((await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: true } })).data.account.active, true, '超级管理员应能重新启用招生学校账户');
+ const resetAdmissionPassword = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}/reset-password`, { method: 'POST' });
+ assert.match(resetAdmissionPassword.data.temporaryPassword, /^Reset-/, '重置招生学校账户密码应返回一次性临时密码');
+ assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: 'Admission123!' } })).response.status, 401, '招生学校账户重置后原密码应失效');
+ assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: resetAdmissionPassword.data.temporaryPassword } })).response.status, 200, '招生学校账户应能使用临时密码重新登录');
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');