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(`${h(notice.category)}${h(notice.title)}
${formatDate(notice.publishAt,true)} · ${h(notice.author)}
${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(`ADMIN PASSWORD RESET管理员临时密码已生成
请通过线下安全渠道交给管理员,本页面关闭后不再展示。
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}旧密码和现有登录会话均已失效。
`);
+ return;
+ }
if (action === 'add-admission-category') {
const sources = state.pageData?.sourceSchools || [];
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
@@ -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 `
${status.complete ? '✓ 本校资格已全部确认,公开公示已自动发布
' : '未全部确认前不会公开,请逐项核对。
'}`; }).join('')}`;
+ return `SOURCE SCHOOL CERTIFICATION${h(data.school?.name)}资格确认簿
逐人确认或多选批量设置。每场考试全部确认后立即自动公示,后续修改也会同步更新公示。
${data.exams.map(item => {
+ const status = item.qualificationStatus;
+ const bulk = ``;
+ const rows = status.rows.map(row => ` | ${h(row.name)}${h(row.registrationNumber)} | ${h(row.specialtyLabel)} | | ${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'} | |
`).join('');
+ return `
${status.complete ? '✓ 本校资格已全部确认,公开公示已自动发布
' : '未全部确认前不会公开,请逐项核对。
'}${bulk}`;
+ }).join('')}`;
}
function adminNotices(notices) {
@@ -232,7 +237,7 @@ export function createAdminViews(context) {
}
function adminUsers(data) {
- return `SELF REGISTRATION考生自主注册
${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}
`;
+ return `SELF REGISTRATION考生自主注册
${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}
`;
}
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 ``;
+ return ``;
}
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) : '
暂无开放考试
'}
- 报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。
${topNotice ? `${h(topNotice.category)}${h(topNotice.title)}
${h(topNotice.summary)}
` : '暂无通知
'}${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
- ADMISSION DISCLOSURE
招生录取公开卷宗
指标分配资格、最终录取名单和录取分数线集中公开,所有重要身份信息均按规则脱敏。
+ ${topNotice ? `${h(topNotice.category)}${h(topNotice.title)}
${h(topNotice.summary)}
` : '暂无通知
'}${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
报名号不会随考试改变,每场考试只新增一条报名记录。
${[['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 `本公示由生源校完成全部考生资格确认后自动生成。
`;
+ if (document.documentType === 'admission') return `报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。
`;
+ return `录取分数线为对应学校、招生类别最终录取考生的最低总成绩。
`;
+ }
+
+ 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 => ``).join('') || '暂无已完成全校确认的资格公示
'}
- 录取完成后自动公告,报名号、姓名、总成绩与录取学校公开透明。
${(data.admissions || []).map(item => ``).join('') || '暂无已完成的录取公告
'}
- 分数线为对应学校、招生类别最终录取考生的最低总成绩。
${(data.cutoffs || []).map(item => ``).join('') || '暂无已发布的录取分数线
'}
- `;
+ if (selected) {
+ app.innerHTML = `${publicHeader()}/${h(selected.subtype)}
${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)}份公开文件${pageRows.map(item => `
`).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');