From fe5fcc9dc22e0dbc04b0f31dd737b890f8c7bcea Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 21 Jul 2026 16:56:58 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 21 +++++++--- src/client/admin-views.mjs | 10 +++-- src/client/ui.mjs | 2 +- src/routes/admin.routes.mjs | 77 +++++++++++++++++++++++++++++++++--- src/routes/public.routes.mjs | 8 ++-- styles.css | 5 ++- tests/system.test.mjs | 17 ++++++++ 7 files changed, 119 insertions(+), 21 deletions(-) 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(`
REGISTRATION DOSSIER

${h(reg.candidate?.name)} · 报名审核

${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}

${badge(reg.status)}
考试

${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 || '待同步')}
科目

本次所报科目

逐科核对日期、时间、满分和报名费用。

${subjects.length} 科
${subjectCards || '
未选择任何科目

该报名记录数据异常,不应通过审核。

'}
费用合计${money(reg.amountDue || 0)}
考生

考生资料摘要

报名审核同时核验身份与学校范围。

姓名 / 性别
${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(``); +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(``); 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 `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; + 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 `

自动公示的内容由招生录取流程生成,这里只控制是否公开显示。

${noticeRows}${publicationRows || (!noticeRows ? '' : '')}
标题来源 / 分类发布人发布时间展示位置状态操作
暂无通知或系统公示
`; } 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, '管理员发布通知后首页应可见');