diff --git a/app.js b/app.js
index f4c7bdb..f79d55a 100644
--- a/app.js
+++ b/app.js
@@ -445,6 +445,7 @@ document.addEventListener('click', async event => {
return;
}
if (action === 'new-notice') { await openNoticeForm(); return; }
+ if (action === 'edit-notice') { await openNoticeForm(state.pageData.notices.find(item => item.id === target.dataset.id)); return; }
if (action === 'new-exam') return openExamForm();
if (action === 'new-admin') return openAdminForm();
if (action === 'new-school') return openSchoolForm();
@@ -650,7 +651,12 @@ document.addEventListener('click', async event => {
}
if (action === 'toggle-notice') {
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
- toast(target.dataset.status === 'published' ? '通知已发布' : '通知已撤回', '首页展示状态已更新'); return refreshPublic().then(renderRoute);
+ toast(target.dataset.status === 'published' ? '通知已发布' : '通知已隐藏', '公开展示状态已更新'); return refreshPublic().then(renderRoute);
+ }
+ if (action === 'toggle-publication') {
+ const visible = target.dataset.visible === 'true';
+ await api(`/api/admin/publications/${target.dataset.sourceType}/${target.dataset.id}`, { method: 'PATCH', body: { visible } });
+ toast(visible ? '系统公示已显示' : '系统公示已隐藏', '只调整公开目录展示,不修改公示内容'); return refreshPublic().then(renderRoute);
}
if (action === 'status-filter') {
target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
@@ -1017,8 +1023,9 @@ document.addEventListener('submit', async event => {
const body = formObject(form);
if (noticeEditor) body.content = noticeEditor.getData();
body.pinned = form.pinned.checked;
- await api('/api/admin/notices', { method: 'POST', body });
- closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute();
+ const endpoint = body.id ? `/api/admin/notices/${body.id}` : '/api/admin/notices';
+ await api(endpoint, { method: body.id ? 'PATCH' : 'POST', body });
+ closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', body.id ? '草稿内容已更新' : '公开首页状态已同步'); renderRoute();
} else if (kind === 'exam-form') {
const body = formObject(form);
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
@@ -1177,8 +1184,12 @@ function openRegistrationReview(id) {
setReviewSubpage(`
考试${h(reg.exam.code)}
${h(reg.exam.name)}
- 报名时间
- ${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}
- 考试时间
- ${dateRange(reg.exam.examStart, reg.exam.examEnd)}
- 考试地点
- ${h(reg.exam.location || '待公布')}
- 本次报名
- ${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}
- 缴费状态
- ${badge(reg.paymentStatus)}
- 账户报名号
- ${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}
- 姓名 / 性别
- ${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}
- 证件号码
- ${h(reg.candidate?.idNumber || '未填写')}
- 学校 / 班级
- ${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}
- 联系电话
- ${h(reg.candidate?.phone || '未填写')}
${decision}
`);
}
-async function openNoticeForm() {
- setModal(`NEW NOTICE发布通知公告
发布后将立即显示在公开首页和考生中心。
`);
+async function openNoticeForm(notice = null) {
+ if (notice === undefined) return toast('草稿不存在', '请刷新页面后重试');
+ const editing = Boolean(notice);
+ const categories = ['报名通知', '考试须知', '考点公告', '成绩通知', '系统公告'];
+ if (notice?.category && !categories.includes(notice.category)) categories.push(notice.category);
+ setModal(`${editing ? 'EDIT DRAFT' : 'NEW NOTICE'}${editing ? '编辑通知草稿' : '发布通知公告'}
${editing ? '修改后可继续保存草稿,也可直接发布。' : '发布后将立即显示在公开首页和考生中心。'}
`);
const source = modalRoot.querySelector('[data-notice-editor]');
try {
const [ckeditor, translation] = await loadCKEditor();
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index ea8b310..330b135 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -37,7 +37,7 @@ export function createAdminViews(context) {
schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'],
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', '查看、导出并修改当前管理范围内考生的考试缴费状态。'],
- notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
+ notices: ['通知发布', '编辑通知草稿,并统一控制手动通知与系统公示是否公开显示。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'],
results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'],
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
@@ -59,7 +59,7 @@ export function createAdminViews(context) {
state.pageData = data;
const content = {
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
- exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
+ exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices, data.publications), admit: () => adminAdmit(data), results: () => adminResults(data),
schools: () => adminSchoolsV2(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data)
}[page]();
@@ -212,8 +212,10 @@ export function createAdminViews(context) {
}).join('')}`;
}
- function adminNotices(notices) {
- return ``;
+ function adminNotices(notices, publications = []) {
+ const noticeRows = notices.map(notice => `| ${h(notice.title)}${h(notice.summary)} | 手动通知${h(notice.category)} | ${h(notice.author)} | ${formatDate(notice.publishAt || notice.createdAt,true)} | ${notice.pinned ? '首页置顶' : notice.status === 'published' ? '通知目录' : '尚未展示'} | ${badge(notice.status)} | ${notice.status === 'draft' ? `` : ''} |
`).join('');
+ const publicationRows = publications.map(item => `| ${h(item.title)}${h(item.summary)} | 自动公示${h(item.category)} | ${h(item.author)} | ${formatDate(item.publishedAt,true)} | 通知目录 | ${badge(item.status)} | |
`).join('');
+ return ``;
}
function adminAdmit(data) {
diff --git a/src/client/ui.mjs b/src/client/ui.mjs
index 937581c..1bc614d 100644
--- a/src/client/ui.mjs
+++ b/src/client/ui.mjs
@@ -1,6 +1,6 @@
export const statusLabels = {
pending: '待审核', approved: '已通过', rejected: '需修改',
- published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
+ published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
super: '超级管理员', school: '校级管理员', class: '班级管理员'
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs
index 46d4caa..dbb83e6 100644
--- a/src/routes/admin.routes.mjs
+++ b/src/routes/admin.routes.mjs
@@ -120,6 +120,56 @@ export function createAdminRoutes(context) {
})).filter(item => item.code && item.name && item.quota > 0);
}
+ function systemPublications(db) {
+ const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
+ const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
+ const view = (record, sourceType, category, title, publishedAt, summary) => ({
+ id: record.id,
+ sourceType,
+ category,
+ title,
+ summary,
+ author: '系统自动发布',
+ publishedAt,
+ visible: record.payload?.publicVisible !== false,
+ status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
+ });
+ const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => view(
+ item,
+ 'plan',
+ '招生计划',
+ `${examName(item.examId)} · ${schoolName(item.schoolId)}招生计划公示`,
+ item.payload?.reviewedAt || item.updatedAt,
+ '审核通过后由系统生成,当前页面仅控制是否在公开通知目录显示。'
+ ));
+ const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published').map(item => view(
+ item,
+ 'qualification',
+ '指标资格',
+ `${examName(item.examId)} · ${schoolName(item.schoolId)}指标分配资格公示`,
+ item.payload?.publishedAt || item.updatedAt,
+ '资格确认完成后由系统生成,内容随资格确认结果更新。'
+ ));
+ const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(item => view(
+ item,
+ 'admission',
+ '录取名单',
+ `${examName(item.examId)}最终录取名单`,
+ item.payload?.completedAt || item.updatedAt,
+ '录取结束后由系统生成,内容取自最终录取结果。'
+ ));
+ const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
+ item,
+ 'cutoff',
+ '录取分数线',
+ `${examName(item.examId)}录取分数线`,
+ item.payload?.publishedAt || item.updatedAt,
+ '录取结束后由系统生成,内容取自各招生类别最低录取分数。'
+ ));
+ return [...plans, ...qualifications, ...admissions, ...cutoffs]
+ .sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
+ }
+
async function handleAdmin(request, response, pathname) {
if (!pathname.startsWith('/api/admin/')) return false;
const user = await requireUser(request, response, 'admin');
@@ -176,7 +226,7 @@ export function createAdminRoutes(context) {
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 } });
+ Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
records.push(publication);
}
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
@@ -203,7 +253,7 @@ export function createAdminRoutes(context) {
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 } });
+ Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
records.push(publication);
}
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
@@ -301,7 +351,7 @@ export function createAdminRoutes(context) {
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
const now = nowIso();
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
- Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
+ Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { ...plan.payload, categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
await cache.invalidate('public');
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
@@ -344,7 +394,7 @@ export function createAdminRoutes(context) {
const cutoffRows = admissionCutoffRows(completedDb, setting.examId);
const existingCutoff = admissionRecords(db, 'cutoff_publication', setting.examId)[0];
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
- Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: cutoffRows } });
+ Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { ...cutoffPublication.payload, publishedAt: now, rows: cutoffRows } });
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, cutoffPublication], logAction(db, user, '结束录取并发布结果与分数线', `${setting.examId} · ${admitted.length} 人`));
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows(completedDb, setting.examId), cutoffRows });
}
@@ -1327,7 +1377,7 @@ export function createAdminRoutes(context) {
const notices = db.notices
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
.map(noticeForClient);
- return sendJson(response, 200, { ok: true, notices });
+ return sendJson(response, 200, { ok: true, notices, publications: systemPublications(db) });
}
if (request.method === 'POST' && pathname === '/api/admin/notices') {
if (!requirePermission(user, response, '*')) return true;
@@ -1339,8 +1389,24 @@ export function createAdminRoutes(context) {
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
await database.createNotice(notice, log);
+ await cache.invalidate('public');
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
}
+ const publicationMatch = pathname.match(/^\/api\/admin\/publications\/(plan|qualification|admission|cutoff)\/([^/]+)$/);
+ if (request.method === 'PATCH' && publicationMatch) {
+ if (!requirePermission(user, response, '*')) return true;
+ const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication' };
+ const sourceType = publicationMatch[1];
+ const record = admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2]);
+ if (!record) return sendError(response, 404, '系统公示不存在');
+ const body = await readJson(request);
+ if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示');
+ record.payload = { ...record.payload, publicVisible: body.visible };
+ record.updatedAt = nowIso();
+ await database.saveAdmissionRecord(record, logAction(db, user, body.visible ? '显示系统公示' : '隐藏系统公示', `${sourceType} · ${record.id}`));
+ await cache.invalidate('public');
+ return sendJson(response, 200, { ok: true, publication: systemPublications({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }).find(item => item.id === record.id && item.sourceType === sourceType) });
+ }
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
if (request.method === 'PATCH' && noticeMatch) {
if (!requirePermission(user, response, '*')) return true;
@@ -1360,6 +1426,7 @@ export function createAdminRoutes(context) {
}
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
await database.updateNotice(notice, log);
+ await cache.invalidate('public');
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
}
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs
index 539825a..b694d8c 100644
--- a/src/routes/public.routes.mjs
+++ b/src/routes/public.routes.mjs
@@ -66,7 +66,7 @@ export function createPublicRoutes(context) {
if (pathname === '/api/public/announcements') {
const payload = await cache.remember('public', 'admission-announcements', async () => {
const db = await readDb();
- const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => ({
+ const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved' && item.payload?.publicVisible !== false).map(item => ({
id: item.id,
examId: item.examId,
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
@@ -87,12 +87,12 @@ export function createPublicRoutes(context) {
}))
}))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
- const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
+ const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
- const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
- const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
+ const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
+ const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
return { ok: true, plans, qualifications, admissions, cutoffs };
});
return sendJson(response, 200, payload);
diff --git a/styles.css b/styles.css
index af4556e..b8e0019 100644
--- a/styles.css
+++ b/styles.css
@@ -152,10 +152,10 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.overline { margin: 0; color: #8c96ae; font-family: Consolas, ui-monospace, monospace; font-size: 10px; font-weight: 700; letter-spacing: 2px; }
.status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 6px; font-size: 10px; font-style: normal; font-weight: 700; white-space: nowrap; }
.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
-.status-open, .status-published, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
+.status-open, .status-published, .status-visible, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
.status-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; }
.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; }
-.status-draft { color: #6d7589; background: #eef0f4; }
+.status-draft, .status-hidden { color: #6d7589; background: #eef0f4; }
.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; }
/* Public site */
@@ -254,6 +254,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
/* Admin */
.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; }
.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; }
+.notice-admin-toolbar { flex-wrap:wrap; }.notice-admin-toolbar > p { flex:1 0 100%; }.notice-row-actions { display:flex; align-items:center; gap:8px; }
.registration-toolbar,.payment-toolbar { flex-wrap:wrap; }.table-filter-selects { flex:1 0 100%; display:grid; grid-template-columns:repeat(4,minmax(130px,1fr)); gap:8px; }.table-filter-selects select { width:100%; min-height:35px; padding:7px 10px; border:1px solid var(--line); border-radius:7px; color:#59647a; background:#fff; font-size:8px; }.registration-bulk-bar { min-height:54px; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:10px 17px; border-bottom:1px solid #dce4f1; background:#f4f7fc; }.registration-bulk-bar > span { color:#6e788d; font-size:8px; }.registration-bulk-bar > span strong { margin:0 3px; color:var(--navy); font-size:13px; }.registration-bulk-bar > div { display:flex; gap:7px; }.registration-bulk-bar .row-action { padding:7px 10px; border:1px solid #ccd6e7; border-radius:7px; background:#fff; }.registration-bulk-bar .row-action.primary { border-color:var(--navy); background:var(--navy); }.registration-bulk-bar .row-action:disabled { border-color:#e1e5ec; color:#aab1bf; background:#f8f9fb; }.selection-cell { width:42px; padding-right:8px; text-align:center; }.selection-cell input { width:15px; height:15px; accent-color:var(--navy); }.row-action.danger { color:#a64b48; }
.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; }
.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
diff --git a/tests/system.test.mjs b/tests/system.test.mjs
index a856377..5e61316 100644
--- a/tests/system.test.mjs
+++ b/tests/system.test.mjs
@@ -604,6 +604,15 @@ try {
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
assert.deepEqual(publicPlan.rows.map(item => item.quota), [20, 4], '招生计划公示应保留各类别计划人数');
+ const noticeControlList = await admin.request('/api/admin/notices');
+ const controlledPlan = noticeControlList.data.publications.find(item => item.sourceType === 'plan' && item.id === structuredPlan.data.plan.id);
+ assert.ok(controlledPlan?.visible, '自动发布的招生计划应出现在通知发布管理页并默认显示');
+ assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: false } })).response.status, 200, '通知发布管理页应能隐藏系统招生计划公示');
+ const hiddenPlanAnnouncements = await anonymous.request('/api/public/announcements');
+ assert.equal(hiddenPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), false, '隐藏系统公示后公开通知目录不得继续返回该招生计划');
+ assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: true } })).response.status, 200, '已隐藏的系统招生计划公示应能重新显示');
+ const restoredPlanAnnouncements = await anonymous.request('/api/public/announcements');
+ assert.ok(restoredPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), '重新显示后招生计划应恢复到公开通知目录');
assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
@@ -946,6 +955,14 @@ try {
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '', status: 'draft' } });
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
+ const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '初始草稿内容
', status: 'draft' } });
+ assert.equal(draftNotice.response.status, 201, '管理员应能保存通知草稿');
+ const editedDraftNotice = await admin.request(`/api/admin/notices/${draftNotice.data.notice.id}`, { method: 'PATCH', body: { title: '已编辑草稿', content: '修改后的草稿内容
', status: 'draft' } });
+ assert.equal(editedDraftNotice.response.status, 200, '草稿编辑入口应通过原通知修改接口保存');
+ assert.equal(editedDraftNotice.data.notice.title, '已编辑草稿');
+ const noticeListAfterEdit = await admin.request('/api/admin/notices');
+ assert.equal(noticeListAfterEdit.data.notices.filter(item => item.id === draftNotice.data.notice.id).length, 1, '编辑草稿不得重复新建通知');
+ assert.match(noticeListAfterEdit.data.notices.find(item => item.id === draftNotice.data.notice.id).contentHtml, /修改后的草稿内容/, '草稿正文修改后应正确回填到管理接口');
const refreshedHome = await anonymous.request('/api/public/home');
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');