防伪+成绩单+录取通知书
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(`<div class="modal-head"><div><span>CANDIDATE NUMBER</span><h2>请保存你的报名号</h2><p>该号码就是长期使用的考生账户。</p></div><button data-action="close-modal">×</button></div><div class="issued-number"><span>固定报名号</span><strong>${h(data.registrationNumber)}</strong><p>以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。</p></div><div class="modal-foot"><button class="solid-button" data-route="login">前往登录</button></div>`);
|
||||
} 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) => {
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+27
-12
@@ -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', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('')}</tbody></table></div>${pagination(preferencePage)}</section>`);
|
||||
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPreferenceTable" placeholder="搜索考生、报名号、考试、志愿学校或类别"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="adminPreferenceTable"><option value="">全部考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="adminPreferenceTable">清除筛选</button></div></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr data-exam="${h(item.examId)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的志愿记录</td></tr>'}</tbody></table></div>${pagination(preferencePage)}</section>`);
|
||||
}
|
||||
|
||||
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 ? '<span class="school-role source">生源校</span>' : ''}${item.isAdmissionSchool ? '<span class="school-role admission">招生校</span>' : ''}`;
|
||||
return `<section class="center-summary school-type-summary"><div><span>学校总数</span><strong>${schools.length}</strong></div><div><span>启用生源校</span><strong>${sourceCount}</strong></div><div><span>启用招生校</span><strong>${admissionCount}</strong></div><div><span>在册考生</span><strong>${schools.reduce((sum, item) => sum + item.candidateCount, 0)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolTable" placeholder="搜索学校名称、代码、类型或地址"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolTable" data-status="active">启用</button><button data-action="status-filter" data-target="schoolTable" data-status="inactive">停用</button></div></div><div class="table-scroll"><table id="schoolTable"><thead><tr><th>学校 / 代码</th><th>学校类型</th><th>地址</th><th>班级</th><th>管理员</th><th>考生</th><th>考点</th><th>状态</th><th>操作</th></tr></thead><tbody>${schools.map(item => `<tr data-status="${item.active ? 'active' : 'inactive'}"><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td><div class="school-role-tags">${roles(item)}</div></td><td>${h(item.address || '未填写')}</td><td>${item.classCount}</td><td>${item.adminCount}</td><td>${item.candidateCount}</td><td>${item.centerCount}</td><td>${badge(item.active ? 'approved' : 'closed')}</td><td><div class="candidate-account-actions"><button class="row-action" data-action="edit-school" data-id="${h(item.id)}">编辑</button><button class="row-action" data-action="toggle-school" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用' : '启用'}</button></div></td></tr>`).join('') || '<tr><td colspan="9" class="empty-state">还没有学校,请先创建学校档案。</td></tr>'}</tbody></table></div></section>`;
|
||||
return `<section class="center-summary school-type-summary"><div><span>学校总数</span><strong>${schools.length}</strong></div><div><span>启用生源校</span><strong>${sourceCount}</strong></div><div><span>启用招生校</span><strong>${admissionCount}</strong></div><div><span>在册考生</span><strong>${schools.reduce((sum, item) => sum + item.candidateCount, 0)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolTable" placeholder="搜索学校名称、代码、类型或地址"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolTable" data-status="active">启用</button><button data-action="status-filter" data-target="schoolTable" data-status="inactive">停用</button></div></div><div class="table-scroll"><table id="schoolTable"><thead><tr><th>学校 / 代码</th><th>学校类型</th><th>地址</th><th>班级</th><th>管理员</th><th>考生</th><th>考点</th><th>状态</th><th>操作</th></tr></thead><tbody>${schoolPage.items.map(item => `<tr data-status="${item.active ? 'active' : 'inactive'}"><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td><div class="school-role-tags">${roles(item)}</div></td><td>${h(item.address || '未填写')}</td><td>${item.classCount}</td><td>${item.adminCount}</td><td>${item.candidateCount}</td><td>${item.centerCount}</td><td>${badge(item.active ? 'approved' : 'closed')}</td><td><div class="candidate-account-actions"><button class="row-action" data-action="edit-school" data-id="${h(item.id)}">编辑</button><button class="row-action" data-action="toggle-school" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用' : '启用'}</button></div></td></tr>`).join('') || '<tr><td colspan="9" class="empty-state">没有符合条件的学校。</td></tr>'}</tbody></table></div>${pagination(schoolPage)}</section>`;
|
||||
}
|
||||
|
||||
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 => `<tr data-status="${account.active ? 'active' : 'disabled'}" data-school="${h(account.schoolId)}"><td><div class="person-cell"><span>${h((account.displayName || '招').slice(0, 1))}</span><div><strong>${h(account.displayName)}</strong><small>${h(account.id)}</small></div></div></td><td><strong class="mono">${h(account.username)}</strong></td><td><strong>${h(account.schoolName || '未绑定')}</strong><small class="mono">${h(account.schoolCode || '')}</small></td><td><span class="status status-${account.active ? 'approved' : 'closed'}">${account.active ? '已启用' : '已停用'}</span></td><td>${formatDate(account.createdAt, true)}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admission-account-password" data-id="${h(account.id)}">重置密码</button><button class="row-action ${account.active ? 'danger' : 'primary'}" data-action="toggle-admission-account" data-id="${h(account.id)}" data-active="${account.active ? 'false' : 'true'}">${account.active ? '停用账户' : '重新启用'}</button></div></td></tr>`).join('');
|
||||
const accounts = `<section class="panel admission-account-panel admission-account-management"><div class="panel-title"><div><h2>招生学校账户管理</h2><p>创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。</p></div><span>${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个</span></div><form data-form="admission-account"><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label><span>显示名称</span><input name="displayName" placeholder="学校招生办公室"></label><button class="solid-button" type="submit">创建招生学校账号</button></form><div class="account-management-divider"><strong>已建账户</strong><span>保留历史记录,不提供物理删除</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admissionAccountTable" placeholder="搜索当前页的学校、账号或显示名称"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="admissionAccountTable"><option value="">全部招生学校</option>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.name)}</option>`).join('')}</select></div></div><div class="filter-pills account-status-pills"><button type="button" class="active" data-action="status-filter" data-target="admissionAccountTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="active">已启用</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="disabled">已停用</button></div><div class="table-scroll"><table id="admissionAccountTable"><thead><tr><th>账户</th><th>登录账号</th><th>绑定招生学校</th><th>状态</th><th>创建时间</th><th>账户操作</th></tr></thead><tbody>${accountRows || '<tr><td colspan="6" class="empty-state">还没有招生学校账户</td></tr>'}</tbody></table></div>${pagination(accountPage)}</section>`;
|
||||
const planForm = `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>代招生校上传计划</h2><p>每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。</p></div></div><form data-form="admission-plan"><div class="field-row"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label></div>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="可填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">代上传并审核通过</button></form></section>`;
|
||||
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="table-scroll"><table id="adminAdmissionPlanTable"><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${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('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
const placements = `<section class="panel data-panel"><div class="panel-title"><h2>投档与退档监督</h2><span>志愿只读,管理员均无修改入口</span></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminAdmissionPlanTable" placeholder="搜索考试、学校、招生类别或指标学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="adminAdmissionPlanTable"><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${planPage.items.map(plan => `<tr data-status="${h(plan.status)}"><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${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('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
const placements = `<section class="panel data-panel"><div class="panel-title"><div><h2>投档与退档监督</h2><p>支持跨页搜索考生、报名号、学校与类别;志愿保持只读。</p></div><span>共 ${data.placements.length} 条</span></div><div class="data-toolbar placement-supervision-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPlacementTable" placeholder="搜索考生、报名号、投档学校、类别或退档理由"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminPlacementTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="school_review">学校审核中</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawal_pending">退档待审</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="final">正式录取</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawn">已退档</button></div><button class="row-action" data-action="clear-table-filters" data-target="adminPlacementTable">清除筛选</button></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr data-status="${h(item.status)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
return `<section class="admission-command-banner"><div><span>ADMISSION COMMAND</span><h2>中考招生录取控制台</h2><p>学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。</p></div><dl><div><dt>待审计划</dt><dd>${pendingPlans.length}</dd></div><div><dt>学校审核中</dt><dd>${data.placements.filter(item => item.status === 'school_review').length}</dd></div><div><dt>退档待审</dt><dd>${withdrawals.length}</dd></div><div><dt>正式录取</dt><dd>${data.placements.filter(item => item.status === 'final').length}</dd></div></dl></section><div class="admission-admin-grid single">${settings}</div>${accounts}${planForm}${plans}${placements}`;
|
||||
}
|
||||
|
||||
@@ -250,9 +253,17 @@ export function createAdminViews(context) {
|
||||
}
|
||||
|
||||
function adminNotices(notices, publications = []) {
|
||||
const noticeRows = notices.map(notice => `<tr data-filter-row data-status="ordinary ${h(notice.status)} ${notice.status === 'published' ? 'visible' : ''}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td><strong>手动通知</strong><small>${h(notice.category)}</small></td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : notice.status === 'published' ? '通知目录' : '尚未展示'}</td><td>${badge(notice.status)}</td><td><div class="notice-row-actions">${notice.status === 'draft' ? `<button class="row-action primary" data-action="edit-notice" data-id="${h(notice.id)}">编辑</button>` : ''}<button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '隐藏' : '发布'}</button></div></td></tr>`).join('');
|
||||
const publicationRows = publications.map(item => `<tr data-filter-row data-status="system ${h(item.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>自动公示</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishedAt,true)}</td><td>通知目录</td><td>${badge(item.status)}</td><td><button class="row-action ${item.visible ? '' : 'primary'}" data-action="toggle-publication" data-id="${h(item.id)}" data-source-type="${h(item.sourceType)}" data-visible="${item.visible ? 'false' : 'true'}">${item.visible ? '隐藏' : '显示'}</button></td></tr>`).join('');
|
||||
return `<section class="panel data-panel"><div class="data-toolbar notice-admin-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知、公示标题或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="visible">正在显示</button><button data-action="status-filter" data-target="noticeTable" data-status="hidden">已隐藏</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="noticeTable" data-status="system">自动公示</button></div><p>自动公示的内容由招生录取流程生成,这里只控制是否公开显示。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>标题</th><th>来源 / 分类</th><th>发布人</th><th>发布时间</th><th>展示位置</th><th>状态</th><th>操作</th></tr></thead><tbody>${noticeRows}${publicationRows || (!noticeRows ? '<tr><td colspan="7" class="empty-state">暂无通知或系统公示</td></tr>' : '')}</tbody></table></div></section>`;
|
||||
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 `<tr data-filter-row data-status="${h(entry.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>手动通知</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishAt || item.createdAt,true)}</td><td>${item.pinned ? '<span class="pin-label">首页置顶</span>' : item.status === 'published' ? '通知目录' : '尚未展示'}</td><td>${badge(item.status)}</td><td><div class="notice-row-actions">${item.status === 'draft' ? `<button class="row-action primary" data-action="edit-notice" data-id="${h(item.id)}">编辑</button>` : ''}<button class="row-action" data-action="toggle-notice" data-id="${h(item.id)}" data-status="${item.status === 'published' ? 'draft' : 'published'}">${item.status === 'published' ? '隐藏' : '发布'}</button></div></td></tr>`;
|
||||
return `<tr data-filter-row data-status="${h(entry.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>自动公示</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishedAt,true)}</td><td>通知目录</td><td>${badge(item.status)}</td><td><button class="row-action ${item.visible ? '' : 'primary'}" data-action="toggle-publication" data-id="${h(item.id)}" data-source-type="${h(item.sourceType)}" data-visible="${item.visible ? 'false' : 'true'}">${item.visible ? '隐藏' : '显示'}</button></td></tr>`;
|
||||
}).join('');
|
||||
return `<section class="panel data-panel"><div class="data-toolbar notice-admin-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索全部通知、公示标题或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="visible">正在显示</button><button data-action="status-filter" data-target="noticeTable" data-status="hidden">已隐藏</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="noticeTable" data-status="system">自动公示</button></div><p>自动公示的内容由招生录取流程生成,这里只控制是否公开显示。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>标题</th><th>来源 / 分类</th><th>发布人</th><th>发布时间</th><th>展示位置</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无符合条件的通知或系统公示</td></tr>'}</tbody></table></div>${pagination(noticePage)}</section>`;
|
||||
}
|
||||
|
||||
function adminAdmit(data) {
|
||||
@@ -329,28 +340,32 @@ export function createAdminViews(context) {
|
||||
}
|
||||
|
||||
function adminUsers(data) {
|
||||
return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel admin-account-ledger"><div class="data-toolbar"><p>管理员账户不物理删除;停用会立即结束现有会话,并完整保留审批和审计记录。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th><th>账户操作</th></tr></thead><tbody>${data.admins.map(item => { const self = item.id === state.user.id; return `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admin-password" data-id="${h(item.id)}" ${self ? 'disabled title="当前账号请到账户安全修改密码"' : ''}>重置密码</button><button class="row-action ${item.active ? 'danger' : 'primary'}" data-action="toggle-admin-account" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}" ${self ? 'disabled title="不能停用当前账号"' : ''}>${item.active ? '停用账户' : '重新启用'}</button></div></td></tr>`; }).join('')}</tbody></table></div></section>`;
|
||||
const adminPage = paged(data.admins || [], 'adminAccountTable', 20);
|
||||
return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel admin-account-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminAccountTable" placeholder="搜索姓名、账号、层级、学校或班级"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminAccountTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminAccountTable" data-status="active">已启用</button><button data-action="status-filter" data-target="adminAccountTable" data-status="disabled">已停用</button></div><p>账户不物理删除,停用后历史审批记录仍保留。</p></div><div class="table-scroll"><table id="adminAccountTable"><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th><th>账户操作</th></tr></thead><tbody>${adminPage.items.map(item => { const self = item.id === state.user.id; return `<tr data-status="${item.active ? 'active' : 'disabled'}"><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admin-password" data-id="${h(item.id)}" ${self ? 'disabled title="当前账号请到账户安全修改密码"' : ''}>重置密码</button><button class="row-action ${item.active ? 'danger' : 'primary'}" data-action="toggle-admin-account" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}" ${self ? 'disabled title="不能停用当前账号"' : ''}>${item.active ? '停用账户' : '重新启用'}</button></div></td></tr>`; }).join('') || '<tr><td colspan="6" class="empty-state">没有符合条件的管理员</td></tr>'}</tbody></table></div>${pagination(adminPage)}</section>`;
|
||||
}
|
||||
|
||||
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 => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||||
const centerPage = paged(data.centers, 'centerDossierGrid', 20);
|
||||
const cards = centerPage.items.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
|
||||
data.centers.forEach(center => { center.address = detailAddresses.get(center.id); });
|
||||
const requests = data.changeRequests || [];
|
||||
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
|
||||
const requestPage = paged(requests, 'centerChangeTable', 20);
|
||||
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="panel data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="centerDossierGrid" placeholder="搜索考点、学校、考场、负责人或地址"></label></div><div class="center-dossier-grid" id="centerDossierGrid">${cards || emptyState('没有符合条件的考点', '可清除搜索后查看全部正式考点。')}</div>${pagination(centerPage)}<section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="centerChangeTable" placeholder="搜索变更类型、考点、学校或责任人"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="centerChangeTable" data-status="all">全部</button><button data-action="status-filter" data-target="centerChangeTable" data-status="pending">待审批</button><button data-action="status-filter" data-target="centerChangeTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="centerChangeTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="centerChangeTable"><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requestPage.items.map(item => `<tr data-status="${h(item.status)}"><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">没有符合条件的考点变更申请</td></tr>'}</tbody></table></div>${pagination(requestPage)}</section>`;
|
||||
}
|
||||
|
||||
function adminAccountBatches(data) {
|
||||
const classes = data.classes || [];
|
||||
const batches = data.batches || [];
|
||||
const batchPage = paged(batches, 'accountBatchLedger', 20);
|
||||
const form = `<section class="panel batch-quota-panel"><div class="batch-quota-head"><div><span>SCHOOL ACCOUNT REQUEST</span><h2>按班级申领报名号</h2><p>只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。</p></div><div><small>单批上限</small><strong>500</strong><span>个账户</span></div></div><form data-form="candidate-account-batch"><div class="quota-grid">${classes.map(item => `<label><span><strong>${h(item.name)}</strong><small>${h(item.grade)}</small></span><span class="quota-input"><input type="number" min="0" max="200" value="0" data-class-id="${h(item.id)}"><em>人</em></span></label>`).join('')}</div><div class="batch-submit-bar"><p><strong>审批通过后生成</strong><span>固定报名号、随机初始密码、待补录考生账户</span></p><button class="solid-button" type="submit">提交批量申领</button></div></form></section>`;
|
||||
const ledger = batches.map(batch => {
|
||||
const ledger = batchPage.items.map(batch => {
|
||||
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="excel-download" data-resource="account_results" data-batch-id="${h(batch.id)}">导出 Excel</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
|
||||
return `<article class="panel account-batch-card ${h(batch.status)}"><header><div><span class="mono">${h(batch.id)}</span><h2>${h(batch.schoolName)} · ${batch.totalCount} 个报名号</h2><p>${formatDate(batch.createdAt, true)} 由 ${h(batch.requesterName)} 提交</p></div>${badge(batch.status)}</header><div class="batch-quota-summary">${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><em>${item.count} 人</em></span>`).join('')}</div><div class="batch-flow-line"><span>当前进度</span><strong>${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}</strong><small>${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}</small></div>${batch.reviewNote ? `<div class="batch-review-note"><strong>审批意见</strong><span>${h(batch.reviewNote)}</span></div>` : ''}${resultRows}</article>`;
|
||||
}).join('');
|
||||
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('还没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
|
||||
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div><div class="panel data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="accountBatchLedger" placeholder="搜索学校、班级、批次号、提交人或状态"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="accountBatchLedger" data-status="all">全部</button><button data-action="status-filter" data-target="accountBatchLedger" data-status="pending">审批中</button><button data-action="status-filter" data-target="accountBatchLedger" data-status="approved">已通过</button><button data-action="status-filter" data-target="accountBatchLedger" data-status="rejected">已退回</button></div></div><div id="accountBatchLedger">${ledger || emptyState('没有符合条件的申领批次', '可清除筛选后查看全部记录。')}</div>${pagination(batchPage)}</section>`;
|
||||
}
|
||||
|
||||
function adminNumberRules(data) {
|
||||
|
||||
@@ -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 `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav>${nav.map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
|
||||
const groups = [...new Set(nav.map(item => item[3]))];
|
||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav class="portal-nav-groups">${groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(item => item[3] === group).map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</section>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
|
||||
}
|
||||
|
||||
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, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...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 `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><i>录取专用章</i></div><p>右侧为 A4 通知书预览;正式下载件会自动写入考生、考试、录取类别及防伪查询码。</p></article></section>`;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -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 `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||
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 => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</section>`).join('');
|
||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav class="portal-nav-groups">${navHtml}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||
}
|
||||
|
||||
function portalHeadingAction(role, page) {
|
||||
@@ -188,7 +193,7 @@ export function createCandidateViews(context) {
|
||||
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
|
||||
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
|
||||
}).join('');
|
||||
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>特征分</small><strong>${h(summary?.featureScore ?? 0)}</strong><i>独立于考试科目</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}</p><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
||||
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>特征分</small><strong>${h(summary?.featureScore ?? 0)}</strong><i>独立于考试科目</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}</p><div class="result-footer-actions"><strong>已发布 ${items.length} 科</strong><button class="solid-button" data-action="download-score-report" data-exam-id="${h(items[0].examId)}">下载 PDF 成绩单</button></div></footer></section>`;
|
||||
return items[0].archivedAt ? `<details class="candidate-archive-fold result-archive-fold"><summary><span><strong>${h(examName)}</strong><small>${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定</small></span><b>历史成绩</b></summary>${panel}</details>` : panel;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
@@ -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 `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b>${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).join('');
|
||||
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
|
||||
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
|
||||
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small></div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
|
||||
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,可下载正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function createPublicViews(context) {
|
||||
}
|
||||
|
||||
function publicHeader() {
|
||||
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#verify" data-route="verify">文书防伪查询</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
@@ -100,6 +100,14 @@ export function createPublicViews(context) {
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
|
||||
}
|
||||
|
||||
function renderVerification(code = '', result = null, error = '') {
|
||||
app.classList.remove('admin-readable');
|
||||
const organization = state.publicData.organization || {};
|
||||
const document = result?.document;
|
||||
const outcome = document ? `<section class="verification-result verified"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>该查询码由系统签发,当前数据与签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>${h(document.typeName)}</dd></div><div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main verification-page"><section class="verification-hero"><div><p class="overline">DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><p>输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。</p></div><form data-form="document-verification"><label><span>防伪查询码</span><input name="code" value="${h(code)}" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button class="solid-button" type="submit">立即核验 ${icons.arrow}</button></form></section>${outcome}<section class="verification-notice"><strong>安全提示</strong><p>查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。</p></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>系统签名实时核验</span></footer>`;
|
||||
}
|
||||
|
||||
function loginForm() {
|
||||
return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
@@ -109,5 +117,5 @@ export function createPublicViews(context) {
|
||||
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
|
||||
return { brand, renderHome, renderNoticeCenter, renderAuth };
|
||||
return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ export const state = {
|
||||
resultExamCatalog: null,
|
||||
resultImportPreview: null,
|
||||
tablePages: {},
|
||||
tableFilters: {},
|
||||
loading: false
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+83
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
|
||||
Reference in New Issue
Block a user