This commit is contained in:
2026-07-21 15:07:19 +08:00 Unverified
parent 6be1ff227a
commit 0b423e565b
8 changed files with 222 additions and 36 deletions
+62 -5
View File
@@ -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 || `<p>${h(notice.content).replace(/\r?\n/g, '</p><p>')}</p>`;
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); 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(`<div class="modal-head"><div><span>ADMIN PASSWORD RESET</span><h2>管理员临时密码已生成</h2><p>请通过线下安全渠道交给管理员,本页面关闭后不再展示。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>登录账号</span><strong>${h(data.username)}</strong><span>临时密码</span><strong>${h(data.temporaryPassword)}</strong><p>旧密码和现有登录会话均已失效。</p></div><div class="modal-foot"><button class="solid-button" data-action="close-modal">我已保存</button></div>`);
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]')) {
+7 -2
View File
@@ -177,7 +177,12 @@ export function createAdminViews(context) {
function adminIndicatorQualifications(data) {
if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。');
return `<section class="qualification-ledger-intro"><div><span>SOURCE SCHOOL CERTIFICATION</span><h2>${h(data.school?.name)}资格确认簿</h2><p>确认对象为本校在册且个人资料已完善的考生。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。</p></div></section>${data.exams.map(item => { const status = item.qualificationStatus; return `<section class="panel qualification-ledger"><header><div><span>${h(item.exam.code)}</span><h2>${h(item.exam.name)}</h2></div><strong class="${status.complete ? 'complete' : ''}">${h(status.confirmed)} / ${h(status.total)} 已确认</strong></header><div class="qualification-progress"><i style="width:${status.total ? Math.round(status.confirmed / status.total * 100) : 0}%"></i></div>${status.complete ? '<div class="qualification-publication-state">✓ 本校资格已全部确认,公开公示已自动发布</div>' : '<div class="qualification-publication-state pending">未全部确认前不会公开,请逐项核对。</div>'}<div class="table-scroll"><table><thead><tr><th>报名号 / 姓名</th><th>特长类型</th><th>指标分配资格</th><th>确认时间</th><th>保存</th></tr></thead><tbody>${status.rows.map(row => `<tr data-exam-id="${h(item.examId)}" data-user-id="${h(row.userId)}"><td><strong>${h(row.name)}</strong><small class="mono">${h(row.registrationNumber)}</small></td><td>${h(row.specialtyLabel)}</td><td><select data-indicator-eligible><option value="" ${row.confirmed ? '' : 'selected'}>请选择</option><option value="true" ${row.confirmed && row.eligible ? 'selected' : ''}>有指标分配资格</option><option value="false" ${row.confirmed && !row.eligible ? 'selected' : ''}>无指标分配资格</option></select></td><td>${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}</td><td><button class="row-action primary" data-action="save-indicator-qualification">确认</button></td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">本校暂无资料已完善的在册考生</td></tr>'}</tbody></table></div></section>`; }).join('')}`;
return `<section class="qualification-ledger-intro"><div><span>SOURCE SCHOOL CERTIFICATION</span><h2>${h(data.school?.name)}资格确认簿</h2><p>逐人确认或多选批量设置。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。</p></div></section>${data.exams.map(item => {
const status = item.qualificationStatus;
const bulk = `<div class="qualification-bulk-toolbar" data-qualification-bulk data-exam-id="${h(item.examId)}"><label><input type="checkbox" data-action="qualification-select-all"><span>全选本页 ${h(status.total)} 名考生</span></label><div><strong data-qualification-selected-count>已选 0 人</strong><select data-bulk-eligible><option value="">批量设置资格</option><option value="true">设为有指标分配资格</option><option value="false">设为无指标分配资格</option></select><button class="solid-button" data-action="bulk-indicator-qualification">应用到所选考生</button></div></div>`;
const rows = status.rows.map(row => `<tr data-exam-id="${h(item.examId)}" data-user-id="${h(row.userId)}"><td><input type="checkbox" data-qualification-select aria-label="选择 ${h(row.name)}"></td><td><strong>${h(row.name)}</strong><small class="mono">${h(row.registrationNumber)}</small></td><td>${h(row.specialtyLabel)}</td><td><select data-indicator-eligible><option value="" ${row.confirmed ? '' : 'selected'}>请选择</option><option value="true" ${row.confirmed && row.eligible ? 'selected' : ''}>有指标分配资格</option><option value="false" ${row.confirmed && !row.eligible ? 'selected' : ''}>无指标分配资格</option></select></td><td>${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}</td><td><button class="row-action primary" data-action="save-indicator-qualification">确认</button></td></tr>`).join('');
return `<section class="panel qualification-ledger"><header><div><span>${h(item.exam.code)}</span><h2>${h(item.exam.name)}</h2></div><strong class="${status.complete ? 'complete' : ''}">${h(status.confirmed)} / ${h(status.total)} 已确认</strong></header><div class="qualification-progress"><i style="width:${status.total ? Math.round(status.confirmed / status.total * 100) : 0}%"></i></div>${status.complete ? '<div class="qualification-publication-state">✓ 本校资格已全部确认,公开公示已自动发布</div>' : '<div class="qualification-publication-state pending">未全部确认前不会公开,请逐项核对。</div>'}${bulk}<div class="table-scroll"><table><thead><tr><th>选择</th><th>报名号 / 姓名</th><th>特长类型</th><th>指标分配资格</th><th>确认时间</th><th>保存</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">本校暂无资料已完善的在册考生</td></tr>'}</tbody></table></div></section>`;
}).join('')}`;
}
function adminNotices(notices) {
@@ -232,7 +237,7 @@ export function createAdminViews(context) {
}
function adminUsers(data) {
return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel"><div class="data-toolbar"><p>管理员层级决定可见范围和可执行操作;同一级可创建多名账号。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th></tr></thead><tbody>${data.admins.map(item => `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td></tr>`).join('')}</tbody></table></div></section>`;
return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel admin-account-ledger"><div class="data-toolbar"><p>管理员账户不物理删除;停用会立即结束现有会话,并完整保留审批和审计记录。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th><th>账户操作</th></tr></thead><tbody>${data.admins.map(item => { const self = item.id === state.user.id; return `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admin-password" data-id="${h(item.id)}" ${self ? 'disabled title="当前账号请到账户安全修改密码"' : ''}>重置密码</button><button class="row-action ${item.active ? 'danger' : 'primary'}" data-action="toggle-admin-account" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}" ${self ? 'disabled title="不能停用当前账号"' : ''}>${item.active ? '停用账户' : '重新启用'}</button></div></td></tr>`; }).join('')}</tbody></table></div></section>`;
}
function adminCenters(data) {
+3 -4
View File
@@ -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) {
+36 -14
View File
@@ -20,7 +20,7 @@ export function createPublicViews(context) {
}
function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#announcements" data-route="announcements">招生公示</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
}
function renderHome() {
@@ -32,27 +32,49 @@ export function createPublicViews(context) {
app.innerHTML = `${publicHeader()}<main class="public-main">
<section class="hero">
<div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-route="notice/${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div>
</section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
<section class="content-section disclosure-entry"><div><p class="overline">ADMISSION DISCLOSURE</p><h2>招生录取公开卷宗</h2><p>指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。</p></div><button class="solid-button large" data-route="announcements">进入招生公示页 ${icons.arrow}</button></section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>招生录取公示已纳入通知公告,可按类别统一查询。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-route="notice/${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div><button class="notice-archive-link" data-route="notices">查看全部通知与录取公示 ${icons.arrow}</button></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
}
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 `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
if (document.documentType === 'qualification') return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('')}</tbody></table></div>`;
if (document.documentType === 'admission') return `<p class="document-lead">报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('')}</tbody></table></div>`;
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
}
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()}<main class="public-main announcement-page"><section class="announcement-hero"><div><p class="overline">PUBLIC ADMISSION LEDGER</p><h1>招生录取<br><em>公开卷宗</em></h1><p>按考试留存资格确认、录取结果和分数线。报名号、姓名、总成绩与录取学校透明公开,证件及联系方式不在本页展示。</p></div><dl><div><dt>公开类别</dt><dd>${sections}</dd></div><div><dt>资格公示</dt><dd>${h(data.qualifications?.length || 0)}</dd></div><div><dt>录取公告</dt><dd>${h(data.admissions?.length || 0)}</dd></div></dl></section>
<nav class="announcement-index"><a href="#qualification-publications">01 指标资格</a><a href="#admission-publications">02 录取名单</a><a href="#cutoff-publications">03 录取分数线</a></nav>
<section class="content-section announcement-register" id="qualification-publications"><div class="section-heading"><div><p class="overline">QUALIFICATION REGISTER</p><h2>指标分配资格公示</h2></div><p>仅在生源校全部考生完成资格确认后自动发布。</p></div>${(data.qualifications || []).map(item => `<article class="panel announcement-sheet"><header><div><span>${formatDate(item.publishedAt, true)} · ${h(item.schoolName)}</span><h3>${h(item.examName)}</h3></div><strong>${h(item.rows.length)} 人</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${item.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('')}</tbody></table></div></article>`).join('') || '<div class="empty-state">暂无已完成全校确认的资格公示</div>'}</section>
<section class="content-section announcement-register" id="admission-publications"><div class="section-heading"><div><p class="overline">ADMISSION RESULTS</p><h2>最终录取名单</h2></div><p>录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。</p></div>${(data.admissions || []).map(item => `<article class="panel announcement-sheet"><header><div><span>${formatDate(item.publishedAt, true)}</span><h3>${h(item.examName)}</h3></div><strong>${h(item.rows.length)} 人录取</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${item.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('')}</tbody></table></div></article>`).join('') || '<div class="empty-state">暂无已完成的录取公告</div>'}</section>
<section class="content-section announcement-register" id="cutoff-publications"><div class="section-heading"><div><p class="overline">ADMISSION CUTOFFS</p><h2>录取分数线统计</h2></div><p>分数线为对应学校、招生类别最终录取考生的最低总成绩。</p></div>${(data.cutoffs || []).map(item => `<article class="panel announcement-sheet cutoff-sheet"><header><div><span>${formatDate(item.publishedAt, true)}</span><h3>${h(item.examName)}</h3></div><strong>${h(item.rows.length)} 条分数线</strong></header><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${item.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div></article>`).join('') || '<div class="empty-state">暂无已发布的录取分数线</div>'}</section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>招生公示数据由系统按业务状态自动生成</span></footer>`;
if (selected) {
app.innerHTML = `${publicHeader()}<main class="public-main notice-document-page"><div class="notice-breadcrumb"><button data-route="notices">通知公告</button><span>/</span><strong>${h(selected.subtype)}</strong></div><article class="notice-document"><header><span>${h(selected.category)} · ${h(selected.subtype)}</span><h1>${h(selected.title)}</h1><p>${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}</p></header><section>${renderDocumentBody(selected)}</section><footer><button class="ghost-button" data-route="notices">返回通知公告列表</button></footer></article></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>公开信息以本页面正式发布内容为准</span></footer>`;
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()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}></button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span></span></footer>`;
}
function renderHeroTicket(exam) {
@@ -61,7 +83,7 @@ export function createPublicViews(context) {
}
function renderNoticeRow(notice) {
return `<button class="notice-row" data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
return `<button class="notice-row" data-route="notice/${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
}
function renderPublicExam(exam) {
@@ -84,5 +106,5 @@ export function createPublicViews(context) {
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
}
return { brand, renderHome, renderAnnouncements, renderAuth };
return { brand, renderHome, renderNoticeCenter, renderAuth };
}
+2
View File
@@ -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,
+53 -7
View File
@@ -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);
+42
View File
@@ -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; } }
+17 -4
View File
@@ -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');