通知修正
This commit is contained in:
@@ -445,6 +445,7 @@ document.addEventListener('click', async event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'new-notice') { await openNoticeForm(); 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-exam') return openExamForm();
|
||||||
if (action === 'new-admin') return openAdminForm();
|
if (action === 'new-admin') return openAdminForm();
|
||||||
if (action === 'new-school') return openSchoolForm();
|
if (action === 'new-school') return openSchoolForm();
|
||||||
@@ -650,7 +651,12 @@ document.addEventListener('click', async event => {
|
|||||||
}
|
}
|
||||||
if (action === 'toggle-notice') {
|
if (action === 'toggle-notice') {
|
||||||
await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } });
|
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') {
|
if (action === 'status-filter') {
|
||||||
target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
|
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);
|
const body = formObject(form);
|
||||||
if (noticeEditor) body.content = noticeEditor.getData();
|
if (noticeEditor) body.content = noticeEditor.getData();
|
||||||
body.pinned = form.pinned.checked;
|
body.pinned = form.pinned.checked;
|
||||||
await api('/api/admin/notices', { method: 'POST', body });
|
const endpoint = body.id ? `/api/admin/notices/${body.id}` : '/api/admin/notices';
|
||||||
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', '公开首页状态已同步'); renderRoute();
|
await api(endpoint, { method: body.id ? 'PATCH' : 'POST', body });
|
||||||
|
closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', body.id ? '草稿内容已更新' : '公开首页状态已同步'); renderRoute();
|
||||||
} else if (kind === 'exam-form') {
|
} else if (kind === 'exam-form') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({
|
||||||
@@ -1177,8 +1184,12 @@ function openRegistrationReview(id) {
|
|||||||
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回报名审核</button><div><span>REGISTRATION DOSSIER</span><h1>${h(reg.candidate?.name)} · 报名审核</h1><p><b class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}</b><i></i>${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</p></div>${badge(reg.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section exam-context-hero"><header><span>考试</span><div><p class="exam-code">${h(reg.exam.code)}</p><h2>${h(reg.exam.name)}</h2></div></header><dl class="review-detail-grid"><div><dt>报名时间</dt><dd>${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(reg.exam.examStart, reg.exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(reg.exam.location || '待公布')}</dd></div><div><dt>本次报名</dt><dd><strong>${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}</strong></dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}</dd></div></dl></section><section class="review-section subject-review-section"><header><span>科目</span><div><h2>本次所报科目</h2><p>逐科核对日期、时间、满分和报名费用。</p></div><strong>${subjects.length} 科</strong></header><div class="review-subject-cards">${subjectCards || '<div class="review-empty-context"><strong>未选择任何科目</strong><p>该报名记录数据异常,不应通过审核。</p></div>'}</div><footer><span>费用合计</span><strong>${money(reg.amountDue || 0)}</strong></footer></section><section class="review-section"><header><span>考生</span><div><h2>考生资料摘要</h2><p>报名审核同时核验身份与学校范围。</p></div></header><dl class="review-detail-grid"><div><dt>姓名 / 性别</dt><dd>${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}</dd></div><div><dt>证件号码</dt><dd class="mono">${h(reg.candidate?.idNumber || '未填写')}</dd></div><div><dt>学校 / 班级</dt><dd>${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</dd></div><div><dt>联系电话</dt><dd>${h(reg.candidate?.phone || '未填写')}</dd></div></dl></section></div>${decision}</div>`);
|
setReviewSubpage(`<header class="review-subpage-header"><button class="review-back" data-action="close-modal">← 返回报名审核</button><div><span>REGISTRATION DOSSIER</span><h1>${h(reg.candidate?.name)} · 报名审核</h1><p><b class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}</b><i></i>${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</p></div>${badge(reg.status)}</header><div class="review-subpage-layout"><div class="review-subpage-main"><section class="review-section exam-context-hero"><header><span>考试</span><div><p class="exam-code">${h(reg.exam.code)}</p><h2>${h(reg.exam.name)}</h2></div></header><dl class="review-detail-grid"><div><dt>报名时间</dt><dd>${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(reg.exam.examStart, reg.exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(reg.exam.location || '待公布')}</dd></div><div><dt>本次报名</dt><dd><strong>${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}</strong></dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}</dd></div></dl></section><section class="review-section subject-review-section"><header><span>科目</span><div><h2>本次所报科目</h2><p>逐科核对日期、时间、满分和报名费用。</p></div><strong>${subjects.length} 科</strong></header><div class="review-subject-cards">${subjectCards || '<div class="review-empty-context"><strong>未选择任何科目</strong><p>该报名记录数据异常,不应通过审核。</p></div>'}</div><footer><span>费用合计</span><strong>${money(reg.amountDue || 0)}</strong></footer></section><section class="review-section"><header><span>考生</span><div><h2>考生资料摘要</h2><p>报名审核同时核验身份与学校范围。</p></div></header><dl class="review-detail-grid"><div><dt>姓名 / 性别</dt><dd>${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}</dd></div><div><dt>证件号码</dt><dd class="mono">${h(reg.candidate?.idNumber || '未填写')}</dd></div><div><dt>学校 / 班级</dt><dd>${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}</dd></div><div><dt>联系电话</dt><dd>${h(reg.candidate?.phone || '未填写')}</dd></div></dl></section></div>${decision}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNoticeForm() {
|
async function openNoticeForm(notice = null) {
|
||||||
setModal(`<div class="modal-head"><div><span>NEW NOTICE</span><h2>发布通知公告</h2><p>发布后将立即显示在公开首页和考生中心。</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form"><div class="field-row"><label><span>通知分类</span><select name="category"><option>报名通知</option><option>考试须知</option><option>考点公告</option><option>成绩通知</option><option>系统公告</option></select></label><label><span>发布方式</span><select name="status"><option value="published">立即发布</option><option value="draft">保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容"></textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned"><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">保存通知</button></div></form>`);
|
if (notice === undefined) return toast('草稿不存在', '请刷新页面后重试');
|
||||||
|
const editing = Boolean(notice);
|
||||||
|
const categories = ['报名通知', '考试须知', '考点公告', '成绩通知', '系统公告'];
|
||||||
|
if (notice?.category && !categories.includes(notice.category)) categories.push(notice.category);
|
||||||
|
setModal(`<div class="modal-head"><div><span>${editing ? 'EDIT DRAFT' : 'NEW NOTICE'}</span><h2>${editing ? '编辑通知草稿' : '发布通知公告'}</h2><p>${editing ? '修改后可继续保存草稿,也可直接发布。' : '发布后将立即显示在公开首页和考生中心。'}</p></div><button data-action="close-modal">×</button></div><form class="modal-form notice-editor-form" data-form="notice-form">${editing ? `<input type="hidden" name="id" value="${h(notice.id)}">` : ''}<div class="field-row"><label><span>通知分类</span><select name="category">${categories.map(category => `<option ${category === notice?.category ? 'selected' : ''}>${h(category)}</option>`).join('')}</select></label><label><span>发布方式</span><select name="status"><option value="published" ${notice?.status === 'published' ? 'selected' : ''}>立即发布</option><option value="draft" ${!notice || notice.status === 'draft' ? 'selected' : ''}>保存草稿</option></select></label></div><label><span>通知标题 *</span><input name="title" required placeholder="清楚说明通知事项" value="${h(notice?.title || '')}"></label><label><span>首页摘要</span><input name="summary" placeholder="用于首页列表展示;留空将从正文自动提取" value="${h(notice?.summary || '')}"></label><div class="notice-editor-field"><span>通知正文 *</span><textarea name="content" rows="10" data-notice-editor placeholder="请输入完整通知内容">${h(notice?.content || '')}</textarea><small>图片请点工具栏“通过 URL 插入图片”并填写专用服务器地址;普通文件仍使用超链接。</small></div><label class="agreement"><input type="checkbox" name="pinned" ${notice?.pinned ? 'checked' : ''}><span>在公开首页置顶展示</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${editing ? '保存修改' : '保存通知'}</button></div></form>`);
|
||||||
const source = modalRoot.querySelector('[data-notice-editor]');
|
const source = modalRoot.querySelector('[data-notice-editor]');
|
||||||
try {
|
try {
|
||||||
const [ckeditor, translation] = await loadCKEditor();
|
const [ckeditor, translation] = await loadCKEditor();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function createAdminViews(context) {
|
|||||||
schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'],
|
schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'],
|
||||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||||
payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', '查看、导出并修改当前管理范围内考生的考试缴费状态。'],
|
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' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'],
|
results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'],
|
||||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||||
@@ -59,7 +59,7 @@ export function createAdminViews(context) {
|
|||||||
state.pageData = data;
|
state.pageData = data;
|
||||||
const content = {
|
const content = {
|
||||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data),
|
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),
|
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)
|
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), admissions: () => adminAdmissionsV2(data), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data)
|
||||||
}[page]();
|
}[page]();
|
||||||
@@ -212,8 +212,10 @@ export function createAdminViews(context) {
|
|||||||
}).join('')}`;
|
}).join('')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminNotices(notices) {
|
function adminNotices(notices, publications = []) {
|
||||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题、摘要或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="published">已发布</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button></div><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-filter-row data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
const noticeRows = notices.map(notice => `<tr data-filter-row data-status="ordinary ${h(notice.status)} ${notice.status === 'published' ? 'visible' : ''}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td><strong>手动通知</strong><small>${h(notice.category)}</small></td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : notice.status === 'published' ? '通知目录' : '尚未展示'}</td><td>${badge(notice.status)}</td><td><div class="notice-row-actions">${notice.status === 'draft' ? `<button class="row-action primary" data-action="edit-notice" data-id="${h(notice.id)}">编辑</button>` : ''}<button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '隐藏' : '发布'}</button></div></td></tr>`).join('');
|
||||||
|
const publicationRows = publications.map(item => `<tr data-filter-row data-status="system ${h(item.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>自动公示</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishedAt,true)}</td><td>通知目录</td><td>${badge(item.status)}</td><td><button class="row-action ${item.visible ? '' : 'primary'}" data-action="toggle-publication" data-id="${h(item.id)}" data-source-type="${h(item.sourceType)}" data-visible="${item.visible ? 'false' : 'true'}">${item.visible ? '隐藏' : '显示'}</button></td></tr>`).join('');
|
||||||
|
return `<section class="panel data-panel"><div class="data-toolbar notice-admin-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知、公示标题或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="visible">正在显示</button><button data-action="status-filter" data-target="noticeTable" data-status="hidden">已隐藏</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="noticeTable" data-status="system">自动公示</button></div><p>自动公示的内容由招生录取流程生成,这里只控制是否公开显示。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>标题</th><th>来源 / 分类</th><th>发布人</th><th>发布时间</th><th>展示位置</th><th>状态</th><th>操作</th></tr></thead><tbody>${noticeRows}${publicationRows || (!noticeRows ? '<tr><td colspan="7" class="empty-state">暂无通知或系统公示</td></tr>' : '')}</tbody></table></div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminAdmit(data) {
|
function adminAdmit(data) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
export const statusLabels = {
|
export const statusLabels = {
|
||||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||||
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
|
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||||
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
||||||
|
|||||||
@@ -120,6 +120,56 @@ export function createAdminRoutes(context) {
|
|||||||
})).filter(item => item.code && item.name && item.quota > 0);
|
})).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) {
|
async function handleAdmin(request, response, pathname) {
|
||||||
if (!pathname.startsWith('/api/admin/')) return false;
|
if (!pathname.startsWith('/api/admin/')) return false;
|
||||||
const user = await requireUser(request, response, 'admin');
|
const user = await requireUser(request, response, 'admin');
|
||||||
@@ -176,7 +226,7 @@ export function createAdminRoutes(context) {
|
|||||||
if (status.complete) {
|
if (status.complete) {
|
||||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
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 };
|
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);
|
records.push(publication);
|
||||||
}
|
}
|
||||||
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||||
@@ -203,7 +253,7 @@ export function createAdminRoutes(context) {
|
|||||||
if (status.complete) {
|
if (status.complete) {
|
||||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
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 };
|
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);
|
records.push(publication);
|
||||||
}
|
}
|
||||||
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
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, '已经产生投档记录,不能再修改该校本轮招生计划');
|
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
|
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 database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
|
||||||
await cache.invalidate('public');
|
await cache.invalidate('public');
|
||||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||||
@@ -344,7 +394,7 @@ export function createAdminRoutes(context) {
|
|||||||
const cutoffRows = admissionCutoffRows(completedDb, setting.examId);
|
const cutoffRows = admissionCutoffRows(completedDb, setting.examId);
|
||||||
const existingCutoff = admissionRecords(db, 'cutoff_publication', setting.examId)[0];
|
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 };
|
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} 人`));
|
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 });
|
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
|
const notices = db.notices
|
||||||
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
|
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
|
||||||
.map(noticeForClient);
|
.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 (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
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 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);
|
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
||||||
await database.createNotice(notice, log);
|
await database.createNotice(notice, log);
|
||||||
|
await cache.invalidate('public');
|
||||||
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
|
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\/([^/]+)$/);
|
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && noticeMatch) {
|
if (request.method === 'PATCH' && noticeMatch) {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
@@ -1360,6 +1426,7 @@ export function createAdminRoutes(context) {
|
|||||||
}
|
}
|
||||||
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||||
await database.updateNotice(notice, log);
|
await database.updateNotice(notice, log);
|
||||||
|
await cache.invalidate('public');
|
||||||
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function createPublicRoutes(context) {
|
|||||||
if (pathname === '/api/public/announcements') {
|
if (pathname === '/api/public/announcements') {
|
||||||
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
||||||
const db = await readDb();
|
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,
|
id: item.id,
|
||||||
examId: item.examId,
|
examId: item.examId,
|
||||||
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
|
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));
|
})).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,
|
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 || '普通生' }))
|
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));
|
})).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 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' && 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 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 { ok: true, plans, qualifications, admissions, cutoffs };
|
||||||
});
|
});
|
||||||
return sendJson(response, 200, payload);
|
return sendJson(response, 200, payload);
|
||||||
|
|||||||
+3
-2
@@ -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; }
|
.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 { 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::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-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; }
|
||||||
.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; }
|
.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; }
|
.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; }
|
||||||
|
|
||||||
/* Public site */
|
/* Public site */
|
||||||
@@ -254,6 +254,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
/* Admin */
|
/* 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; }
|
.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; }
|
.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; }
|
.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; }
|
.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); }
|
.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
|
||||||
|
|||||||
@@ -604,6 +604,15 @@ try {
|
|||||||
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
|
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
|
||||||
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
|
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
|
||||||
assert.deepEqual(publicPlan.rows.map(item => item.quota), [20, 4], '招生计划公示应保留各类别计划人数');
|
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 classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
|
||||||
assert.equal((await admin.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');
|
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
|
||||||
@@ -946,6 +955,14 @@ try {
|
|||||||
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
|
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
|
||||||
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
|
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
|
||||||
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
||||||
|
const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '<p>初始草稿内容</p>', 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: '<p>修改后的草稿内容</p>', 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 refreshedHome = await anonymous.request('/api/public/home');
|
||||||
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
||||||
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
||||||
|
|||||||
Reference in New Issue
Block a user