From d029931c3167e74f22caa79e46ab76ed8ede115d Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 08:31:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=B2=E4=BC=AA+=E6=88=90=E7=BB=A9=E5=8D=95+?= =?UTF-8?q?=E5=BD=95=E5=8F=96=E9=80=9A=E7=9F=A5=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.docker.example | 5 +- .env.example | 3 + README.md | 5 +- app.js | 64 ++++++++++- package.json | 2 +- server.mjs | 8 ++ src/client/admin-views.mjs | 39 +++++-- src/client/admission-views.mjs | 16 ++- src/client/candidate-views.mjs | 11 +- src/client/pdf-export.mjs | 150 +++++++++++++++++++++++++ src/client/public-views.mjs | 12 +- src/client/state.mjs | 1 + src/client/table-state.mjs | 44 ++++++++ src/routes/admission.routes.mjs | 28 +++++ src/routes/candidate.routes.mjs | 18 ++- src/routes/public.routes.mjs | 27 +++++ src/security/document-verification.mjs | 28 +++++ styles.css | 83 ++++++++++++++ tests/document-verification.test.mjs | 42 +++++++ 19 files changed, 552 insertions(+), 34 deletions(-) create mode 100644 src/client/pdf-export.mjs create mode 100644 src/client/table-state.mjs create mode 100644 src/security/document-verification.mjs create mode 100644 tests/document-verification.test.mjs 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(`
固定报名号${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} 份
${preferencePage.items.map(item => ``).join('')}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('
')}
${formatDate(item.payload.submittedAt, true)}
${pagination(preferencePage)}
`); + container.insertAdjacentHTML('beforeend', `

考生志愿只读台账

仅超级管理员可查看;系统不提供任何管理员修改入口。

${data.preferences.length} 份
${preferencePage.items.map(item => ``).join('') || ''}
报名号 / 考生考试轮次志愿顺序本人提交时间
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}第 ${h(item.payload.round || 1)} 轮${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('
')}
${formatDate(item.payload.submittedAt, true)}
没有符合条件的志愿记录
${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)}
${schools.map(item => ``).join('') || ''}
学校 / 代码学校类型地址班级管理员考生考点状态操作
${h(item.name)}${h(item.code)}
${roles(item)}
${h(item.address || '未填写')}${item.classCount}${item.adminCount}${item.candidateCount}${item.centerCount}${badge(item.active ? 'approved' : 'closed')}
还没有学校,请先创建学校档案。
`; + return `
学校总数${schools.length}
启用生源校${sourceCount}
启用招生校${admissionCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
${schoolPage.items.map(item => ``).join('') || ''}
学校 / 代码学校类型地址班级管理员考生考点状态操作
${h(item.name)}${h(item.code)}
${roles(item)}
${h(item.address || '未填写')}${item.classCount}${item.adminCount}${item.candidateCount}${item.centerCount}${badge(item.active ? 'approved' : 'closed')}
没有符合条件的学校。
${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} 个
${accountRows || ''}
账户登录账号绑定招生学校状态创建时间账户操作
还没有招生学校账户
${pagination(accountPage)}
`; const planForm = `

代招生校上传计划

每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。

${admissionCategoriesEditor(h, data.sourceSchools)}
`; - const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; - const placements = `

投档与退档监督

志愿只读,管理员均无修改入口
${placementPage.items.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
${pagination(placementPage)}
`; + const plans = `

招生计划审核

核对类别资格、计划总数和指标分配。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; + const placements = `

投档与退档监督

支持跨页搜索考生、报名号、学校与类别;志愿保持只读。

共 ${data.placements.length} 条
${placementPage.items.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
${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 `

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

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

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

${rows || ''}
标题来源 / 分类发布人发布时间展示位置状态操作
暂无符合条件的通知或系统公示
${pagination(noticePage)}
`; } function adminAdmit(data) { @@ -329,28 +340,32 @@ export function createAdminViews(context) { } function adminUsers(data) { - return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

管理员账户不物理删除;停用会立即结束现有会话,并完整保留审批和审计记录。

${data.admins.map(item => { const self = item.id === state.user.id; return ``; }).join('')}
管理员登录账号层级绑定范围状态账户操作
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
`; + const adminPage = paged(data.admins || [], 'adminAccountTable', 20); + return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}

账户不物理删除,停用后历史审批记录仍保留。

${adminPage.items.map(item => { const self = item.id === state.user.id; return ``; }).join('') || ''}
管理员登录账号层级绑定范围状态账户操作
${h(item.displayName.slice(0, 1))}
${h(item.displayName)}${h(item.id)}
${h(item.username)}${h(item.levelName)}${h(item.schoolName || '全局')}${h(item.className || '')}${item.active ? badge('approved') : badge('closed')}
没有符合条件的管理员
${pagination(adminPage)}
`; } 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 => ``).join('')}
考场位置类型容量座位编排状态
${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')}
${h(center.notes || '无补充说明')}
`).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 => ``).join('')}
考场位置类型容量座位编排状态
${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')}
${h(center.notes || '无补充说明')}
`).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('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requests.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
暂无考点变更申请
`; + 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)}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requestPage.items.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
没有符合条件的考点变更申请
${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个账户
${classes.map(item => ``).join('')}

审批通过后生成固定报名号、随机初始密码、待补录考生账户

`; - const ledger = batches.map(batch => { + const ledger = batchPage.items.map(batch => { const resultRows = batch.status === 'approved' ? `
账号下发清单${batch.totalCount} 个账号 · 考生首次登录必须改密
${batch.items.map((item, index) => ``).join('')}
序号班级固定报名号 / 账户初始密码
${index + 1}${h(item.className)}${h(item.candidateNumber)}${h(item.initialPassword)}
` : ''; return `
${h(batch.id)}

${h(batch.schoolName)} · ${batch.totalCount} 个报名号

${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交

${badge(batch.status)}
${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 `
招生学校/${h(title)}
${h((state.user?.displayName || '招').slice(0,1))}${h(state.user?.displayName)}招生学校账号

SCHOOL ADMISSION

${h(title)}

${h(description)}

${content}
`; + const groups = [...new Set(nav.map(item => item[3]))]; + return `
招生学校/${h(title)}
${h((state.user?.displayName || '招').slice(0,1))}${h(state.user?.displayName)}招生学校账号

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 `

模板设计

正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}

${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}
${h(template.eyebrow || 'ADMISSION NOTICE')}

${h(template.title || '录 取 通 知 书')}

${h(data.school?.name)}

张同学:

${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}

${h(template.footer || '')}${h(data.school?.name)}
录取专用章

右侧为 A4 通知书预览;正式下载件会自动写入考生、考试、录取类别及防伪查询码。

`; + } + 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(items[0].examCode)}

${h(examName)}

${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}
当前总分${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}

${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}

已发布 ${items.length} 科
`; + const panel = `
${h(items[0].examCode)}

${h(examName)}

${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}
当前总分${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}

${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}

`; 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 `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].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 ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}
`}
`; + return `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].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 ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : 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 `
${brand()}
`; + return `
${brand()}
`; } 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()}

DOCUMENT AUTHENTICITY

文书防伪查询

输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。

${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('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');