Expand application functionality
This commit is contained in:
@@ -9,6 +9,8 @@ import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './
|
||||
const app = document.querySelector('#app');
|
||||
const modalRoot = document.querySelector('#modalRoot');
|
||||
let toastTimer;
|
||||
let noticeEditor;
|
||||
let ckeditorModulePromise;
|
||||
function toast(title, message = '') {
|
||||
const element = document.querySelector('#toast');
|
||||
element.querySelector('strong').textContent = title;
|
||||
@@ -24,9 +26,27 @@ function setModal(content) {
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const editor = noticeEditor;
|
||||
noticeEditor = null;
|
||||
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
|
||||
function loadCKEditor() {
|
||||
if (!document.querySelector('link[data-ckeditor-styles]')) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.rel = 'stylesheet';
|
||||
stylesheet.href = '/vendor/ckeditor5/ckeditor5.css';
|
||||
stylesheet.dataset.ckeditorStyles = '';
|
||||
document.head.append(stylesheet);
|
||||
}
|
||||
ckeditorModulePromise ||= Promise.all([
|
||||
import('/vendor/ckeditor5/ckeditor5.js'),
|
||||
import('/vendor/ckeditor5/translations/zh-cn.js')
|
||||
]);
|
||||
return ckeditorModulePromise;
|
||||
}
|
||||
|
||||
function emptyState(title, description, route, action) {
|
||||
return `<section class="panel empty-panel"><span>${icons.ticket}</span><h2>${h(title)}</h2><p>${h(description)}</p>${route ? `<button class="solid-button" data-route="${h(route)}">${h(action)}</button>` : ''}</section>`;
|
||||
}
|
||||
@@ -108,7 +128,8 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
if (action === 'open-notice') {
|
||||
const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice;
|
||||
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content"><p>${h(notice.content).replace(/\n/g, '</p><p>')}</p></article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
|
||||
const contentHtml = notice.contentHtml || `<p>${h(notice.content).replace(/\r?\n/g, '</p><p>')}</p>`;
|
||||
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
|
||||
}
|
||||
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
||||
if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
|
||||
@@ -119,7 +140,7 @@ document.addEventListener('click', async event => {
|
||||
window.location.href = `/api/admin/admission-exports/${type}?examId=${encodeURIComponent(examId)}`;
|
||||
return;
|
||||
}
|
||||
if (action === 'new-notice') return openNoticeForm();
|
||||
if (action === 'new-notice') { await openNoticeForm(); return; }
|
||||
if (action === 'new-exam') return openExamForm();
|
||||
if (action === 'new-admin') return openAdminForm();
|
||||
if (action === 'new-school-class') return openSchoolClassForm();
|
||||
@@ -484,7 +505,9 @@ document.addEventListener('submit', async event => {
|
||||
await api(`/api/admin/workflow-instances/${body.id}/supervise`, { method: 'PATCH', body });
|
||||
closeModal(); toast('流程已监督调整', '节点与责任人已更新并记录'); renderRoute();
|
||||
} else if (kind === 'notice-form') {
|
||||
const body = formObject(form); body.pinned = form.pinned.checked;
|
||||
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();
|
||||
} else if (kind === 'exam-form') {
|
||||
@@ -599,8 +622,50 @@ function openRegistrationReview(id) {
|
||||
setModal(`<div class="modal-head"><div><span>REGISTRATION REVIEW</span><h2>${canReview ? '处理' : '查看'}考试报名</h2><p>${h(reg.candidate?.name)} · ${h(reg.exam.name)}</p></div><button data-action="close-modal">×</button></div><div class="registration-review"><div><span>报考科目</span><p>${reg.subjects.map(subject => `<b>${h(subject.name)}</b>`).join('')}</p></div><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || reg.candidate?.candidateNumber || '账户号码异常')}</dd></div><div><dt>当前步骤</dt><dd>${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}</dd></div><div><dt>责任人</dt><dd>${h(reg.workflow?.assignee?.displayName || '—')}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}</dd></div></dl></div>${canReview ? `<form class="modal-form" data-form="registration-review"><input type="hidden" name="id" value="${h(reg.id)}"><label><span>审核结论</span><select name="status"><option value="approved">通过当前步骤</option><option value="rejected">退回报名</option></select></label><label><span>审核意见</span><textarea name="reviewNote" rows="3" placeholder="可填写审核说明">${h(reg.reviewNote || '')}</textarea></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>` : '<div class="modal-foot"><button class="solid-button" data-action="close-modal">关闭</button></div>'}`);
|
||||
}
|
||||
|
||||
function openNoticeForm() {
|
||||
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" 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="用于首页列表展示,建议 60 字以内"></label><label><span>通知正文 *</span><textarea name="content" rows="7" required placeholder="请输入完整通知内容"></textarea></label><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>`);
|
||||
async function openNoticeForm() {
|
||||
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>`);
|
||||
const source = modalRoot.querySelector('[data-notice-editor]');
|
||||
try {
|
||||
const [ckeditor, translation] = await loadCKEditor();
|
||||
const {
|
||||
ClassicEditor, AutoImage, AutoLink, BlockQuote, Bold, Essentials, Heading,
|
||||
Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar,
|
||||
Italic, Link, List, Paragraph, Table, TableCaption, TableToolbar, Underline
|
||||
} = ckeditor;
|
||||
if (!source?.isConnected) return;
|
||||
noticeEditor = await ClassicEditor.create(source, {
|
||||
licenseKey: 'GPL',
|
||||
language: 'zh-cn',
|
||||
translations: [translation.default],
|
||||
plugins: [
|
||||
Essentials, Paragraph, Heading, Bold, Italic, Underline, Link, AutoLink, List, BlockQuote,
|
||||
Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar, AutoImage,
|
||||
Table, TableToolbar, TableCaption
|
||||
],
|
||||
toolbar: {
|
||||
items: ['heading', '|', 'bold', 'italic', 'underline', '|', 'bulletedList', 'numberedList', 'blockQuote', '|', 'link', 'insertImageViaUrl', 'insertTable', '|', 'undo', 'redo'],
|
||||
shouldNotGroupWhenFull: true
|
||||
},
|
||||
heading: {
|
||||
options: [
|
||||
{ model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' },
|
||||
{ model: 'heading2', view: 'h2', title: '二级标题', class: 'ck-heading_heading2' },
|
||||
{ model: 'heading3', view: 'h3', title: '三级标题', class: 'ck-heading_heading3' }
|
||||
]
|
||||
},
|
||||
link: { defaultProtocol: 'https://', addTargetToExternalLinks: true },
|
||||
image: {
|
||||
toolbar: ['imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative']
|
||||
},
|
||||
table: {
|
||||
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', '|', 'toggleTableCaption']
|
||||
},
|
||||
placeholder: '请输入完整通知内容;图片使用 URL 插入,文件使用超链接'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('CKEditor failed to load', error);
|
||||
toast('富文本编辑器加载失败', '已保留普通文本输入,可检查网络后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function dateTimeLocal(value) {
|
||||
|
||||
Generated
+2793
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -9,8 +9,10 @@
|
||||
"reset-db": "node scripts/reset-dev-database.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ckeditor5": "^48.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"mysql2": "^3.14.2"
|
||||
"mysql2": "^3.14.2",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
"overrides": {
|
||||
"exceljs": {
|
||||
|
||||
+8
-2
@@ -36,6 +36,11 @@ const staticFiles = new Set([
|
||||
'/src/client/region-select.mjs',
|
||||
'/src/data/china-regions.mjs'
|
||||
]);
|
||||
const vendorStaticFiles = new Map([
|
||||
['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')],
|
||||
['/vendor/ckeditor5/ckeditor5.css', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.css')],
|
||||
['/vendor/ckeditor5/translations/zh-cn.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'translations', 'zh-cn.js')]
|
||||
]);
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
@@ -865,8 +870,9 @@ const handleAdmin = createAdminRoutes(routeContext);
|
||||
|
||||
async function serveStatic(response, pathname) {
|
||||
const requestPath = pathname === '/' ? '/index.html' : pathname;
|
||||
if (!staticFiles.has(requestPath)) return false;
|
||||
const filePath = normalize(join(root, requestPath.replace(/^\/+/, '')));
|
||||
const filePath = vendorStaticFiles.get(requestPath)
|
||||
|| (staticFiles.has(requestPath) ? normalize(join(root, requestPath.replace(/^\/+/, ''))) : null);
|
||||
if (!filePath) return false;
|
||||
const body = await readFile(filePath);
|
||||
response.writeHead(200, { 'Content-Type': mimeTypes[extname(filePath)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
||||
response.end(body);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
||||
|
||||
export function createAdminRoutes(context) {
|
||||
const {
|
||||
@@ -1001,18 +1002,22 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/notices') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
|
||||
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 });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const title = cleanText(body.title, 120);
|
||||
const content = cleanText(body.content, 5000);
|
||||
if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空');
|
||||
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.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 content = sanitizeNoticeContent(body.content);
|
||||
const contentText = noticePlainText(content);
|
||||
if (!title || !contentText) return sendError(response, 400, '通知标题和正文不能为空');
|
||||
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);
|
||||
return sendJson(response, 201, { ok: true, notice });
|
||||
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && noticeMatch) {
|
||||
@@ -1020,7 +1025,12 @@ export function createAdminRoutes(context) {
|
||||
const body = await readJson(request);
|
||||
const notice = db.notices.find(item => item.id === noticeMatch[1]);
|
||||
if (!notice) return sendError(response, 404, '通知不存在');
|
||||
['title', 'summary', 'content', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], field === 'content' ? 5000 : 260); });
|
||||
['title', 'summary', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], 260); });
|
||||
if (body.content != null) {
|
||||
const content = sanitizeNoticeContent(body.content);
|
||||
if (!noticePlainText(content)) return sendError(response, 400, '通知正文不能为空');
|
||||
notice.content = content;
|
||||
}
|
||||
if (body.pinned != null) notice.pinned = Boolean(body.pinned);
|
||||
if (body.status && ['draft', 'published'].includes(body.status)) {
|
||||
notice.status = body.status;
|
||||
@@ -1028,7 +1038,7 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||
await database.updateNotice(notice, log);
|
||||
return sendJson(response, 200, { ok: true, notice });
|
||||
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||
if (!requirePermission(user, response, 'results.read')) return true;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
|
||||
export function createCandidateRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
@@ -65,7 +67,7 @@ export function createCandidateRoutes(context) {
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
|
||||
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
||||
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
|
||||
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
|
||||
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5).map(noticeForClient);
|
||||
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
|
||||
export function createPublicRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
@@ -50,14 +52,14 @@ export function createPublicRoutes(context) {
|
||||
async function handlePublic(pathname, response) {
|
||||
const db = await readDb();
|
||||
if (pathname === '/api/public/home') {
|
||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
|
||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
||||
if (noticeMatch) {
|
||||
const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
||||
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||
return notice ? sendJson(response, 200, { ok: true, notice: noticeForClient(notice) }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const allowedTags = [
|
||||
'p', 'br', 'h2', 'h3', 'h4',
|
||||
'strong', 'em', 'u', 's',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a',
|
||||
'figure', 'figcaption', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
|
||||
];
|
||||
|
||||
const blockTags = /<\/?(?:p|h[2-4]|ul|ol|li|blockquote|br|figure|figcaption|table|thead|tbody|tfoot|tr|th|td)\b[^>]*>/gi;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function decodeTextEntities(value) {
|
||||
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ' };
|
||||
const codePoint = (code, radix) => {
|
||||
const parsed = Number.parseInt(code, radix);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0x10ffff && !(parsed >= 0xd800 && parsed <= 0xdfff)
|
||||
? String.fromCodePoint(parsed)
|
||||
: '�';
|
||||
};
|
||||
return String(value)
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) => codePoint(code, 16))
|
||||
.replace(/&#(\d+);/g, (_, code) => codePoint(code, 10))
|
||||
.replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/gi, (_, name) => named[name.toLowerCase()]);
|
||||
}
|
||||
|
||||
export function sanitizeNoticeContent(value) {
|
||||
const source = String(value ?? '').trim().slice(0, 20000);
|
||||
return sanitizeHtml(source, {
|
||||
allowedTags,
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target', 'rel'],
|
||||
figure: ['class'],
|
||||
img: ['src', 'alt'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
td: ['colspan', 'rowspan']
|
||||
},
|
||||
allowedClasses: {
|
||||
figure: [
|
||||
'image', 'table', 'image-style-inline', 'image-style-block', 'image-style-side',
|
||||
'image-style-align-left', 'image-style-align-right',
|
||||
'image-style-block-align-left', 'image-style-block-align-right'
|
||||
]
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a(tagName, attributes) {
|
||||
const safeAttributes = {};
|
||||
if (attributes.href) safeAttributes.href = attributes.href;
|
||||
if (attributes.target === '_blank') safeAttributes.target = '_blank';
|
||||
safeAttributes.rel = 'noopener noreferrer';
|
||||
return { tagName, attribs: safeAttributes };
|
||||
},
|
||||
img(tagName, attributes) {
|
||||
const safeAttributes = {};
|
||||
if (/^https?:\/\//i.test(attributes.src || '')) safeAttributes.src = attributes.src;
|
||||
if (attributes.alt) safeAttributes.alt = attributes.alt;
|
||||
return { tagName, attribs: safeAttributes };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function noticePlainText(value) {
|
||||
const sanitized = sanitizeNoticeContent(value).replace(blockTags, ' ');
|
||||
const withoutTags = sanitizeHtml(sanitized, { allowedTags: [], allowedAttributes: {} });
|
||||
return decodeTextEntities(withoutTags).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function noticeContentHtml(value) {
|
||||
const source = String(value ?? '').trim();
|
||||
if (!source) return '';
|
||||
if (!/<\/?(?:p|h[2-4]|strong|em|u|s|ul|ol|li|blockquote|a|br|figure|figcaption|img|table|thead|tbody|tfoot|tr|th|td)\b/i.test(source)) {
|
||||
return source
|
||||
.split(/\r?\n{2,}/)
|
||||
.map(paragraph => `<p>${escapeHtml(paragraph).replace(/\r?\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
return sanitizeNoticeContent(source);
|
||||
}
|
||||
|
||||
export function noticeForClient(notice) {
|
||||
return { ...notice, content: sanitizeNoticeContent(notice.content), contentHtml: noticeContentHtml(notice.content) };
|
||||
}
|
||||
+20
-1
@@ -265,8 +265,27 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.score-rule-hint { margin:0; padding:9px 11px; border-radius:7px; color:#6d7890; background:#f3f6fb; font-size:8px; }
|
||||
|
||||
/* Modals and feedback */
|
||||
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; }.notice-content p { margin:0 0 13px; color:#525d73; font-size:11px; line-height:2; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
|
||||
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
|
||||
.modal-card:has(.exam-config-form) { width:min(980px,100%); }
|
||||
.modal-card:has(.notice-editor-form) { width:min(820px,100%); }
|
||||
.notice-editor-field { display:grid; gap:7px; }
|
||||
.notice-editor-field > span { color:#555f75; font-size:10px; font-weight:700; }
|
||||
.notice-editor-field > small { color:#8b94a5; font-size:8px; font-weight:400; }
|
||||
.notice-editor-form .ck.ck-editor { width:100%; }
|
||||
.notice-editor-form .ck-editor__editable_inline { min-height:280px; max-height:430px; color:#333d52; font-size:13px; line-height:1.75; }
|
||||
.notice-editor-form .ck.ck-toolbar { border-color:#dce1ea; border-radius:8px 8px 0 0; background:#f8f9fc; }
|
||||
.notice-editor-form .ck.ck-editor__main > .ck-editor__editable { border-color:#dce1ea; border-radius:0 0 8px 8px; }
|
||||
.notice-content figure.image { margin:20px auto; text-align:center; }
|
||||
.notice-content figure.image img { display:block; width:auto; max-width:100%; height:auto; margin:auto; border-radius:7px; }
|
||||
.notice-content figure.image.image-style-side { max-width:50%; margin-left:auto; }
|
||||
.notice-content figure.image.image-style-align-left,.notice-content figure.image.image-style-block-align-left { margin-right:auto; margin-left:0; }
|
||||
.notice-content figure.image.image-style-align-right,.notice-content figure.image.image-style-block-align-right { margin-right:0; margin-left:auto; }
|
||||
.notice-content figcaption { margin-top:7px; color:#8a93a5; font-size:9px; line-height:1.6; text-align:center; }
|
||||
.notice-content figure.table { margin:18px 0; overflow-x:auto; }
|
||||
.notice-content table { width:100%; border-collapse:collapse; background:#fff; font-size:10px; }
|
||||
.notice-content th,.notice-content td { min-width:70px; padding:9px 11px; border:1px solid #d8dfeb; color:#525d73; line-height:1.7; text-align:left; vertical-align:top; }
|
||||
.notice-content th { color:var(--navy); background:#f1f4f9; font-weight:700; }
|
||||
.notice-content td p,.notice-content th p { margin:0; font-size:inherit; line-height:inherit; }
|
||||
.exam-config-form { gap:18px; }
|
||||
.exam-form-section { display:grid; gap:14px; padding:17px; border:1px solid var(--line); border-radius:11px; background:#fbfcfe; }
|
||||
.exam-form-section > header { display:flex; align-items:center; gap:11px; }
|
||||
|
||||
+25
-2
@@ -144,6 +144,11 @@ try {
|
||||
assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知');
|
||||
assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目');
|
||||
assert.equal(publicHome.data.selfRegistrationEnabled, false, '自主注册默认应关闭');
|
||||
const ckeditorAsset = await anonymous.request('/vendor/ckeditor5/ckeditor5.js');
|
||||
assert.equal(ckeditorAsset.response.status, 200, '服务端应提供自托管 CKEditor 浏览器包');
|
||||
assert.match(ckeditorAsset.data, /ImageInsertViaUrl/, '自托管 CKEditor 应包含图片 URL 插件');
|
||||
const ckeditorStyles = await anonymous.request('/vendor/ckeditor5/ckeditor5.css');
|
||||
assert.equal(ckeditorStyles.response.status, 200, '服务端应提供自托管 CKEditor 样式');
|
||||
|
||||
const closedRegister = await candidate.request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
@@ -605,10 +610,28 @@ try {
|
||||
const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`);
|
||||
assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载');
|
||||
|
||||
const publishNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '系统测试成绩发布通知', summary: '测试通知将同步到首页', content: '系统测试考试成绩现已发布。', category: '成绩通知', pinned: true, status: 'published' } });
|
||||
const publishNotice = await admin.request('/api/admin/notices', { method: 'POST', body: {
|
||||
title: '系统测试成绩发布通知',
|
||||
summary: '',
|
||||
content: '<h2>成绩发布</h2><p>系统测试考试成绩现已发布。<a href="https://files.example.com/results.pdf" target="_blank" onclick="alert(1)">下载说明</a></p><figure class="image"><img src="https://files.example.com/banner.png" alt="成绩发布横幅" onerror="alert(1)"><figcaption>成绩发布说明</figcaption></figure><figure class="table"><table><tbody><tr><th>科目</th><th>状态</th></tr><tr><td>语文</td><td>已发布</td></tr></tbody></table></figure><script>alert(1)</script>',
|
||||
category: '成绩通知',
|
||||
pinned: true,
|
||||
status: 'published'
|
||||
} });
|
||||
assert.equal(publishNotice.response.status, 201);
|
||||
assert.match(publishNotice.data.notice.contentHtml, /<h2>成绩发布<\/h2>/, '通知接口应保留 CKEditor 标题格式');
|
||||
assert.match(publishNotice.data.notice.contentHtml, /href="https:\/\/files\.example\.com\/results\.pdf"/, '通知接口应保留安全超链接');
|
||||
assert.match(publishNotice.data.notice.contentHtml, /rel="noopener noreferrer"/, '外部链接应带安全关系属性');
|
||||
assert.match(publishNotice.data.notice.contentHtml, /<img src="https:\/\/files\.example\.com\/banner\.png" alt="成绩发布横幅" \/>/, '通知接口应保留专用服务器图片链接');
|
||||
assert.match(publishNotice.data.notice.contentHtml, /<table>.*<th>科目<\/th>.*<td>语文<\/td>.*<\/table>/s, '通知接口应保留表格结构');
|
||||
assert.doesNotMatch(publishNotice.data.notice.contentHtml, /script|onclick|onerror/i, '通知富文本必须移除脚本和事件属性');
|
||||
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' } });
|
||||
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
||||
const refreshedHome = await anonymous.request('/api/public/home');
|
||||
assert.ok(refreshedHome.data.notices.some(item => item.title === '系统测试成绩发布通知'), '管理员发布通知后首页应可见');
|
||||
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
||||
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
||||
assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文');
|
||||
|
||||
const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }]));
|
||||
const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile });
|
||||
|
||||
Reference in New Issue
Block a user