From 0b423e565b1814f4ca7955289c83e9ab6919f427 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 15:07:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E4=BC=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 67 +++++++++++++++++++++++++++++++--- src/client/admin-views.mjs | 9 ++++- src/client/candidate-views.mjs | 7 ++-- src/client/public-views.mjs | 50 ++++++++++++++++++------- src/client/state.mjs | 2 + src/routes/admin.routes.mjs | 60 ++++++++++++++++++++++++++---- styles.css | 42 +++++++++++++++++++++ tests/system.test.mjs | 21 +++++++++-- 8 files changed, 222 insertions(+), 36 deletions(-) diff --git a/app.js b/app.js index 5a2d5f0..471d033 100644 --- a/app.js +++ b/app.js @@ -59,7 +59,7 @@ function renderError(error) { } const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; -const { brand, renderHome, renderAnnouncements, renderAuth } = createPublicViews(baseViewContext); +const { brand, renderHome, renderNoticeCenter, renderAuth } = createPublicViews(baseViewContext); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand }); @@ -75,7 +75,8 @@ async function renderRoute() { const route = location.hash.slice(1) || 'home'; const [section, page = 'dashboard'] = route.split('/'); if (section === 'home') renderHome(); - else if (section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderAnnouncements(state.publicAnnouncements); } + else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); } + else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); } else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'candidate') await renderCandidate(page); else if (section === 'admin') await renderAdmin(page); @@ -103,6 +104,19 @@ function updateRegistrationSelection() { document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; }); } +function updateQualificationSelection(container) { + if (!container) return; + const rows = [...container.querySelectorAll('[data-qualification-select]')]; + const selected = rows.filter(input => input.checked); + const selectAll = container.querySelector('[data-action="qualification-select-all"]'); + if (selectAll) { + selectAll.checked = rows.length > 0 && selected.length === rows.length; + selectAll.indeterminate = selected.length > 0 && selected.length < rows.length; + } + const count = container.querySelector('[data-qualification-selected-count]'); + if (count) count.textContent = `已选 ${selected.length} 人`; +} + function applyTableFilters(tableId) { const table = document.getElementById(tableId); if (!table) return; @@ -182,9 +196,20 @@ document.addEventListener('click', async event => { await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return; } if (action === 'open-notice') { - const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice; - const contentHtml = notice.contentHtml || `

${h(notice.content).replace(/\r?\n/g, '

')}

`; - setModal(`
${contentHtml}
`); return; + return navigate(`notice/${target.dataset.id}`); + } + if (action === 'notice-category') { + state.noticeCategory = target.dataset.category; + state.noticePage = 1; + renderNoticeCenter(state.publicAnnouncements); + return; + } + if (action === 'notice-page') { + if (target.disabled) return; + state.noticePage = Number(target.dataset.page || 1); + renderNoticeCenter(state.publicAnnouncements); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; } if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } if (action === 'download-admitted-candidates') { @@ -230,6 +255,37 @@ document.addEventListener('click', async event => { toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生'); return renderRoute(); } + if (action === 'qualification-select-all') { + const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger'); + container?.querySelectorAll('[data-qualification-select]').forEach(input => { input.checked = target.checked; }); + updateQualificationSelection(container); + return; + } + if (action === 'bulk-indicator-qualification') { + const toolbar = target.closest('[data-qualification-bulk]'); + const ledger = toolbar?.closest('.qualification-ledger'); + const userIds = [...ledger.querySelectorAll('[data-qualification-select]:checked')].map(input => input.closest('tr').dataset.userId); + const value = toolbar.querySelector('[data-bulk-eligible]').value; + if (!userIds.length) return toast('请先选择考生', '可使用左侧复选框或全选'); + if (!value) return toast('请选择批量资格结论'); + if (!window.confirm(`确认将所选 ${userIds.length} 名考生批量设为“${value === 'true' ? '有' : '无'}指标分配资格”?`)) return; + const data = await api(`/api/admin/indicator-qualifications/${toolbar.dataset.examId}/bulk`, { method: 'PUT', body: { userIds, eligible: value === 'true' } }); + toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`); + return renderRoute(); + } + if (action === 'toggle-admin-account') { + const active = target.dataset.active === 'true'; + if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return; + await api(`/api/admin/admins/${target.dataset.id}`, { method: 'PATCH', body: { active } }); + toast(active ? '管理员账户已启用' : '管理员账户已停用', active ? '该账号可以重新登录' : '现有会话已结束,历史记录仍保留'); + return renderRoute(); + } + if (action === 'reset-admin-password') { + if (!window.confirm('确认重置该管理员密码?其现有登录会话会立即失效。')) return; + const data = await api(`/api/admin/admins/${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)); @@ -451,6 +507,7 @@ document.addEventListener('input', event => { }); document.addEventListener('change', event => { + if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger')); if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target); if (event.target.matches('[data-registration-select]')) updateRegistrationSelection(); if (event.target.matches('[data-registration-select-all]')) { diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index da5b702..8055568 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -177,7 +177,12 @@ export function createAdminViews(context) { function adminIndicatorQualifications(data) { if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。'); - return `
SOURCE SCHOOL CERTIFICATION

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

确认对象为本校在册且个人资料已完善的考生。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。

${data.exams.map(item => { const status = item.qualificationStatus; return `
${h(item.exam.code)}

${h(item.exam.name)}

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

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

逐人确认或多选批量设置。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。

${data.exams.map(item => { + const status = item.qualificationStatus; + const bulk = `
已选 0 人
`; + const rows = status.rows.map(row => `${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}`).join(''); + return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}${bulk}
${rows || ''}
选择报名号 / 姓名特长类型指标分配资格确认时间保存
本校暂无资料已完善的在册考生
`; + }).join('')}`; } function adminNotices(notices) { @@ -232,7 +237,7 @@ export function createAdminViews(context) { } function adminUsers(data) { - return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

管理员层级决定可见范围和可执行操作;同一级可创建多名账号。

${data.admins.map(item => ``).join('')}
管理员登录账号层级绑定范围状态
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; + return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

管理员账户不物理删除;停用会立即结束现有会话,并完整保留审批和审计记录。

${data.admins.map(item => { const self = item.id === state.user.id; return ``; }).join('')}
管理员登录账号层级绑定范围状态账户操作
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; } function adminCenters(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index b788c0c..d66ba72 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -28,10 +28,9 @@ export function createCandidateViews(context) { const level = state.user?.adminLevel || 'super'; const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']]; const security = ['security', '账户安全', 'user']; - if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security]; - const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']]; - if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security]; - return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security]; + if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security]; + if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security]; + return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], security]; } function portalShell(role, page, content, title, description) { diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index 57a77f4..e5be14e 100644 --- a/src/client/public-views.mjs +++ b/src/client/public-views.mjs @@ -20,7 +20,7 @@ export function createPublicViews(context) { } function publicHeader() { - return `
${brand()}
`; + return `
${brand()}
`; } function renderHome() { @@ -32,27 +32,49 @@ export function createPublicViews(context) { app.innerHTML = `${publicHeader()}
-
最新

${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}

${h(siteCopy.heroTitle || '一个报名号,')}
${h(siteCopy.heroHighlight || '贯穿每一次考试。')}

${h(siteCopy.heroDescription || '')}

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
+
最新

${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}

${h(siteCopy.heroTitle || '一个报名号,')}
${h(siteCopy.heroHighlight || '贯穿每一次考试。')}

${h(siteCopy.heroDescription || '')}

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
${featured ? renderHeroTicket(featured) : '
暂无开放考试
'}
-

NOTICE BOARD

通知公告

报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
-

ADMISSION DISCLOSURE

招生录取公开卷宗

指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。

+

NOTICE BOARD

通知公告

招生录取公示已纳入通知公告,可按类别统一查询。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
`; } - function renderAnnouncements(data = state.publicAnnouncements) { + function noticeDocuments(data = state.publicAnnouncements) { + const ordinary = (state.publicData.notices || []).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt })); + const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` })); + const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: '录取名单', title: `${item.examName}最终录取名单`, summary: `共 ${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` })); + const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` })); + return [...ordinary, ...qualifications, ...admissions, ...cutoffs].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt)); + } + + function renderDocumentBody(document) { + if (document.documentType === 'notice') return `
${document.contentHtml || `

${h(document.content || '').replace(/\r?\n/g, '

')}

`}
`; + if (document.documentType === 'qualification') return `

本公示由生源校完成全部考生资格确认后自动生成。

${document.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`; + if (document.documentType === 'admission') return `

报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${document.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`; + return `

录取分数线为对应学校、招生类别最终录取考生的最低总成绩。

${document.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`; + } + + function renderNoticeCenter(data = state.publicAnnouncements, selectedId = '') { app.classList.remove('admin-readable'); + const documents = noticeDocuments(data); + const selected = documents.find(item => item.documentId === selectedId); const organization = state.publicData.organization || {}; - const sections = [data.qualifications?.length, data.admissions?.length, data.cutoffs?.length].filter(Boolean).length; - app.innerHTML = `${publicHeader()}

PUBLIC ADMISSION LEDGER

招生录取
公开卷宗

按考试留存资格确认、录取结果和分数线。报名号、姓名、总成绩与录取学校透明公开,证件及联系方式不在本页展示。

公开类别
${sections}
资格公示
${h(data.qualifications?.length || 0)}
录取公告
${h(data.admissions?.length || 0)}
- -

QUALIFICATION REGISTER

指标分配资格公示

仅在生源校全部考生完成资格确认后自动发布。

${(data.qualifications || []).map(item => `
${formatDate(item.publishedAt, true)} · ${h(item.schoolName)}

${h(item.examName)}

${h(item.rows.length)} 人
${item.rows.map(row => ``).join('')}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
`).join('') || '
暂无已完成全校确认的资格公示
'}
-

ADMISSION RESULTS

最终录取名单

录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。

${(data.admissions || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 人录取
${item.rows.map(row => ``).join('')}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
`).join('') || '
暂无已完成的录取公告
'}
-

ADMISSION CUTOFFS

录取分数线统计

分数线为对应学校、招生类别最终录取考生的最低总成绩。

${(data.cutoffs || []).map(item => `
${formatDate(item.publishedAt, true)}

${h(item.examName)}

${h(item.rows.length)} 条分数线
${item.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`).join('') || '
暂无已发布的录取分数线
'}
-
`; + if (selected) { + app.innerHTML = `${publicHeader()}
/${h(selected.subtype)}
${h(selected.category)} · ${h(selected.subtype)}

${h(selected.title)}

${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}

${renderDocumentBody(selected)}
`; + return; + } + const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))]; + const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部'; + const filtered = category === '全部' ? documents : documents.filter(item => item.category === category); + const pageSize = 8; + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1))); + state.noticeCategory = category; state.noticePage = page; + const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize); + app.innerHTML = `${publicHeader()}

PUBLIC NOTICE ARCHIVE

通知公告

考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。

${h(documents.length)}份公开文件
${h(category)}第 ${h(page)} / ${h(totalPages)} 页
共 ${h(filtered.length)} 条
${pageRows.map(item => ``).join('') || '
当前分类暂无公开信息
'}
${Array.from({length:totalPages},(_,index) => index + 1).map(value => ``).join('')}
`; } function renderHeroTicket(exam) { @@ -61,7 +83,7 @@ export function createPublicViews(context) { } function renderNoticeRow(notice) { - return ``; + return ``; } function renderPublicExam(exam) { @@ -84,5 +106,5 @@ export function createPublicViews(context) { return `
`; } - return { brand, renderHome, renderAnnouncements, renderAuth }; + return { brand, renderHome, renderNoticeCenter, renderAuth }; } diff --git a/src/client/state.mjs b/src/client/state.mjs index f5a1a00..b14b346 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -3,6 +3,8 @@ export const state = { profile: null, publicData: { organization: {}, notices: [], exams: [], stats: {} }, publicAnnouncements: { qualifications: [], admissions: [], cutoffs: [] }, + noticeCategory: '全部', + noticePage: 1, permissions: [], scopeLabel: '', pageData: null, diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 44cf998..1675041 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -151,6 +151,37 @@ export function createAdminRoutes(context) { }).filter(item => item.exam); return sendJson(response, 200, { ok: true, school, exams }); } + const qualificationBulkMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/bulk$/); + if (qualificationBulkMatch && request.method === 'PUT') { + if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以批量确认指标分配资格'); + const setting = admissionSetting(db, qualificationBulkMatch[1]); + if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报'); + const body = await readJson(request); + if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择批量设置为有资格或无资格'); + const userIds = [...new Set((Array.isArray(body.userIds) ? body.userIds : []).map(value => cleanText(value, 64)).filter(Boolean))]; + if (!userIds.length) return sendError(response, 400, '请至少选择一名考生'); + const profiles = userIds.map(userId => db.candidateProfiles.find(item => item.userId === userId && item.schoolId === user.schoolId && item.profileCompleted)); + if (profiles.some(item => !item) || profiles.some(profile => !db.users.some(item => item.id === profile.userId && item.role === 'candidate' && item.active))) return sendError(response, 403, '批量名单中包含不属于本校的有效考生'); + const now = nowIso(); + const existing = new Map(admissionRecords(db, 'indicator_qualification', setting.examId).map(item => [item.userId, item])); + const qualifications = profiles.map(profile => { + const qualification = existing.get(profile.userId) || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now }; + Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } }); + return qualification; + }); + const replacementIds = new Set(qualifications.map(item => item.id)); + const nextDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !replacementIds.has(item.id)), ...qualifications] }; + const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId); + const records = [...qualifications]; + if (status.complete) { + const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId); + const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now }; + Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } }); + records.push(publication); + } + await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`)); + return sendJson(response, 200, { ok: true, count: qualifications.length, qualificationStatus: status, published: status.complete }); + } const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/); if (qualificationMatch && request.method === 'PUT') { if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格'); @@ -451,19 +482,34 @@ export function createAdminRoutes(context) { } const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/); if (adminMatch && request.method === 'PATCH') { - if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员'); + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能维护管理员'); const body = await readJson(request); - const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId); - if (!target) return sendError(response, 404, '班级管理员不存在'); - const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId); - if (!schoolClass) return sendError(response, 400, '请选择本校有效班级'); + const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))); + if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围'); + if (target.id === user.id && body.active === false) return sendError(response, 409, '不能停用当前正在使用的管理员账户'); + const schoolClass = target.adminLevel === 'class' ? db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === target.schoolId) : null; + if (target.adminLevel === 'class' && !schoolClass) return sendError(response, 400, '请选择该管理员所属学校的有效班级'); const password = String(body.password || ''); if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位'); - Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) }); + Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass?.id || target.classId || null, active: body.active == null ? target.active : Boolean(body.active) }); if (password) target.passwordHash = hashPassword(password); - await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`)); + await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`)); + if (!target.active || password) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token); return sendJson(response, 200, { ok: true, admin: safeUser(target) }); } + const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/); + if (adminPasswordResetMatch && request.method === 'POST') { + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能重置管理员密码'); + const target = db.users.find(item => item.id === adminPasswordResetMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))); + if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围'); + if (target.id === user.id) return sendError(response, 409, '当前账号请在“账户安全”中修改自己的密码'); + 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 }); + } if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') { if (!requirePermission(user, response, '*')) return true; const body = await readJson(request); diff --git a/styles.css b/styles.css index bd10ebc..5d3afb0 100644 --- a/styles.css +++ b/styles.css @@ -640,3 +640,45 @@ button:disabled { cursor: not-allowed; opacity: .5; } .announcement-register { scroll-margin-top:130px; }.announcement-sheet { margin-top:20px; overflow:hidden; }.announcement-sheet > header { display:flex; align-items:center; justify-content:space-between; padding:22px 25px; border-bottom:1px solid #e6ebf1; background:linear-gradient(90deg,#fff,#f4f8fa); }.announcement-sheet > header span { color:#637386; font-size:13px; }.announcement-sheet > header h3 { margin:5px 0 0; font-size:20px; }.announcement-sheet > header > strong { color:#17375f; } .qualification-result { display:inline-flex; min-width:38px; justify-content:center; padding:4px 9px; border-radius:99px; color:#7b5b24; background:#fff5db; font-weight:700; }.qualification-result.eligible { color:#17665b; background:#e4f4ef; }.cutoff-score { color:#a75c18; font:700 20px Georgia,serif; } @media (max-width:760px) { .disclosure-entry,.announcement-hero { display:block; }.disclosure-entry button { margin-top:22px; }.announcement-hero dl { margin-top:28px; grid-template-columns:repeat(3,1fr); }.announcement-hero dl div { padding:14px 10px; }.announcement-index { justify-content:flex-start; overflow-x:auto; }.announcement-index a { white-space:nowrap; } } + +/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */ +.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; } +.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; } +.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:22px 24px 24px; gap:17px; } +.admission-settings-panel form label,.admission-account-panel form label,.admission-plan-console form > label { display:grid; align-content:start; gap:7px; min-width:0; color:#45546a; font-size:13px; font-weight:700; } +.admission-settings-panel input,.admission-settings-panel select,.admission-account-panel input,.admission-account-panel select,.admission-plan-console form > label input,.admission-plan-console form > label select,.admission-plan-console form > label textarea { width:100%; min-height:44px; padding:9px 12px; border:1px solid #ccd6e2; border-radius:8px; color:#26364b; background:#fff; font-size:14px; } +.admission-settings-panel input:focus,.admission-settings-panel select:focus,.admission-account-panel input:focus,.admission-account-panel select:focus,.admission-plan-console input:focus,.admission-plan-console select:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); } +.admission-settings-panel label small { color:#7d899a; font-size:12px; font-weight:400; line-height:1.5; } +.admission-settings-panel .field-row,.admission-account-panel .field-row,.admission-plan-console > form > .field-row { gap:16px; } +.admission-settings-panel .agreement,.admission-account-panel .agreement { display:flex; align-items:center; gap:10px; font-weight:500; } +.admission-settings-panel .agreement input,.admission-account-panel .agreement input { width:18px; min-height:18px; } +.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { justify-self:end; min-width:190px; min-height:44px; } +.admission-control-actions { margin:0; padding:17px 24px; border-top:1px solid #e1e7ee; background:#f8fafc; } +.admission-control-actions button { min-height:42px; } +.admission-plan-console .admission-builder-head { margin-bottom:2px; }.admission-plan-console .admission-builder-head strong { font-size:16px; }.admission-plan-console .admission-builder-head small { font-size:12px; } +.admission-plan-console .admission-category-fields label > span,.admission-plan-console .indicator-allocation-row label span { font-size:12px; }.admission-plan-console .admission-category-fields input,.admission-plan-console .admission-category-fields select,.admission-plan-console .indicator-allocation-row input,.admission-plan-console .indicator-allocation-row select { min-height:43px; font-size:14px; } + +/* 指标资格批处理 */ +.qualification-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:16px 24px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; } +.qualification-bulk-toolbar > label { display:flex; align-items:center; gap:9px; color:#3d4e63; font-size:13px; font-weight:700; }.qualification-bulk-toolbar input[type="checkbox"] { width:17px; height:17px; } +.qualification-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.qualification-bulk-toolbar strong { margin-right:4px; color:#287486; font-size:13px; }.qualification-bulk-toolbar select { min-width:210px; min-height:40px; padding:7px 10px; border:1px solid #cbd6df; border-radius:7px; background:#fff; }.qualification-bulk-toolbar .solid-button { min-height:40px; padding:8px 14px; } +.qualification-ledger tbody td:first-child { width:52px; text-align:center; }.qualification-ledger [data-qualification-select] { width:17px; height:17px; } + +/* 管理员账户操作 */ +.admin-account-actions { display:flex; flex-wrap:wrap; gap:7px; }.row-action.danger { color:#a1463e; border-color:#e3bcb8; background:#fff8f7; }.row-action.danger:hover { color:#fff; background:#a94e45; }.admin-account-ledger td:last-child { min-width:180px; } + +/* 完整通知目录与正文页 */ +.notice-archive-link { grid-column:1/-1; justify-self:end; border:0; color:#245783; background:transparent; font-weight:700; cursor:pointer; } +.notice-center-page,.notice-document-page { min-height:calc(100vh - 76px); padding-bottom:80px; background:#f3f6f9; } +.notice-center-hero { display:flex; align-items:flex-end; justify-content:space-between; min-height:255px; padding:70px max(5vw,28px) 42px; color:#fff; background:linear-gradient(118deg,#132f52 0 62%,#20677a); } +.notice-center-hero h1 { margin:8px 0 12px; font:400 clamp(40px,5vw,62px)/1 STKaiti,KaiTi,serif; }.notice-center-hero p:last-child { margin:0; color:rgba(255,255,255,.72); font-size:15px; }.notice-center-hero > strong { display:grid; justify-items:end; font:400 52px/1 Georgia,serif; }.notice-center-hero > strong small { margin-top:8px; color:#a9d9df; font:12px/1.2 system-ui,sans-serif; letter-spacing:.12em; } +.notice-center-shell { width:min(1180px,calc(100% - 48px)); display:grid; grid-template-columns:210px minmax(0,1fr); gap:0; margin:38px auto 0; border:1px solid #dbe2ea; background:#fff; box-shadow:0 16px 44px rgba(22,46,75,.08); } +.notice-category-nav { padding:24px 0; border-right:1px solid #e1e6ec; background:#f7f9fb; }.notice-category-nav button { width:100%; display:flex; justify-content:space-between; padding:13px 20px; border:0; border-left:3px solid transparent; color:#5f6d80; background:transparent; text-align:left; cursor:pointer; }.notice-category-nav button span { color:#9aa4b2; }.notice-category-nav button.active { border-left-color:#287486; color:#17375f; background:#eaf2f4; font-weight:700; } +.notice-directory > header { display:flex; align-items:center; justify-content:space-between; min-height:72px; padding:0 28px; border-bottom:1px solid #e1e6ec; }.notice-directory > header div { display:flex; align-items:baseline; gap:13px; }.notice-directory > header strong { color:#17375f; font-size:18px; }.notice-directory > header span,.notice-directory > header small { color:#8894a4; font-size:12px; } +.notice-directory-list > button { width:100%; display:grid; grid-template-columns:76px minmax(0,1fr) 24px; align-items:center; gap:20px; min-height:112px; padding:18px 28px; border:0; border-bottom:1px solid #e6eaf0; color:inherit; background:#fff; text-align:left; cursor:pointer; transition:background .16s,padding-left .16s; }.notice-directory-list > button:hover { padding-left:34px; background:#f7fafb; }.notice-directory-list time { display:grid; justify-items:center; padding-right:18px; border-right:1px solid #dce3e9; color:#8190a2; }.notice-directory-list time strong { color:#245783; font:400 27px Georgia,serif; }.notice-directory-list time span { margin-top:5px; font-size:11px; } +.notice-directory-copy { display:grid; gap:6px; min-width:0; }.notice-directory-copy em { color:#287486; font-size:11px; font-style:normal; font-weight:700; letter-spacing:.08em; }.notice-directory-copy strong { color:#24354b; font-size:17px; }.notice-directory-copy small { overflow:hidden; color:#7d8998; font-size:13px; line-height:1.55; text-overflow:ellipsis; white-space:nowrap; }.notice-directory-arrow { color:#8092a6; } +.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; } +.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; } +@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 1ec18ee..e3a5d33 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -396,6 +396,16 @@ try { const adminDirectory = await admin.request('/api/admin/admins'); assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员'); + const managedAdmin = adminDirectory.data.admins.find(item => item.username === 'school_admin_2'); + assert.ok(managedAdmin, '管理员台账应返回可管理的校级管理员'); + assert.equal((await admin.request(`/api/admin/admins/${adminDirectory.data.admins.find(item => item.username === 'admin')?.id}`, { method: 'PATCH', body: { active: false } })).response.status, 409, '超级管理员不能停用当前正在使用的自己'); + assert.equal((await admin.request(`/api/admin/admins/${managedAdmin.id}`, { method: 'PATCH', body: { active: false } })).response.status, 200, '超级管理员应能停用其他管理员账户'); + assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, false, '管理员台账应同步显示停用状态'); + const adminPasswordReset = await admin.request(`/api/admin/admins/${managedAdmin.id}/reset-password`, { method: 'POST' }); + assert.equal(adminPasswordReset.response.status, 200, '超级管理员应能重置其他管理员密码'); + assert.match(adminPasswordReset.data.temporaryPassword, /^Reset-/, '管理员密码重置应返回一次性临时密码'); + assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, true, '重置密码应重新启用目标管理员账户'); + assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: managedAdmin.username, password: adminPasswordReset.data.temporaryPassword } })).response.status, 200, '被重置的管理员应可使用临时密码重新登录'); const schoolCenters = await schoolAdmin.request('/api/admin/centers'); assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点'); const legacySuperCenters = await legacyAdmin.request('/api/admin/centers'); @@ -590,10 +600,13 @@ try { const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); const qualificationExam = qualificationLedger.data.exams.find(item => item.examId === exam.id); assert.ok(qualificationExam?.qualificationStatus.rows.length, '生源校学校管理员应看到本校待确认考生'); - for (const row of qualificationExam.qualificationStatus.rows) { - const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${row.userId}`, { method: 'PUT', body: { eligible: row.registrationNumber === candidateNumber } }); - assert.equal(confirmation.response.status, 200, `生源校应能逐人确认指标分配资格:${confirmation.data?.error || confirmation.data?.message || ''}\n${serverError}`); - } + const bulkQualification = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/bulk`, { method: 'PUT', body: { userIds: qualificationExam.qualificationStatus.rows.map(row => row.userId), eligible: false } }); + assert.equal(bulkQualification.response.status, 200, '生源校应能多选后一键批量确认指标分配资格'); + assert.equal(bulkQualification.data.count, qualificationExam.qualificationStatus.rows.length, '批量确认应返回实际更新人数'); + assert.equal(bulkQualification.data.published, true, '批量确认覆盖全校考生时应自动完成公示'); + const candidateQualificationRow = qualificationExam.qualificationStatus.rows.find(row => row.registrationNumber === candidateNumber); + const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${candidateQualificationRow.userId}`, { method: 'PUT', body: { eligible: true } }); + assert.equal(confirmation.response.status, 200, '批量确认后仍应支持逐人修正资格'); const completedQualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications'); assert.equal(completedQualificationLedger.data.exams.find(item => item.examId === exam.id).qualificationStatus.complete, true, '本校全部考生确认后应自动完成资格公示'); const publicQualification = await anonymous.request('/api/public/announcements');