From 48bef02904ca437d8bcc46e9a2de7b0313221d9e Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 16:43:57 +0800 Subject: [PATCH] patch3 --- app.js | 51 +++++++++++++++++++++++++++++++++ src/client/admin-views.mjs | 5 ++-- src/client/admission-views.mjs | 6 +++- src/routes/admin.routes.mjs | 29 ++++++++++++++++++- src/routes/admission.routes.mjs | 22 +++++++++++++- styles.css | 9 ++++++ tests/system.test.mjs | 51 +++++++++++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 5 deletions(-) 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(`
登录账号${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(`
登录账号${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} 个
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
`; 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)}` : '—'}
尚未产生投档记录
`; - 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} 人
${data.placements.map(item => ``).join('') || ''}
考生资格当次成绩投档类别状态审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${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 || '已处理')}`}
暂无投档考生
`; + 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 || ''}
选择考生 / 考试资格当次成绩投档类别状态单人审核
暂无投档考生
`; } 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, '创建考试时应同步登记该场考试的专属物理表');