diff --git a/.env.docker.example b/.env.docker.example
index fc777bb..e4353e3 100644
--- a/.env.docker.example
+++ b/.env.docker.example
@@ -1,6 +1,7 @@
-# 首次启动前请复制为 .env.docker,并替换下面两个值。
-# TOTP_ENCRYPTION_KEY 必须至少 32 个字符,部署后不得随意更换。
+# 首次启动前请复制为 .env.docker,并替换下面三个值。
+# 两项密钥必须至少 32 个字符、彼此独立,部署后不得随意更换。
TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
+DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=replace-with-a-strong-admin-password
diff --git a/.env.example b/.env.example
index fa4f529..d123fa5 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,9 @@ INITIAL_ADMIN_DISPLAY_NAME=系统管理员
# TOTP 密钥加密主密钥。生产环境必填且至少 32 个字符;修改后已绑定的 TOTP 将无法解密。
# TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
+# 成绩单与录取通知书防伪码签名密钥。生产环境必须独立设置并长期稳定保存。
+# DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
+
# 公开首页文案与联系方式(修改后重启应用生效)
PUBLIC_SITE_NAME=海州市教育考试中心
PUBLIC_SITE_CODE=HZ-EDU-032
diff --git a/README.md b/README.md
index dd60163..1a9e8c2 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@
- 审批实例、当前责任人、转交和监督操作全程留痕
- 桌面端与移动端响应式布局
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
+- 成绩单与录取通知书以 PDF 下载,使用服务端 HMAC 防伪码支持公开验真
## 运行
@@ -92,13 +93,15 @@ npm start
账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
+生产环境还必须单独设置至少 32 个字符的 `DOCUMENT_VERIFICATION_SECRET`。系统用它为成绩单和录取通知书生成 HMAC 防伪查询码;更换该值会使此前下载文书的查询码失效,因此应独立生成、稳定保存且不得与 TOTP 密钥共用。
+
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建。
### Docker
项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。
-先创建容器环境文件,并将其中的 TOTP 主密钥和初始管理员密码替换为安全随机值:
+先创建容器环境文件,并将其中的 TOTP 主密钥、文书防伪签名密钥和初始管理员密码替换为相互独立的安全随机值:
```powershell
Copy-Item .env.docker.example .env.docker
diff --git a/app.js b/app.js
index 8223db4..f832363 100644
--- a/app.js
+++ b/app.js
@@ -8,12 +8,15 @@ import { state } from './src/client/state.mjs';
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
import { specialtyCatalog } from './src/data/specialty-types.mjs';
+import { getTableControl, setTableControl } from './src/client/table-state.mjs';
+import { downloadAdmissionNotice, downloadScoreReport } from './src/client/pdf-export.mjs';
const app = document.querySelector('#app');
const modalRoot = document.querySelector('#modalRoot');
let toastTimer;
let noticeEditor;
let ckeditorModulePromise;
+let tableSearchTimer;
function toast(title, message = '') {
const element = document.querySelector('#toast');
element.querySelector('strong').textContent = title;
@@ -83,7 +86,7 @@ function requireLogin() {
}
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, requireLogin, emptyState };
-const { brand, renderHome, renderNoticeCenter, renderAuth } = createPublicViews(baseViewContext);
+const { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
@@ -100,6 +103,13 @@ async function renderRoute() {
const [section, page = 'dashboard'] = route.split('/');
if (section !== 'login') state.authNotice = '';
if (section === 'home') renderHome();
+ else if (section === 'verify') {
+ if (!page || page === 'dashboard') renderVerification();
+ else {
+ try { renderVerification(page, await api(`/api/public/verifications/${encodeURIComponent(page)}`)); }
+ catch (error) { renderVerification(page, null, error.message); }
+ }
+ }
else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); }
else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); }
else if (section === 'login' || section === 'register') renderAuth(section);
@@ -107,6 +117,13 @@ async function renderRoute() {
else if (section === 'admin') await renderAdmin(page);
else if (section === 'admission_school') await renderAdmission(page);
else navigate('home');
+ restoreTableControls();
+}
+
+function restoreTableControls() {
+ document.querySelectorAll('[data-action="table-search"][data-target]').forEach(input => { input.value = getTableControl(state, input.dataset.target).query || ''; });
+ document.querySelectorAll('[data-action="status-filter"][data-target]').forEach(button => button.classList.toggle('active', (getTableControl(state, button.dataset.target).status || 'all') === button.dataset.status));
+ document.querySelectorAll('[data-table-filter][data-target]').forEach(select => { select.value = getTableControl(state, select.dataset.target).filters?.[select.dataset.tableFilter] || ''; });
}
function formObject(form) {
@@ -323,6 +340,20 @@ document.addEventListener('click', async event => {
return;
}
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
+ if (action === 'download-score-report') {
+ const examId = target.dataset.examId;
+ const results = (state.pageData?.results || []).filter(item => item.examId === examId);
+ const summary = (state.pageData?.summaries || []).find(item => item.examId === examId);
+ if (!results.length || !summary?.verificationCode) return toast('成绩单暂不可下载', '请刷新页面后重试');
+ downloadScoreReport({ organization: state.publicData.organization, candidate: state.pageData.candidate || { name: state.user.displayName, candidateNumber: state.user.candidateNumber }, exam: { id: examId, name: results[0].examName, code: results[0].examCode }, results, summary: { ...summary, publishedAt: [...results].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0]?.publishedAt }, verificationCode: summary.verificationCode, verificationUrl: `${location.origin}/#verify/${summary.verificationCode}` });
+ return toast('PDF 成绩单已生成', '文件包含防伪查询码');
+ }
+ if (action === 'download-admission-notice') {
+ const item = (state.pageData?.admissions || []).find(entry => entry.examId === target.dataset.examId);
+ if (!item?.placement || item.placement.status !== 'final' || !item.noticeVerificationCode) return toast('录取通知书暂不可下载', '只有正式录取后才能生成');
+ downloadAdmissionNotice({ organization: state.publicData.organization, candidate: { name: state.profile?.name || state.user.displayName }, exam: item.exam, placement: item.placement, school: item.placementSchool || { name: item.placement.schoolName || '招生学校' }, template: item.noticeTemplate || {}, verificationCode: item.noticeVerificationCode, verificationUrl: `${location.origin}/#verify/${item.noticeVerificationCode}` });
+ return toast('录取通知书已生成', '请核对学校和录取类别');
+ }
if (action === 'download-admitted-candidates') {
const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value;
if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载');
@@ -376,11 +407,11 @@ document.addEventListener('click', async event => {
}
if (action === 'clear-table-filters') {
const tableId = target.dataset.target;
+ state.tableFilters[tableId] = { query: '', status: 'all', filters: {} };
document.querySelectorAll(`[data-table-filter][data-target="${tableId}"]`).forEach(select => { select.value = ''; });
document.querySelectorAll(`[data-action="table-search"][data-target="${tableId}"]`).forEach(input => { input.value = ''; });
document.querySelectorAll(`[data-action="status-filter"][data-target="${tableId}"]`).forEach((button, index) => button.classList.toggle('active', index === 0));
- applyTableFilters(tableId);
- return;
+ return renderRoute();
}
if (action === 'bulk-indicator-qualification') {
const toolbar = target.closest('[data-qualification-bulk]');
@@ -686,15 +717,26 @@ document.addEventListener('click', async event => {
toast(visible ? '系统公示已显示' : '系统公示已隐藏', '只调整公开目录展示,不修改公示内容'); return refreshPublic().then(renderRoute);
}
if (action === 'status-filter') {
- target.parentElement.querySelectorAll('button').forEach(button => button.classList.toggle('active', button === target));
- applyTableFilters(target.dataset.target);
+ setTableControl(state, target.dataset.target, { status: target.dataset.status });
+ return renderRoute();
}
} catch (error) { toast('操作未完成', error.message); }
});
document.addEventListener('input', event => {
if (event.target.matches('[data-action="table-search"]')) {
+ setTableControl(state, event.target.dataset.target, { query: event.target.value });
applyTableFilters(event.target.dataset.target);
+ clearTimeout(tableSearchTimer);
+ tableSearchTimer = setTimeout(() => renderRoute(), 260);
+ }
+ if (event.target.closest('[data-notice-template]')) {
+ const studio = event.target.closest('[data-notice-template]');
+ const preview = studio.querySelector('.notice-template-preview');
+ if (event.target.name === 'primaryColor') preview.style.setProperty('--template-primary', event.target.value);
+ if (event.target.name === 'accentColor') preview.style.setProperty('--template-accent', event.target.value);
+ const output = studio.querySelector(`[data-template-preview="${event.target.name}"]`);
+ if (output) output.textContent = event.target.value.replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}', state.pageData?.school?.name || '本校').replaceAll('{{录取类别}}','普通生');
}
if (event.target.matches('[data-result-score]')) updateResultScoreInput(event.target);
if (event.target.matches('[data-feature-score]')) updateFeatureScoreInput(event.target);
@@ -739,7 +781,10 @@ document.addEventListener('change', event => {
renderRoute();
}
if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger'));
- if (event.target.matches('[data-table-filter]')) applyTableFilters(event.target.dataset.target);
+ if (event.target.matches('[data-table-filter]')) {
+ setTableControl(state, event.target.dataset.target, { filters: { [event.target.dataset.tableFilter]: event.target.value } });
+ renderRoute();
+ }
if (event.target.matches('[data-registration-select]')) updateRegistrationSelection();
if (event.target.matches('[data-candidate-select]')) updateCandidateSelection();
if (event.target.matches('[data-payment-select]')) updatePaymentSelection();
@@ -898,6 +943,10 @@ document.addEventListener('submit', async event => {
} else if (kind === 'register') {
const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) });
setModal(`
CANDIDATE NUMBER请保存你的报名号
该号码就是长期使用的考生账户。
固定报名号${h(data.registrationNumber)}以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。
`);
+ } else if (kind === 'document-verification') {
+ const code = form.code.value.trim().toUpperCase();
+ if (!code) throw new Error('请输入防伪查询码');
+ navigate(`verify/${encodeURIComponent(code)}`);
} else if (kind === 'candidate-password') {
const body = formObject(form);
if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致');
@@ -962,6 +1011,9 @@ document.addEventListener('submit', async event => {
} else if (kind === 'admission-account') {
await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) });
form.reset(); toast('招生学校账号已创建'); renderRoute();
+ } else if (kind === 'admission-notice-template') {
+ await api('/api/admission/notice-template', { method: 'PUT', body: formObject(form) });
+ toast('录取通知书模板已保存', '正式录取考生将使用该模板生成 PDF'); renderRoute();
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
const body = formObject(form);
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
diff --git a/package.json b/package.json
index ca76326..d5ab41b 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"start": "node server.mjs",
- "test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
+ "test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
"test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs",
diff --git a/server.mjs b/server.mjs
index cb01874..7239ea4 100644
--- a/server.mjs
+++ b/server.mjs
@@ -17,10 +17,12 @@ import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './s
import { createBaseDatabase } from './src/data/base.mjs';
import { resolveRegion } from './src/data/region-service.mjs';
import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
+import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from './src/security/document-verification.mjs';
const root = resolve(process.cwd());
const envPath = join(root, '.env');
if (existsSync(envPath)) loadEnvFile(envPath);
+const documentVerificationSecret = resolveDocumentVerificationSecret();
const port = Number(process.env.PORT || 4173);
const host = process.env.HOST || '127.0.0.1';
@@ -51,6 +53,8 @@ const staticFiles = new Set([
'/src/client/public-views.mjs',
'/src/client/state.mjs',
'/src/client/ui.mjs',
+ '/src/client/table-state.mjs',
+ '/src/client/pdf-export.mjs',
'/src/client/region-select.mjs',
'/src/data/china-regions.mjs',
'/src/data/specialty-types.mjs'
@@ -902,6 +906,10 @@ const routeContext = {
database,
cache,
resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
+ documentVerificationSecret,
+ scoreReportCode,
+ admissionNoticeCode,
+ safeCodeEqual,
readDb,
publicSiteConfig,
sendJson,
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index ee104ff..ff80c47 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -1,6 +1,7 @@
import { formatRegionAddress } from './region-select.mjs';
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
+import { filterTableItems } from './table-state.mjs';
export const numberSegmentMeta = {
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
@@ -78,7 +79,7 @@ export function createAdminViews(context) {
const container = app.querySelector('.portal-content');
if (!container || !data.preferences?.length) return;
const preferencePage = paged(data.preferences, 'adminPreferenceTable');
- container.insertAdjacentHTML('beforeend', `考生志愿只读台账
仅超级管理员可查看;系统不提供任何管理员修改入口。
${data.preferences.length} 份 ${pagination(preferencePage)}`);
+ container.insertAdjacentHTML('beforeend', `考生志愿只读台账
仅超级管理员可查看;系统不提供任何管理员修改入口。
${data.preferences.length} 份 ${pagination(preferencePage)}`);
}
function adminDashboard(data) {
@@ -99,10 +100,11 @@ export function createAdminViews(context) {
function adminSchoolsV2(data) {
const schools = data.schools || [];
+ const schoolPage = paged(schools, 'schoolTable', 20);
const sourceCount = schools.filter(item => item.active && item.isSourceSchool).length;
const admissionCount = schools.filter(item => item.active && item.isAdmissionSchool).length;
const roles = item => `${item.isSourceSchool ? '生源校' : ''}${item.isAdmissionSchool ? '招生校' : ''}`;
- return `学校总数${schools.length}
启用生源校${sourceCount}
启用招生校${admissionCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
`;
+ return `学校总数${schools.length}
启用生源校${sourceCount}
启用招生校${admissionCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
${pagination(schoolPage)}`;
}
function adminSchoolOrganization(data) {
@@ -144,6 +146,7 @@ export function createAdminViews(context) {
}
function paged(items, key, defaultPageSize = 50) {
+ items = filterTableItems(state, items, key);
const current = state.tablePages[key] || {};
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
const total = items.length;
@@ -230,8 +233,8 @@ export function createAdminViews(context) {
const accountRows = accountPage.items.map(account => `${h((account.displayName || '招').slice(0, 1))}${h(account.displayName)}${h(account.id)} | ${h(account.username)} | ${h(account.schoolName || '未绑定')}${h(account.schoolCode || '')} | ${account.active ? '已启用' : '已停用'} | ${formatDate(account.createdAt, true)} | |
`).join('');
const accounts = `招生学校账户管理
创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。
${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个已建账户保留历史记录,不提供物理删除
${pagination(accountPage)}`;
const planForm = `代招生校上传计划
每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。
`;
- const plans = `${pendingPlans.length} 份待审 ${pagination(planPage)}`;
- const placements = `投档与退档监督
志愿只读,管理员均无修改入口${pagination(placementPage)}`;
+ const plans = `${pendingPlans.length} 份待审 ${pagination(planPage)}`;
+ const placements = `投档与退档监督
支持跨页搜索考生、报名号、学校与类别;志愿保持只读。
共 ${data.placements.length} 条 ${pagination(placementPage)}`;
return `ADMISSION COMMAND中考招生录取控制台
学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。
- 待审计划
- ${pendingPlans.length}
- 学校审核中
- ${data.placements.filter(item => item.status === 'school_review').length}
- 退档待审
- ${withdrawals.length}
- 正式录取
- ${data.placements.filter(item => item.status === 'final').length}
${settings}
${accounts}${planForm}${plans}${placements}`;
}
@@ -250,9 +253,17 @@ export function createAdminViews(context) {
}
function adminNotices(notices, publications = []) {
- const noticeRows = notices.map(notice => `| ${h(notice.title)}${h(notice.summary)} | 手动通知${h(notice.category)} | ${h(notice.author)} | ${formatDate(notice.publishAt || notice.createdAt,true)} | ${notice.pinned ? '首页置顶' : notice.status === 'published' ? '通知目录' : '尚未展示'} | ${badge(notice.status)} | ${notice.status === 'draft' ? `` : ''} |
`).join('');
- const publicationRows = publications.map(item => `| ${h(item.title)}${h(item.summary)} | 自动公示${h(item.category)} | ${h(item.author)} | ${formatDate(item.publishedAt,true)} | 通知目录 | ${badge(item.status)} | |
`).join('');
- return ``;
+ const entries = [
+ ...notices.map(data => ({ kind: 'notice', status: `ordinary ${data.status} ${data.status === 'published' ? 'visible' : 'hidden'}`, data })),
+ ...publications.map(data => ({ kind: 'publication', status: `system ${data.status} ${data.visible ? 'visible' : 'hidden'}`, data }))
+ ];
+ const noticePage = paged(entries, 'noticeTable', 20);
+ const rows = noticePage.items.map(entry => {
+ const item = entry.data;
+ if (entry.kind === 'notice') return `| ${h(item.title)}${h(item.summary)} | 手动通知${h(item.category)} | ${h(item.author)} | ${formatDate(item.publishAt || item.createdAt,true)} | ${item.pinned ? '首页置顶' : item.status === 'published' ? '通知目录' : '尚未展示'} | ${badge(item.status)} | ${item.status === 'draft' ? `` : ''} |
`;
+ return `| ${h(item.title)}${h(item.summary)} | 自动公示${h(item.category)} | ${h(item.author)} | ${formatDate(item.publishedAt,true)} | 通知目录 | ${badge(item.status)} | |
`;
+ }).join('');
+ return `${pagination(noticePage)}`;
}
function adminAdmit(data) {
@@ -329,28 +340,32 @@ export function createAdminViews(context) {
}
function adminUsers(data) {
- return `SELF REGISTRATION考生自主注册
${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}
`;
+ const adminPage = paged(data.admins || [], 'adminAccountTable', 20);
+ return `SELF REGISTRATION考生自主注册
${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}
`;
}
function adminCenters(data) {
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
const detailAddresses = new Map(data.centers.map(center => [center.id, center.address]));
data.centers.forEach(center => { center.address = formatRegionAddress(center); });
- const cards = data.centers.map(center => `${h(center.schoolName)} · ${h(center.code)}
${h(center.name)}
${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}个
启用席位${center.totalCapacity}席
开放时间${h(center.gateOpenTime || '未设')}
- 详细地址
- ${h(center.address)}
- 考点负责人
- ${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
- 应急电话
- ${h(center.emergencyPhone || '未填写')}
- 交通提示
- ${h(center.transport || '未填写')}
| 考场 | 位置 | 类型 | 容量 | 座位编排 | 状态 |
${center.rooms.map(room => `| ${h(room.name)}${h(room.code)} | ${h(room.building)} · ${h(room.floor || '楼层未填')} | ${h(roomTypeNames[room.roomType] || room.roomType)} | ${h(room.capacity)} 席 | ${h(room.seatPlan || '按现场座次表编排')} | ${badge(room.status === 'active' ? 'approved' : 'closed')} |
`).join('')}
`).join('');
+ const centerPage = paged(data.centers, 'centerDossierGrid', 20);
+ const cards = centerPage.items.map(center => `${h(center.schoolName)} · ${h(center.code)}
${h(center.name)}
${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}个
启用席位${center.totalCapacity}席
开放时间${h(center.gateOpenTime || '未设')}
- 详细地址
- ${h(center.address)}
- 考点负责人
- ${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
- 应急电话
- ${h(center.emergencyPhone || '未填写')}
- 交通提示
- ${h(center.transport || '未填写')}
| 考场 | 位置 | 类型 | 容量 | 座位编排 | 状态 |
${center.rooms.map(room => `| ${h(room.name)}${h(room.code)} | ${h(room.building)} · ${h(room.floor || '楼层未填')} | ${h(roomTypeNames[room.roomType] || room.roomType)} | ${h(room.capacity)} 席 | ${h(room.seatPlan || '按现场座次表编排')} | ${badge(room.status === 'active' ? 'approved' : 'closed')} |
`).join('')}
`).join('');
data.centers.forEach(center => { center.address = detailAddresses.get(center.id); });
const requests = data.changeRequests || [];
- return `${excelToolbar('centers', { label: '考点考场档案' })}正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}
考点变更台账
新增和修改均保留申请快照,审批通过后才更新正式档案。
`;
+ const requestPage = paged(requests, 'centerChangeTable', 20);
+ return `${excelToolbar('centers', { label: '考点考场档案' })}正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('没有符合条件的考点', '可清除搜索后查看全部正式考点。')}
${pagination(centerPage)}考点变更台账
新增和修改均保留申请快照,审批通过后才更新正式档案。
${pagination(requestPage)}`;
}
function adminAccountBatches(data) {
const classes = data.classes || [];
const batches = data.batches || [];
+ const batchPage = paged(batches, 'accountBatchLedger', 20);
const form = `SCHOOL ACCOUNT REQUEST按班级申领报名号
只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。
单批上限500个账户
`;
- const ledger = batches.map(batch => {
+ const ledger = batchPage.items.map(batch => {
const resultRows = batch.status === 'approved' ? `` : '';
return `${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}
当前进度${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}
${batch.reviewNote ? `审批意见${h(batch.reviewNote)}
` : ''}${resultRows}`;
}).join('');
- return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}REQUEST LEDGER
申领批次与返回结果
结果只在最终批准后生成;报名号随后作为考生长期账户。
${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}`;
+ return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}REQUEST LEDGER
申领批次与返回结果
结果只在最终批准后生成;报名号随后作为考生长期账户。
${ledger || emptyState('没有符合条件的申领批次', '可清除筛选后查看全部记录。')}
${pagination(batchPage)}`;
}
function adminNumberRules(data) {
diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs
index 323b00d..f67fed5 100644
--- a/src/client/admission-views.mjs
+++ b/src/client/admission-views.mjs
@@ -1,20 +1,21 @@
export function createAdmissionViews(context) {
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
- const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
+ const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
function shell(page, content, title, description) {
- return `SCHOOL ADMISSION
${h(title)}
${h(description)}
${content} `;
+ const groups = [...new Set(nav.map(item => item[3]))];
+ return `SCHOOL ADMISSION
${h(title)}
${h(description)}
${content} `;
}
async function renderAdmission(page) {
if (state.user?.role !== 'admission_school') return requireLogin();
if (!nav.some(item => item[0] === page)) page = 'dashboard';
- const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
+ const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] };
app.innerHTML = shell(page, '正在读取数据
', ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'context' : page;
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
- const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : placements(data);
+ const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : noticeTemplate(data);
app.innerHTML = shell(page, content, ...meta[page]);
} catch (error) { renderError(error); }
}
@@ -24,6 +25,7 @@ export function createAdmissionViews(context) {
}
function paged(items, key, defaultPageSize = 50) {
+ items = filterTableItems(state, items, key);
const current = state.tablePages[key] || {};
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
const total = items.length;
@@ -33,6 +35,11 @@ export function createAdmissionViews(context) {
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
}
+ function noticeTemplate(data) {
+ const template = data.template || {};
+ return ``;
+ }
+
function pagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
@@ -59,3 +66,4 @@ export function createAdmissionViews(context) {
}
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
+import { filterTableItems } from './table-state.mjs';
diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs
index a45dc6a..3e3912d 100644
--- a/src/client/candidate-views.mjs
+++ b/src/client/candidate-views.mjs
@@ -38,7 +38,12 @@ export function createCandidateViews(context) {
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心';
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
- return `${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}
${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}
${h(title)}
${h(description)}
${portalHeadingAction(role, page)}
${content} `;
+ const groupFor = id => role === 'candidate'
+ ? ({ dashboard: '个人总览', profile: '账户与档案', security: '账户与档案', exams: '考试服务', registrations: '考试服务', admit: '考试服务', results: '考试服务', admissions: '招生录取', notices: '招生录取' }[id] || '其他')
+ : ({ dashboard: '运行总览', schools: '组织与账户', organization: '组织与账户', admins: '组织与账户', 'account-batches': '组织与账户', candidates: '报名考务', registrations: '报名考务', payments: '报名考务', admit: '报名考务', exams: '考试与成绩', results: '考试与成绩', admissions: '招生录取', 'indicator-qualifications': '招生录取', notices: '招生录取', centers: '场所与流程', flows: '场所与流程', 'flow-design': '系统配置', 'number-rules': '系统配置', security: '系统配置' }[id] || '其他');
+ const groups = [...new Set(nav.map(([id]) => groupFor(id)))];
+ const navHtml = groups.map(group => `${h(group)}${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => ``).join('')}`).join('');
+ return `${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}
${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}
${h(title)}
${h(description)}
${portalHeadingAction(role, page)}
${content} `;
}
function portalHeadingAction(role, page) {
@@ -188,7 +193,7 @@ export function createCandidateViews(context) {
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
return `${h(item.subjectName)}${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}
${h(item.score)} / ${h(item.fullScore)}${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%本科排名${h(item.passText || '不设单科线')}
${appealPanel}`;
}).join('');
- const panel = `当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名特征分${h(summary?.featureScore ?? 0)}独立于考试科目整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}
`;
+ const panel = `当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名特征分${h(summary?.featureScore ?? 0)}独立于考试科目整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}
`;
return items[0].archivedAt ? `${h(examName)}${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定历史成绩
${panel} ` : panel;
}).join('')}`;
}
@@ -216,7 +221,7 @@ export function createCandidateViews(context) {
const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}`; }).join('');
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
- return `${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${h(phaseLabels[item.status] || item.status)}
${h(item.payload.progress || '等待录取工作更新')}
${item.placement ? `当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}
` : ''}${canFill ? `` : choices.length ? `${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}`;
+ return `${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${h(phaseLabels[item.status] || item.status)}
${h(item.payload.progress || '等待录取工作更新')}
${item.placement ? `当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.placement.status === 'final' ? '已正式录取,可下载正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}${item.placement.status === 'final' ? `` : ''}
` : ''}${canFill ? `` : choices.length ? `${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}`;
}).join('')}`;
}
diff --git a/src/client/pdf-export.mjs b/src/client/pdf-export.mjs
new file mode 100644
index 0000000..866767c
--- /dev/null
+++ b/src/client/pdf-export.mjs
@@ -0,0 +1,150 @@
+const A4 = { width: 2480, height: 3508 };
+
+function roundRect(ctx, x, y, width, height, radius = 18) {
+ ctx.beginPath();
+ ctx.roundRect(x, y, width, height, radius);
+}
+
+function fitText(ctx, text, maxWidth, initialSize, weight = 400) {
+ let size = initialSize;
+ do {
+ ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
+ if (ctx.measureText(String(text)).width <= maxWidth) return size;
+ size -= 2;
+ } while (size > 24);
+ return size;
+}
+
+function drawText(ctx, text, x, y, { size = 36, weight = 400, color = '#17213f', align = 'left', maxWidth } = {}) {
+ if (maxWidth) size = fitText(ctx, text, maxWidth, size, weight);
+ ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
+ ctx.fillStyle = color;
+ ctx.textAlign = align;
+ ctx.textBaseline = 'alphabetic';
+ ctx.fillText(String(text ?? ''), x, y, maxWidth);
+}
+
+function jpegPdf(dataUrl, width, height) {
+ const binary = atob(dataUrl.split(',')[1]);
+ const image = Uint8Array.from(binary, char => char.charCodeAt(0));
+ const encoder = new TextEncoder();
+ const chunks = [];
+ const offsets = [0];
+ let length = 0;
+ const add = value => { const bytes = typeof value === 'string' ? encoder.encode(value) : value; chunks.push(bytes); length += bytes.length; };
+ add('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
+ const object = (id, body) => { offsets[id] = length; add(`${id} 0 obj\n${body}\nendobj\n`); };
+ object(1, '<< /Type /Catalog /Pages 2 0 R >>');
+ object(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
+ object(3, '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>');
+ offsets[4] = length;
+ add(`4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.length} >>\nstream\n`);
+ add(image); add('\nendstream\nendobj\n');
+ const stream = 'q\n595.28 0 0 841.89 0 0 cm\n/Im0 Do\nQ';
+ object(5, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
+ const xref = length;
+ add(`xref\n0 6\n0000000000 65535 f \n`);
+ for (let id = 1; id <= 5; id += 1) add(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`);
+ add(`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);
+ const output = new Uint8Array(length);
+ let cursor = 0;
+ for (const chunk of chunks) { output.set(chunk, cursor); cursor += chunk.length; }
+ return new Blob([output], { type: 'application/pdf' });
+}
+
+function downloadCanvasPdf(canvas, filename) {
+ const blob = jpegPdf(canvas.toDataURL('image/jpeg', .94), canvas.width, canvas.height);
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(link.href), 3000);
+}
+
+export function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl }) {
+ const canvas = document.createElement('canvas');
+ Object.assign(canvas, A4);
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#f5f8fb'; ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#14234b'; ctx.fillRect(0, 0, canvas.width, 270);
+ ctx.fillStyle = '#c94b45'; ctx.fillRect(170, 234, 250, 12);
+ drawText(ctx, organization?.name || '考试服务平台', 170, 112, { size: 42, weight: 700, color: '#ffffff' });
+ drawText(ctx, '考 生 成 绩 单', 170, 205, { size: 76, weight: 700, color: '#ffffff' });
+ drawText(ctx, exam.code, 2300, 115, { size: 32, weight: 600, color: '#9eadce', align: 'right' });
+ drawText(ctx, exam.name, 2300, 192, { size: 38, weight: 500, color: '#ffffff', align: 'right', maxWidth: 1120 });
+
+ const box = (x, y, w, h, fill = '#ffffff') => { roundRect(ctx, x, y, w, h, 22); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#dce4ec'; ctx.lineWidth = 2; ctx.stroke(); };
+ box(170, 330, 2140, 300);
+ const meta = [['姓名', candidate.name], ['报名号', candidate.candidateNumber], ['考试', exam.name], ['发布时间', summary?.publishedAt ? new Date(summary.publishedAt).toLocaleString('zh-CN') : '以系统记录为准']];
+ meta.forEach(([label, value], index) => {
+ const x = 225 + (index % 2) * 1050, y = 420 + Math.floor(index / 2) * 115;
+ drawText(ctx, label, x, y, { size: 28, color: '#77839a' });
+ drawText(ctx, value, x + 155, y, { size: 34, weight: 600, maxWidth: 800 });
+ });
+ box(170, 690, 2140, 320, '#eaf4f1');
+ const totals = [['总分', `${summary?.total ?? '—'} / ${summary?.fullScore ?? '—'}`], ['特征分', summary?.featureScore ?? 0], ['合格结论', summary?.qualified == null ? '不判定' : summary.qualified ? '合格' : '未合格'], ['发布进度', `${summary?.publishedSubjects ?? results.length} / ${summary?.subjectCount ?? results.length} 科`]];
+ totals.forEach(([label, value], index) => {
+ const x = 235 + index * 520;
+ drawText(ctx, label, x, 790, { size: 28, color: '#5d766f' });
+ drawText(ctx, value, x, 900, { size: 48, weight: 700, color: '#173b35', maxWidth: 440 });
+ });
+
+ drawText(ctx, '科目成绩与等级排名', 170, 1115, { size: 42, weight: 700 });
+ drawText(ctx, '等级与排名均以系统正式发布数据为准', 2310, 1115, { size: 25, color: '#7b8598', align: 'right' });
+ const cols = 2, gap = 34, cardW = (2140 - gap) / cols, cardH = Math.min(300, Math.max(220, (1760 - Math.ceil(results.length / cols) * 20) / Math.ceil(results.length / cols)));
+ results.forEach((item, index) => {
+ const col = index % cols, row = Math.floor(index / cols), x = 170 + col * (cardW + gap), y = 1180 + row * (cardH + 20);
+ box(x, y, cardW, cardH);
+ drawText(ctx, item.subjectName, x + 42, y + 72, { size: 38, weight: 700, maxWidth: cardW - 450 });
+ drawText(ctx, item.qualified == null ? '不判定' : item.qualified ? '达线' : '未达线', x + cardW - 42, y + 70, { size: 27, weight: 600, color: item.qualified === false ? '#b43d38' : '#2d7462', align: 'right' });
+ drawText(ctx, item.score, x + 42, y + 158, { size: 62, weight: 700 });
+ drawText(ctx, `/ ${item.fullScore}`, x + 190, y + 156, { size: 28, color: '#8993a6' });
+ drawText(ctx, `${item.grade} · 第 ${item.rank} / ${item.cohortSize} 名 · 前 ${item.rankPercent}%`, x + 42, y + 220, { size: 28, color: '#455068', maxWidth: cardW - 84 });
+ drawText(ctx, item.passText || '不设单科线', x + 42, y + cardH - 30, { size: 24, color: '#7c8798', maxWidth: cardW - 84 });
+ });
+
+ const footerY = 3100;
+ box(170, footerY, 2140, 235, '#f0f3f7');
+ drawText(ctx, '防伪查询码', 225, footerY + 70, { size: 28, color: '#6f7a8e' });
+ drawText(ctx, verificationCode, 225, footerY + 135, { size: 38, weight: 700, color: '#17213f' });
+ drawText(ctx, '登录考试服务平台,在“文书防伪查询”中输入本码核验。', 225, footerY + 188, { size: 24, color: '#667085' });
+ drawText(ctx, verificationUrl, 2250, footerY + 135, { size: 22, color: '#53627b', align: 'right', maxWidth: 1050 });
+ drawText(ctx, `生成时间 ${new Date().toLocaleString('zh-CN')}`, 2310, 3435, { size: 22, color: '#8a94a6', align: 'right' });
+ downloadCanvasPdf(canvas, `${exam.name}-${candidate.name}-成绩单.pdf`);
+}
+
+export function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl }) {
+ const canvas = document.createElement('canvas'); Object.assign(canvas, A4);
+ const ctx = canvas.getContext('2d');
+ const primary = template.primaryColor || '#8d2028', accent = template.accentColor || '#c9a45b';
+ ctx.fillStyle = '#fffdf8'; ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.strokeStyle = primary; ctx.lineWidth = 10; ctx.strokeRect(90, 90, 2300, 3328);
+ ctx.strokeStyle = accent; ctx.lineWidth = 3; ctx.strokeRect(120, 120, 2240, 3268);
+ drawText(ctx, template.eyebrow || 'ADMISSION NOTICE', 1240, 350, { size: 30, weight: 600, color: accent, align: 'center' });
+ drawText(ctx, template.title || '录 取 通 知 书', 1240, 560, { size: 96, weight: 700, color: primary, align: 'center', maxWidth: 1950 });
+ drawText(ctx, school.name, 1240, 700, { size: 42, weight: 600, align: 'center', maxWidth: 1900 });
+ drawText(ctx, `${candidate.name} 同学:`, 300, 1040, { size: 48, weight: 700 });
+ const body = (template.body || '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。')
+ .replaceAll('{{考生姓名}}', candidate.name).replaceAll('{{考试名称}}', exam.name).replaceAll('{{录取学校}}', school.name).replaceAll('{{录取类别}}', placement.payload.categoryName || '招生类别');
+ const lines = [];
+ for (const paragraph of body.split(/\n+/)) {
+ let line = '';
+ for (const char of paragraph) {
+ ctx.font = '400 42px "Microsoft YaHei", sans-serif';
+ if (ctx.measureText(line + char).width > 1840) { lines.push(line); line = char; } else line += char;
+ }
+ if (line) lines.push(line); lines.push('');
+ }
+ lines.slice(0, 13).forEach((line, index) => drawText(ctx, line, 320, 1210 + index * 82, { size: 42, color: '#332f2c' }));
+ drawText(ctx, template.footer || '请妥善保管本通知书,报到时出示。', 300, 2550, { size: 32, color: '#6c6257', maxWidth: 1700 });
+ drawText(ctx, school.name, 2080, 2750, { size: 38, weight: 700, color: primary, align: 'right' });
+ drawText(ctx, new Date().toLocaleDateString('zh-CN'), 2080, 2820, { size: 30, color: '#5f5951', align: 'right' });
+ ctx.beginPath(); ctx.arc(1940, 2670, 160, 0, Math.PI * 2); ctx.strokeStyle = `${primary}99`; ctx.lineWidth = 12; ctx.stroke();
+ drawText(ctx, '录取专用章', 1940, 2682, { size: 34, weight: 700, color: `${primary}bb`, align: 'center' });
+ roundRect(ctx, 240, 3040, 2000, 210, 20); ctx.fillStyle = '#f4efe5'; ctx.fill();
+ drawText(ctx, '防伪查询码', 300, 3115, { size: 26, color: '#756b5e' });
+ drawText(ctx, verificationCode, 300, 3185, { size: 35, weight: 700 });
+ drawText(ctx, verificationUrl, 2160, 3185, { size: 21, color: '#71695f', align: 'right', maxWidth: 1000 });
+ drawText(ctx, organization?.name || '考试服务平台', 1240, 3380, { size: 23, color: '#8a8177', align: 'center' });
+ downloadCanvasPdf(canvas, `${school.name}-${candidate.name}-录取通知书.pdf`);
+}
diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs
index b6a340c..da23b3d 100644
--- a/src/client/public-views.mjs
+++ b/src/client/public-views.mjs
@@ -20,7 +20,7 @@ export function createPublicViews(context) {
}
function publicHeader() {
- return ``;
+ return ``;
}
function renderHome() {
@@ -99,6 +99,14 @@ export function createPublicViews(context) {
const authNotice = login && state.authNotice ? `需要重新登录${h(state.authNotice)}
` : '';
app.innerHTML = `${brand()}
CANDIDATE SERVICE
${login ? '凭一个号码,' : '自主申请,'}
${login ? '办理每一次考试。' : '领取固定报名号。'}
报名号就是考生账户,不因考试、科目或年度报名而改变。
首次登录顺序修改初始密码 → 补全个人信息 → 等待资料审核。
${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}
${login ? '报名号登录' : '自主申请报名号'}
${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}
${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '
自主注册已关闭学校管理员会为考生创建账户并下发初始密码。
'}${login && selfRegistration ? `
还没有报名号?
` : !login ? '
已经有报名号?
' : ''}
`;
}
+
+ function renderVerification(code = '', result = null, error = '') {
+ app.classList.remove('admin-readable');
+ const organization = state.publicData.organization || {};
+ const document = result?.document;
+ const outcome = document ? `✓VERIFIED DOCUMENT文书真实有效
该查询码由系统签发,当前数据与签发记录一致。
- 文书类型
- ${h(document.typeName)}
- 考生
- ${h(document.candidateName)}
- 考试
- ${h(document.examName)}
${document.schoolName ? `- 录取学校
- ${h(document.schoolName)}
` : ''}${document.categoryName ? `- 录取类别
- ${h(document.categoryName)}
` : ''}${document.totalScore != null ? `- 成绩摘要
- ${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}
` : ''}- 签发时间
- ${formatDate(document.issuedAt, true)}
` : error ? `!NOT VERIFIED未找到有效文书
${h(error)}
` : '';
+ app.innerHTML = `${publicHeader()}${outcome}安全提示查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。
`;
+ }
function loginForm() {
return ``;
@@ -109,5 +117,5 @@ export function createPublicViews(context) {
return ``;
}
- return { brand, renderHome, renderNoticeCenter, renderAuth };
+ return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
}
diff --git a/src/client/state.mjs b/src/client/state.mjs
index 3fb33f3..36c54a0 100644
--- a/src/client/state.mjs
+++ b/src/client/state.mjs
@@ -14,5 +14,6 @@ export const state = {
resultExamCatalog: null,
resultImportPreview: null,
tablePages: {},
+ tableFilters: {},
loading: false
};
diff --git a/src/client/table-state.mjs b/src/client/table-state.mjs
new file mode 100644
index 0000000..c819c6f
--- /dev/null
+++ b/src/client/table-state.mjs
@@ -0,0 +1,44 @@
+function controlState(state, key) {
+ state.tableFilters ||= {};
+ return state.tableFilters[key] ||= { query: '', status: 'all', filters: {} };
+}
+
+function searchable(value) {
+ if (value == null) return '';
+ if (Array.isArray(value)) return value.map(searchable).join(' ');
+ if (typeof value === 'object') return Object.values(value).map(searchable).join(' ');
+ return String(value);
+}
+
+function statusTokens(item) {
+ const tokens = [item?.status, item?.paymentStatus];
+ if (typeof item?.active === 'boolean') tokens.push(item.active ? 'active approved' : 'inactive disabled closed');
+ if (typeof item?.published === 'boolean') tokens.push(item.published ? 'published visible' : 'draft hidden');
+ if (typeof item?.qualified === 'boolean') tokens.push(item.qualified ? 'qualified' : 'unqualified');
+ if (typeof item?.confirmed === 'boolean') tokens.push(item.confirmed ? (item.eligible ? 'confirmed eligible' : 'confirmed ineligible') : 'unconfirmed');
+ return tokens.filter(Boolean).join(' ').toLowerCase();
+}
+
+export function filterTableItems(state, items, key) {
+ const control = controlState(state, key);
+ const query = String(control.query || '').trim().toLocaleLowerCase('zh-CN');
+ const status = String(control.status || 'all').toLowerCase();
+ const filters = Object.values(control.filters || {}).filter(Boolean).map(value => String(value).toLocaleLowerCase('zh-CN'));
+ return (items || []).filter(item => {
+ const haystack = searchable(item).toLocaleLowerCase('zh-CN');
+ if (query && !query.split(/\s+/).every(word => haystack.includes(word))) return false;
+ if (status !== 'all' && !statusTokens(item).split(/\s+/).includes(status)) return false;
+ return filters.every(value => haystack.includes(value));
+ });
+}
+
+export function setTableControl(state, key, patch) {
+ const current = controlState(state, key);
+ Object.assign(current, patch);
+ if (patch.filters) current.filters = { ...(current.filters || {}), ...patch.filters };
+ if (state.tablePages?.[key]) state.tablePages[key].page = 1;
+}
+
+export function getTableControl(state, key) {
+ return controlState(state, key);
+}
diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs
index 63f29ad..e0ed049 100644
--- a/src/routes/admission.routes.mjs
+++ b/src/routes/admission.routes.mjs
@@ -52,6 +52,34 @@ export function createAdmissionRoutes(context) {
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
}
+ if (request.method === 'GET' && pathname === '/api/admission/notice-template') {
+ const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template');
+ const template = record?.payload?.template || {
+ eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书',
+ body: '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。',
+ footer: '请妥善保管本通知书,报到时出示。', primaryColor: '#8d2028', accentColor: '#c9a45b'
+ };
+ return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt), template, updatedAt: record?.updatedAt || null });
+ }
+ if (request.method === 'PUT' && pathname === '/api/admission/notice-template') {
+ const body = await readJson(request);
+ const exam = db.exams.find(item => item.id === cleanText(body.examId, 64)) || db.exams.find(item => !item.archivedAt) || db.exams[0];
+ if (!exam) return sendError(response, 409, '系统中还没有可关联的考试,暂时无法保存模板');
+ const template = {
+ eyebrow: cleanText(body.eyebrow || 'ADMISSION NOTICE', 60),
+ title: cleanText(body.title || '录 取 通 知 书', 80),
+ body: cleanText(body.body, 1600), footer: cleanText(body.footer, 300),
+ primaryColor: /^#[0-9a-f]{6}$/i.test(body.primaryColor) ? body.primaryColor : '#8d2028',
+ accentColor: /^#[0-9a-f]{6}$/i.test(body.accentColor) ? body.accentColor : '#c9a45b'
+ };
+ if (!template.body) return sendError(response, 400, '请填写录取通知书正文');
+ const now = nowIso();
+ const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template')
+ || { id: uid('notice_template'), kind: 'notification', examId: exam.id, userId: null, schoolId: school.id, status: 'template', createdAt: now };
+ Object.assign(record, { examId: exam.id, updatedAt: now, payload: { template, updatedBy: user.displayName } });
+ await database.saveAdmissionRecord(record, logAction(db, user, '保存录取通知书模板', school.name));
+ return sendJson(response, 200, { ok: true, template, updatedAt: now });
+ }
if (request.method === 'GET' && pathname === '/api/admission/placements') {
const accountById = new Map(db.users.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs
index 972c94e..278094d 100644
--- a/src/routes/candidate.routes.mjs
+++ b/src/routes/candidate.routes.mjs
@@ -40,6 +40,9 @@ export function createCandidateRoutes(context) {
examResultSummary,
subjectPassText,
resultRankInfo,
+ documentVerificationSecret,
+ scoreReportCode,
+ admissionNoticeCode,
subjectPassEvaluation,
logAction,
excelResourceNames,
@@ -152,8 +155,13 @@ export function createCandidateRoutes(context) {
appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null
};
});
- const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
- return { ok: true, results, summaries };
+ const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects).map(summary => {
+ const registration = registrations.find(item => item.examId === summary.examId);
+ const exam = db.exams.find(item => item.id === summary.examId);
+ const reportResults = db.results.filter(item => item.registrationId === registration?.id && item.published);
+ return { ...summary, verificationCode: registration && exam ? scoreReportCode(documentVerificationSecret, registration, exam, reportResults) : '' };
+ });
+ return { ok: true, results, summaries, candidate: { name: profile.name || user.displayName, candidateNumber: user.candidateNumber || '' } };
}, { ttlSeconds: resultsCacheTtlSeconds });
return sendJson(response, 200, payload);
}
@@ -184,7 +192,11 @@ export function createCandidateRoutes(context) {
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
const submissionCount = Number(preference?.payload?.submissionCount || 0);
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
- return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
+ const school = placement ? db.schools.find(item => item.id === placement.schoolId) : null;
+ const templateRecord = placement ? admissionRecords(db, 'notification').find(item => item.schoolId === placement.schoolId && item.status === 'template') : null;
+ const noticeTemplate = templateRecord?.payload?.template || null;
+ const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : '';
+ return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
}).filter(item => item.exam);
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs
index b694d8c..bffef91 100644
--- a/src/routes/public.routes.mjs
+++ b/src/routes/public.routes.mjs
@@ -51,9 +51,36 @@ export function createPublicRoutes(context) {
hasExcelResource,
parseWorkbook,
adminLevelNames
+ , documentVerificationSecret, scoreReportCode, admissionNoticeCode, safeCodeEqual
} = context;
async function handlePublic(pathname, response) {
+ const verificationMatch = pathname.match(/^\/api\/public\/verifications\/([^/]+)$/);
+ if (verificationMatch) {
+ const db = await readDb();
+ const code = decodeURIComponent(verificationMatch[1]).toUpperCase();
+ const hideName = value => value ? `${value.slice(0, 1)}${'*'.repeat(Math.max(1, value.length - 1))}` : '';
+ if (code.startsWith('SR-')) {
+ for (const registration of db.registrations) {
+ const exam = db.exams.find(item => item.id === registration.examId);
+ const results = db.results.filter(item => item.registrationId === registration.id && item.published);
+ if (!exam || !results.length || !safeCodeEqual(code, scoreReportCode(documentVerificationSecret, registration, exam, results))) continue;
+ const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
+ const user = db.users.find(item => item.id === registration.userId) || {};
+ return sendJson(response, 200, { ok: true, verified: true, document: { type: 'score-report', typeName: '考生成绩单', candidateName: hideName(profile.name || user.displayName), candidateNumber: String(user.candidateNumber || registration.registrationNumber || '').replace(/^(.{3}).+(.{3})$/, '$1****$2'), examName: exam.name, subjectCount: results.length, totalScore: Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)), issuedAt: [...results].sort((a, b) => new Date(b.publishedAt || b.updatedAt) - new Date(a.publishedAt || a.updatedAt))[0]?.publishedAt } });
+ }
+ }
+ if (code.startsWith('AN-')) {
+ for (const placement of admissionRecords(db, 'placement').filter(item => item.status === 'final')) {
+ const exam = db.exams.find(item => item.id === placement.examId);
+ if (!exam || !safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, placement, exam))) continue;
+ const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
+ const school = db.schools.find(item => item.id === placement.schoolId) || {};
+ return sendJson(response, 200, { ok: true, verified: true, document: { type: 'admission-notice', typeName: '录取通知书', candidateName: hideName(profile.name), examName: exam.name, schoolName: school.name, categoryName: placement.payload?.categoryName || '', issuedAt: placement.updatedAt } });
+ }
+ }
+ return sendError(response, 404, '未查询到有效文书,请核对防伪码');
+ }
if (pathname === '/api/public/home') {
const payload = await cache.remember('public', 'home', async () => {
const db = await readDb();
diff --git a/src/security/document-verification.mjs b/src/security/document-verification.mjs
new file mode 100644
index 0000000..896855f
--- /dev/null
+++ b/src/security/document-verification.mjs
@@ -0,0 +1,28 @@
+import { createHmac, timingSafeEqual } from 'node:crypto';
+
+function signature(secret, type, parts) {
+ return createHmac('sha256', secret).update([type, ...parts].join('\u001f')).digest('hex').slice(0, 24).toUpperCase();
+}
+
+export function resolveDocumentVerificationSecret(env = process.env) {
+ const configured = String(env.DOCUMENT_VERIFICATION_SECRET || '');
+ if (env.NODE_ENV === 'production' && configured.length < 32) {
+ throw new Error('生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET');
+ }
+ return configured || String(env.SESSION_SECRET || '') || 'development-document-verification-secret';
+}
+
+export function scoreReportCode(secret, registration, exam, results = []) {
+ const scores = [...results].sort((a, b) => String(a.subjectId).localeCompare(String(b.subjectId))).map(item => `${item.subjectId}:${Number(item.score)}:${item.publishedAt || item.updatedAt || ''}`);
+ return `SR-${signature(secret, 'score-report', [registration.id, registration.userId, exam.id, ...scores])}`;
+}
+
+export function admissionNoticeCode(secret, placement, exam) {
+ return `AN-${signature(secret, 'admission-notice', [placement.id, placement.userId, placement.schoolId, exam.id, placement.payload?.categoryCode || '', placement.updatedAt || ''])}`;
+}
+
+export function safeCodeEqual(left, right) {
+ const a = Buffer.from(String(left || '').toUpperCase());
+ const b = Buffer.from(String(right || '').toUpperCase());
+ return a.length === b.length && timingSafeEqual(a, b);
+}
diff --git a/styles.css b/styles.css
index f43ed2a..4abe474 100644
--- a/styles.css
+++ b/styles.css
@@ -880,3 +880,86 @@ body.review-subpage-open { overflow:hidden; }
@media (max-width: 720px) {
.table-pagination { align-items: flex-start; flex-direction: column; }
}
+
+/* 业务域分组导航 */
+.portal-sidebar { width: 264px; overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #42547f transparent; }
+.portal-main { margin-left: 264px; }
+.portal-nav-groups { display: grid; gap: 15px !important; padding: 3px 0 18px; }
+.portal-nav-group { display: grid; gap: 4px; }
+.portal-nav-group > strong { padding: 0 13px 4px; color: #7181aa; font-size: 10px; font-weight: 700; letter-spacing: .12em; }
+.portal-nav-group button { min-height: 38px !important; }
+.sidebar-help { flex: 0 0 auto; }
+
+/* 成绩卡:排名信息回归文档流,永不覆盖科目标题 */
+.score-grid { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
+.score-grid article { min-width: 0; place-content: initial; align-content: start; justify-items: stretch; min-height: 230px; border: 1px solid var(--line); border-width: 0 1px 1px 0; }
+.score-subject-head { width: 100%; min-height: 31px; }
+.score-subject-head > span { overflow: hidden; color: var(--navy); font-size: 14px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
+.score-grid article > strong { margin: 22px 0 8px; font-size: 40px; }
+.score-grid article > em { position: static; display: block; padding: 0; color: #526079; background: transparent; font-size: 12px; font-style: normal; line-height: 1.55; }
+.rank-rule-line { align-items: flex-start; flex-direction: column; gap: 5px; margin-top: auto; }
+.rank-rule-line span { font-size: 11px; }
+.rank-rule-line b { font-size: 12px; line-height: 1.45; }
+.result-footer-actions { display: flex; align-items: center; gap: 14px; }
+.result-footer-actions .solid-button { min-height: 34px; padding: 0 14px; font-size: 11px; }
+
+/* 通知书模板工作室 */
+.notice-template-studio { display: grid; grid-template-columns: minmax(380px, .82fr) minmax(420px, 1.18fr); gap: 22px; align-items: start; }
+.notice-template-form { padding: 24px; }
+.notice-template-form .panel-title { margin-bottom: 20px; }
+.notice-template-form > label { display: grid; gap: 7px; margin-bottom: 16px; }
+.notice-template-form textarea { resize: vertical; }
+.template-color-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 18px 0; }
+.template-color-row label { display: flex; align-items: center; justify-content: space-between; padding: 12px; border: 1px solid var(--line); border-radius: 9px; }
+.template-color-row input { width: 64px; height: 34px; padding: 2px; }
+.notice-template-preview { position: sticky; top: 20px; }
+.template-frame { position: relative; min-height: 720px; padding: 66px 64px; border: 8px solid var(--template-primary); outline: 2px solid var(--template-accent); outline-offset: -18px; color: #332f2c; background: #fffdf8; box-shadow: var(--shadow); }
+.template-frame > small { display: block; color: var(--template-accent); font-family: Consolas, monospace; letter-spacing: .18em; text-align: center; }
+.template-frame h2 { margin: 26px 0 12px; color: var(--template-primary); font-family: "STKaiti", serif; font-size: 45px; font-weight: 700; letter-spacing: .16em; text-align: center; }
+.template-frame h3 { margin: 0 0 70px; text-align: center; }
+.template-frame > strong { font-size: 18px; }
+.template-frame > p { min-height: 250px; margin: 24px 0; font-size: 16px; line-height: 2.2; white-space: pre-wrap; }
+.template-frame footer { display: grid; justify-items: end; gap: 12px; margin-top: 40px; }
+.template-frame footer span { justify-self: stretch; color: #786f65; }
+.template-frame > i { position: absolute; right: 72px; bottom: 85px; width: 110px; height: 110px; display: grid; place-items: center; border: 5px solid color-mix(in srgb, var(--template-primary), transparent 25%); border-radius: 50%; color: var(--template-primary); font-size: 13px; font-style: normal; font-weight: 800; transform: rotate(-8deg); }
+.notice-template-preview > p { color: var(--muted); font-size: 11px; line-height: 1.7; }
+
+/* 公开防伪查询 */
+.verification-page { min-height: calc(100vh - 76px); padding: 72px max(24px, calc((100% - 1060px) / 2)); background: #f4f7fa; }
+.verification-hero { display: grid; grid-template-columns: 1fr 470px; gap: 70px; align-items: end; padding: 48px; border-radius: 18px; color: #fff; background: var(--navy); box-shadow: 0 26px 70px rgba(19,36,81,.2); }
+.verification-hero h1 { margin: 10px 0; font-family: "STKaiti", serif; font-size: 44px; font-weight: 400; }
+.verification-hero p { color: #abb7d3; }
+.verification-hero form { display: flex; align-items: end; gap: 10px; }
+.verification-hero label { display: grid; flex: 1; gap: 8px; color: #bdc8df; font-size: 12px; }
+.verification-hero input { height: 46px; border-color: rgba(255,255,255,.18); color: #fff; background: rgba(255,255,255,.08); font-family: Consolas, monospace; }
+.verification-result { display: grid; grid-template-columns: 64px 1fr; gap: 22px; margin-top: 24px; padding: 32px; border: 1px solid #cfe1db; border-radius: 15px; background: #fff; }
+.verification-result > span { width: 56px; height: 56px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #28735e; font-size: 27px; }
+.verification-result h2 { margin: 5px 0; }
+.verification-result p { color: var(--muted); }
+.verification-result dl { grid-column: 1/-1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 4px 0 0; }
+.verification-result dl div { padding: 14px; border-radius: 8px; background: #f3f6f8; }
+.verification-result dt { color: var(--muted); font-size: 10px; }
+.verification-result dd { margin: 5px 0 0; font-weight: 700; }
+.verification-result.invalid { border-color: #efcfcb; }
+.verification-result.invalid > span { background: #b94b44; }
+.verification-notice { margin-top: 20px; padding: 20px 24px; border-left: 3px solid #8792a8; background: #fff; }
+.verification-notice p { margin: 6px 0 0; color: var(--muted); }
+.admission-result-banner .solid-button { justify-self: start; margin-top: 10px; }
+
+@media (max-width: 1200px) {
+ .notice-template-studio,.verification-hero { grid-template-columns: 1fr; }
+ .notice-template-preview { position: static; }
+}
+@media (max-width: 850px) {
+ .portal-sidebar { width: 264px; }
+ .portal-main { margin-left: 0; }
+}
+@media (max-width: 620px) {
+ .score-grid { grid-template-columns: 1fr; }
+ .result-footer-actions { width: 100%; align-items: stretch; flex-direction: column; }
+ .verification-page { padding: 28px 14px; }
+ .verification-hero { padding: 26px 20px; }
+ .verification-hero form { align-items: stretch; flex-direction: column; }
+ .verification-result dl { grid-template-columns: 1fr; }
+ .template-frame { min-height: 620px; padding: 48px 34px; }
+}
diff --git a/tests/document-verification.test.mjs b/tests/document-verification.test.mjs
new file mode 100644
index 0000000..d144bd4
--- /dev/null
+++ b/tests/document-verification.test.mjs
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from '../src/security/document-verification.mjs';
+
+const secret = 'test-document-verification-secret-32-characters';
+const exam = { id: 'exam_1' };
+const registration = { id: 'registration_1', userId: 'candidate_1' };
+const results = [
+ { subjectId: 'math', score: 118, publishedAt: '2026-07-20T08:00:00.000Z' },
+ { subjectId: 'chinese', score: 112, publishedAt: '2026-07-20T08:00:00.000Z' }
+];
+
+const scoreCode = scoreReportCode(secret, registration, exam, results);
+const reorderedCode = scoreReportCode(secret, registration, exam, [...results].reverse());
+const changedScoreCode = scoreReportCode(secret, registration, exam, [{ ...results[0], score: 119 }, results[1]]);
+
+assert.match(scoreCode, /^SR-[A-F0-9]{24}$/);
+assert.equal(scoreCode, reorderedCode, '科目返回顺序不应改变同一成绩单的防伪码');
+assert.notEqual(scoreCode, changedScoreCode, '成绩变化必须使旧防伪码失效');
+assert.equal(safeCodeEqual(scoreCode, scoreCode.toLowerCase()), true);
+assert.equal(safeCodeEqual(scoreCode, `${scoreCode}0`), false);
+
+const placement = {
+ id: 'placement_1', userId: 'candidate_1', schoolId: 'school_1',
+ payload: { categoryCode: 'general' }, updatedAt: '2026-07-21T08:00:00.000Z'
+};
+const noticeCode = admissionNoticeCode(secret, placement, exam);
+const movedSchoolCode = admissionNoticeCode(secret, { ...placement, schoolId: 'school_2' }, exam);
+
+assert.match(noticeCode, /^AN-[A-F0-9]{24}$/);
+assert.notEqual(noticeCode, movedSchoolCode, '录取学校变化必须使旧通知书防伪码失效');
+
+assert.throws(
+ () => resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: 'too-short' }),
+ /至少 32 个字符/,
+ '生产环境不得静默使用弱密钥或开发回退值'
+);
+assert.equal(
+ resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: secret }),
+ secret
+);
+
+console.log('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');