已完成迁移验收和清理。

删除全部 Node.js 后端、.mjs 测试、辅助脚本、package*.json、node_modules 和迁移对照工具。
浏览器前端保留并迁入 [Eis.Web/wwwroot](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/wwwroot/index.html),统一改为 .js 模块。
修复一个隐藏遗漏:.NET 仍依赖 schema.mjs,且 MySQL 结构解析可能被转义反引号截断。现已改为原生 [SQLite SQL](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/Schema.sqlite.sql) 和 [MySQL SQL](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/Schema.mysql.sql) 资源。
更新了 [DatabaseInitializer.cs](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseInitializer.cs)、项目配置、README 和迁移文档。
data 目录和现有数据库未修改;测试仅使用系统临时 SQLite 数据库。
This commit is contained in:
2026-07-23 17:49:57 +08:00 Unverified
parent f3ffc18aae
commit b139834396
79 changed files with 695 additions and 18757 deletions
@@ -9,8 +9,7 @@ public static class ApplicationPaths
var directory = new DirectoryInfo(Path.GetFullPath(start));
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")) ||
File.Exists(Path.Combine(directory.FullName, "package.json")))
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")))
{
return directory.FullName;
}
-8
View File
@@ -8,12 +8,4 @@
<ProjectReference Include="..\Eis.Application\Eis.Application.csproj" />
<ProjectReference Include="..\Eis.Infrastructure\Eis.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\index.html" Link="wwwroot\index.html" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\styles.css" Link="wwwroot\styles.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\app.js" Link="wwwroot\app.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\client\**\*.mjs" Link="wwwroot\src\client\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\china-regions.mjs" Link="wwwroot\src\data\china-regions.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\src\data\specialty-types.mjs" Link="wwwroot\src\data\specialty-types.mjs" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+3 -67
View File
@@ -1,78 +1,14 @@
using Microsoft.AspNetCore.StaticFiles;
namespace Eis.Web.Frontend;
public static class FrontendAssets
{
private static readonly string[] ClientModules =
[
"api.mjs",
"admin-views.mjs",
"candidate-views.mjs",
"admission-views.mjs",
"admission-plan-editor.mjs",
"public-views.mjs",
"state.mjs",
"ui.mjs",
"table-state.mjs",
"pdf-export.mjs",
"region-select.mjs"
];
public static void MapFrontendAssets(this WebApplication app)
{
app.UseDefaultFiles();
app.UseStaticFiles(CreateStaticOptions());
var repositoryRoot = FindRepositoryRoot(app.Environment.ContentRootPath);
if (repositoryRoot is null)
app.UseStaticFiles(new StaticFileOptions
{
app.MapFallbackToFile("index.html");
return;
}
var clientRoot = Path.Combine(repositoryRoot, "src", "client");
foreach (var fileName in ClientModules)
{
MapFile(app, $"/src/client/{fileName}", Path.Combine(clientRoot, fileName), "text/javascript; charset=utf-8");
}
MapFile(app, "/src/data/china-regions.mjs", Path.Combine(repositoryRoot, "src", "data", "china-regions.mjs"), "text/javascript; charset=utf-8");
MapFile(app, "/src/data/specialty-types.mjs", Path.Combine(repositoryRoot, "src", "data", "specialty-types.mjs"), "text/javascript; charset=utf-8");
MapFile(app, "/styles.css", Path.Combine(repositoryRoot, "styles.css"), "text/css; charset=utf-8");
MapFile(app, "/app.js", Path.Combine(repositoryRoot, "app.js"), "text/javascript; charset=utf-8");
var indexPath = Path.Combine(repositoryRoot, "index.html");
MapFile(app, "/", indexPath, "text/html; charset=utf-8");
MapFile(app, "/index.html", indexPath, "text/html; charset=utf-8");
app.MapFallback(async context =>
{
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.Headers.CacheControl = "no-cache";
await context.Response.SendFileAsync(indexPath, context.RequestAborted);
OnPrepareResponse = context => context.Context.Response.Headers.CacheControl = "no-cache"
});
}
private static void MapFile(WebApplication app, string route, string path, string contentType) =>
app.MapGet(route, (HttpContext context) =>
{
context.Response.Headers.CacheControl = "no-cache";
return Results.File(path, contentType);
});
private static StaticFileOptions CreateStaticOptions() => new()
{
OnPrepareResponse = SetNoCache
};
private static void SetNoCache(StaticFileResponseContext context) =>
context.Context.Response.Headers.CacheControl = "no-cache";
private static string? FindRepositoryRoot(string contentRoot)
{
var candidate = Path.GetFullPath(Path.Combine(contentRoot, "..", ".."));
return File.Exists(Path.Combine(candidate, "package.json")) && File.Exists(Path.Combine(candidate, "index.html"))
? candidate
: null;
app.MapFallbackToFile("index.html");
}
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#132451" />
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
<title>衡准 · 考试信息管理系统</title>
<link rel="stylesheet" href="/styles.css?v=20260723" />
</head>
<body>
<div id="app" aria-live="polite">
<div class="boot-screen">
<div class="brand-symbol"><i></i><i></i><i></i></div>
<strong>衡准</strong>
<span>系统正在加载</span>
</div>
</div>
<div id="modalRoot"></div>
<div class="toast" id="toast" role="status" aria-live="polite">
<span class="toast-icon"></span>
<div><strong>操作成功</strong><small>更改已保存</small></div>
</div>
<script type="module" src="/js/app.js?v=20260723"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
import { specialtyCatalog } from '../data/specialty-types.js';
export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) {
return `<div class="indicator-allocation-row"><label><span>生源学校</span><select name="indicatorSchool"><option value="">请选择生源校</option>${sourceSchools.map(school => `<option value="${h(school.id)}" ${school.id === allocation.sourceSchoolId ? 'selected' : ''}>${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><label><span>分配名额</span><input name="indicatorQuota" type="number" min="1" step="1" value="${h(allocation.quota || '')}" placeholder="人数"></label><button type="button" class="row-action" data-action="remove-indicator-allocation">移除</button></div>`;
}
export function admissionCategoryEditor(h, sourceSchools = [], category = {}) {
const specialty = Boolean(category.specialtyCategory);
const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory);
return `<article class="admission-category-editor"><header><div><span>招生类别</span><strong>${h(category.name || '新类别')}</strong></div><button type="button" data-action="remove-admission-category">移除类别</button></header><div class="admission-category-fields"><label><span>类别名称 *</span><input name="categoryName" required maxlength="80" value="${h(category.name || '')}" placeholder="例如:普通生、艺术特长生"></label><label><span>计划人数 *</span><input name="categoryQuota" required type="number" min="1" step="1" value="${h(category.quota || '')}" placeholder="人数"></label><label><span>类别性质</span><select name="categoryKind" data-action="plan-category-kind"><option value="general" ${specialty ? '' : 'selected'}>普通 / 政策类</option><option value="specialty" ${specialty ? 'selected' : ''}>特长生</option></select></label><div class="specialty-plan-fields ${specialty ? '' : 'hidden'}" data-plan-specialty><label><span>特长大类 *</span><select name="categorySpecialtyCategory" data-action="specialty-category" ${specialty ? '' : 'disabled'}><option value="">请选择大类</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyCategory ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长小类 *</span><select name="categorySpecialtyType" data-specialty-type ${specialty && selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyType ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div></div><section class="indicator-allocation-editor"><div class="indicator-allocation-head"><div><strong>指标分配</strong><small>可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。</small></div><button type="button" class="row-action" data-action="add-indicator-allocation">添加生源校指标</button></div><div data-indicator-allocations>${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}</div></section></article>`;
}
export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) {
const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }];
return `<section class="admission-categories-builder"><div class="admission-builder-head"><div><strong>招生类别与计划</strong><small>逐项设置类别、资格范围和生源校指标。</small></div><button type="button" class="row-action primary" data-action="add-admission-category">添加招生类别</button></div><div data-admission-categories>${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}</div></section>`;
}
@@ -0,0 +1,87 @@
export function createAdmissionViews(context) {
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['reporting','考生报到','users','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
function shell(page, content, title, description) {
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:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], reporting:['考生报到','暂存报到状态,支持 Excel 批量维护和通知书二维码核验。'], '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) : page === 'placements' ? placements(data) : page === 'reporting' ? reporting(data) : noticeTemplate(data);
app.innerHTML = shell(page, content, ...meta[page]);
} catch (error) { renderError(error); }
}
function dashboard(data) {
const progress = data.plans || [];
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section>${progress.length ? `<section class="admission-progress-grid">${progress.map(plan => `<article><header><span>${h(plan.examName)}</span><strong>${h(plan.progress.admissionRate)}%</strong></header><div class="progress-meter"><i style="width:${Math.min(100, plan.progress.admissionRate)}%"></i></div><p>计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人</p><small>实际报到完成率 ${h(plan.progress.reportingRate)}%</small></article>`).join('')}</section>` : ''}<div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button><button data-route="admission_school/reporting"><i>到</i><span><strong>登记考生报到</strong><small>暂存、Excel 导入或扫描通知书二维码</small></span>${icons.arrow}</button></section>${data.notifications?.length ? `<section class="panel compact-notices"><div class="panel-title"><h2>系统自动通知</h2><span>${data.notifications.length} 条</span></div>${data.notifications.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section>` : ''}</div>`;
}
function reporting(data) {
if (!data.batches?.length) return `<section class="panel empty-state"><h2>暂无报到批次</h2><p>超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。</p></section>`;
const statusLabels = { draft: '暂存中', submitted: '报到已提交', pending_approval: '补录决定待审批', approved: '已审批并公示', rejected: '审批退回', not_started: '尚未开始' };
return data.batches.map(batch => {
const key = `reporting-${batch.exam.id}-${batch.round}`;
const page = paged(batch.rows, key, 20);
const editable = ['draft', 'rejected'].includes(batch.status);
const importSummary = state.reportingImportSummaries?.[batch.exam.id];
const rowHtml = page.items.map(item => `<tr>${editable ? `<td class="selection-cell"><input type="checkbox" data-reporting-select value="${h(item.placementId)}" aria-label="选择 ${h(item.name)}"></td>` : ''}<td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join('');
const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`;
const bulkTools = editable ? `<div class="reporting-bulk-bar" data-reporting-bulk data-table-id="${h(key)}"><label class="bulk-check"><input type="checkbox" data-reporting-select-all data-table-id="${h(key)}"><span>全选本页</span></label><strong data-reporting-selected-count>已选 0 人</strong><label><span>统一状态</span><select data-reporting-bulk-status><option value="reported">Y · 确认报到</option><option value="not_reported">N · 确认未报到</option><option value="pending">P · 待确认</option></select></label><label class="bulk-note"><span>统一备注(留空则保留原备注)</span><input data-reporting-bulk-note placeholder="例如:现场核验通过"></label><button type="button" class="ghost-button" data-action="bulk-reporting-apply">应用到所选</button></div>` : '';
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div>${bulkTools}<div class="table-scroll"><table id="${h(key)}"><thead><tr>${editable ? '<th class="selection-cell">选择</th>' : ''}<th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || `<tr><td colspan="${editable ? '5' : '4'}" class="empty-state">本轮没有正式录取考生</td></tr>`}</tbody></table></div>${pagination(page)}`;
const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`;
return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div class="reporting-excel-tool"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><div class="tool-buttons"><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label></div>${importSummary ? `<div class="reporting-import-summary ${importSummary.changedCount ? 'changed' : 'unchanged'}"><strong>${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)}` : '最近导入没有产生变化'}</strong><span>读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行</span>${importSummary.changes?.length ? `<small>${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}${h(item.fromCode)}${h(item.toCode)}`).join('')}</small>` : '<small>Excel 内容与当前暂存状态一致。</small>'}</div>` : ''}</div><div class="reporting-scan-tool"><div><strong>通知书二维码核验</strong><small>打开实时相机扫描;识别后先核对考生,再点击暂存。</small></div><button type="button" class="camera-button" data-action="open-reporting-camera" data-exam-id="${h(batch.exam.id)}">${icons.camera || ''}<span>打开相机扫码</span></button><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="也可粘贴 AN 防伪码或二维码链接" required><button class="ghost-button" type="submit">核验</button></form></div></section>` : ''}${ledgerBlock}</section>`;
}).join('');
}
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;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages);
state.tablePages[key] = { page, pageSize };
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><div class="template-notice-number">通知书编号:AD01-EX-2026-ZK-000001</div><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><div class="template-qr-placeholder">防伪二维码</div></div><p>右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。</p></article></section>`;
}
function pagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
const end = Math.min(meta.total, meta.page * meta.pageSize);
const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages);
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button>${pages.map((page, index) => `${index && page - pages[index - 1] > 1 ? '<i>…</i>' : ''}<button type="button" class="${page === meta.page ? 'active' : ''}" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${page}">${page}</button>`).join('')}<button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
}
function plans(data) {
const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolAdmissionPlanTable" placeholder="跨页搜索考试、类别、指标学校或审核意见"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>实时完成率</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td><strong>${h(plan.progress?.admissionRate || 0)}%</strong><small>正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}</small><small>实际报到 ${h(plan.progress?.reportingRate || 0)}%</small></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>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
}
function placements(data) {
const exportBar = data.completedExams?.length ? `<section class="panel admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息 Excel</strong><small>仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。</small></div><label><span>已完成考试</span><select name="exportExamId">${data.completedExams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><button class="solid-button" data-action="download-admitted-candidates">下载 Excel</button></section>` : '';
const exams = [...new Map(data.placements.map(item => [item.examId, item.examName])).entries()];
const categories = [...new Set(data.placements.map(item => item.payload.categoryName).filter(Boolean))];
const pendingCount = data.placements.filter(item => item.status === 'school_review').length;
const placementPage = paged(data.placements, 'placementReviewTable');
const rows = placementPage.items.map(item => `<tr data-status="${h(item.status)}" data-exam="${h(item.examId)}" data-category="${h(item.payload.categoryName)}"><td><input type="checkbox" data-placement-select value="${h(item.id)}" ${item.status === 'school_review' ? '' : 'disabled'} aria-label="选择 ${h(item.candidate.name)}"></td><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small><small>${h(item.examName)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写至少 8 字理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('');
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="跨页搜索姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</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(placementPage)}</section>`;
}
return { renderAdmission };
}
import { admissionCategoriesEditor } from './admission-plan-editor.js';
import { specialtyLabel } from '../data/specialty-types.js';
import { filterTableItems } from './table-state.js';
+34
View File
@@ -0,0 +1,34 @@
const pendingReads = new Map();
async function request(path, options) {
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
const response = await fetch(path, {
credentials: 'same-origin',
headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers },
...options,
body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body
});
const type = response.headers.get('content-type') || '';
const data = type.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) {
const error = new Error(data?.message || '操作未完成,请稍后重试');
error.status = response.status;
throw error;
}
return data;
}
export function api(path, options = {}) {
const method = String(options.method || 'GET').toUpperCase();
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
// A quick double click or repeated render must not download and parse the
// same large JSON response more than once while the first request is active.
const key = String(path);
if (pendingReads.has(key)) return pendingReads.get(key);
const loading = request(path, options).finally(() => {
if (pendingReads.get(key) === loading) pendingReads.delete(key);
});
pendingReads.set(key, loading);
return loading;
}
@@ -0,0 +1,247 @@
import { mountRegionSelects } from './region-select.js';
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.js';
export function createCandidateViews(context) {
const {
state,
app,
h,
formatDate,
dateRange,
badge,
money,
passPolicyText,
statusLabels,
icons,
api,
renderError,
requireLogin,
emptyState,
brand
} = context;
const candidateNav = [
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'],
['security', '账户安全', 'user']
];
function adminNavForUser() {
const level = state.user?.adminLevel || 'super';
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
const security = ['security', '账户安全', 'user'];
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security];
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security];
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['admission-settings', '录取设置', 'check'], ['admission-accounts', '招生账户', 'users'], ['admission-plans', '招生计划', 'exam'], ['admission-reporting', '报到与补录', 'bell'], ['admission-supervision', '投档监督', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], security];
}
function portalShell(role, page, content, title, description) {
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心';
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
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: '招生录取', 'admission-settings': '招生录取', 'admission-accounts': '招生录取', 'admission-plans': '招生录取', 'admission-reporting': '招生录取', 'admission-supervision': '招生录取', '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) {
if (role === 'admin' && page === 'notices') return `<button class="solid-button" data-action="new-notice">${icons.plus} 发布通知</button>`;
if (role === 'admin' && page === 'exams') return `<button class="solid-button" data-action="new-exam">${icons.plus} 创建考试</button>`;
if (role === 'admin' && page === 'schools') return `<button class="solid-button" data-action="new-school">${icons.plus} 创建学校</button>`;
if (role === 'admin' && page === 'admins') return `<button class="solid-button" data-action="new-admin">${icons.plus} 添加管理员</button>`;
if (role === 'admin' && page === 'centers') return `<button class="solid-button" data-action="new-center">${icons.plus} 提交新考点</button>`;
if (role === 'admin' && page === 'organization') return `<button class="solid-button" data-action="new-school-class">${icons.plus} 新增班级</button>`;
if (role === 'candidate' && page === 'profile') return `<span class="heading-status">当前状态 ${badge(state.profile?.status || 'pending')}</span>`;
return '';
}
function loadingPanel() {
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
}
function mountAdmissionProfileFields(profile = {}) {
const actions = app.querySelector('.profile-form .form-actions');
if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
const qualification = resolveProfileSpecialty(profile);
const selectedCategory = specialtyCatalog.find(item => item.code === qualification.category);
actions.insertAdjacentHTML('beforebegin', `<div class="form-section-title" data-admission-profile-fields><span>04</span><div><h2>中考招生资格</h2><p>特长资格按大类和小类登记,填志愿时系统只显示与本人资格相符的招生类别。</p></div></div><div class="form-grid specialty-qualification-grid"><label><span>特长生大类</span><select name="specialtyCategory" data-action="specialty-category"><option value="">无特长生资格</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === qualification.category ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长生小类</span><select name="specialtyType" data-specialty-type ${selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === qualification.type ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长证明编号</span><input name="specialtyCertificate" value="${h(profile.specialtyCertificate || '')}" placeholder="证书或统一测试材料编号"></label><label class="wide-field"><span>政策资格说明</span><input name="policyEligibility" value="${h(profile.policyEligibility || '')}" placeholder="例如:指标生资格已核验"></label></div>`);
}
function onboardingShell(stage, content) {
const passwordDone = stage !== 'password';
return `<main class="onboarding-page"><aside class="onboarding-identity">${brand()}<span>固定报名号</span><strong>${h(state.user.candidateNumber)}</strong><p>这个号码就是你的考生账户。以后参加不同考试,仍然使用同一个报名号。</p><div class="onboarding-steps"><div class="${stage === 'password' ? 'current' : 'done'}"><i>${passwordDone ? '✓' : '1'}</i><span><b>修改初始密码</b><small>设置仅本人知道的新密码</small></span></div><div class="${stage === 'profile' ? 'current' : passwordDone ? '' : ''}"><i>2</i><span><b>补全个人信息</b><small>实名、籍贯、住址和学籍信息</small></span></div><div><i>3</i><span><b>等待资料审核</b><small>审核通过后开始考试报名</small></span></div></div><button data-action="logout">退出当前账户</button></aside><section class="onboarding-work"><div class="onboarding-work-head"><span>FIRST SIGN-IN</span><h1>${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}</h1><p>${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}</p></div>${content}</section></main>`;
}
function passwordOnboardingForm() {
return `<section class="panel password-onboarding"><div class="password-rule"><b>新密码要求</b><span>至少 8 位,且不能与初始密码相同。</span></div><form class="stack-form" data-form="candidate-password"><label><span>当前初始密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>设置新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码并继续 ${icons.arrow}</button></form></section>`;
}
async function renderCandidate(page) {
if (state.user?.role !== 'candidate') return requireLogin();
app.classList.remove('admin-readable');
if (state.user.mustChangePassword) {
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
return;
}
if (!state.profile?.profileCompleted) {
try {
const data = await api('/api/candidate/profile');
state.pageData = data; state.profile = data.profile;
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
mountAdmissionProfileFields(data.profile);
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
} catch (error) { renderError(error); }
return;
}
const meta = {
dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'],
profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'],
exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'],
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'],
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
security: ['账户安全', '使用当前密码设置新的登录密码。']
};
if (!meta[page]) page = 'dashboard';
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
const data = page === 'notices' ? await api('/api/candidate/notices') : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
state.pageData = data;
if (data.profile) state.profile = data.profile;
const content = {
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
results: () => candidateResults(data), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
}[page]();
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); }
} catch (error) { renderError(error); }
}
function candidateDashboard(data) {
const registration = data.registrations[0];
const steps = [
['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'],
['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'],
['考试报名', Boolean(registration), registration ? '已报名' : '未报名'],
['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'],
['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length}` : '待发布']
];
return `<section class="candidate-welcome"><div><span>${new Date().getHours() < 12 ? '上午好' : '下午好'}</span><h2>${h(data.profile?.name || state.user.displayName)},下一步已为你标出。</h2><p>${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}</p></div><div class="welcome-seal">准<br>考</div></section><div class="summary-grid"><article><span class="summary-icon">${icons.user}</span><div><small>个人资料</small><strong>${statusLabels[data.profile?.status] || '未填写'}</strong></div>${badge(data.profile?.status || 'pending')}</article><article><span class="summary-icon">${icons.exam}</span><div><small>已报名考试</small><strong>${data.registrations.length} 场</strong></div><button data-route="candidate/exams">去报名</button></article><article><span class="summary-icon">${icons.ticket}</span><div><small>可下载准考证</small><strong>${data.registrations.filter(item => item.admitCard).length} 份</strong></div><button data-route="candidate/admit">查看</button></article><article><span class="summary-icon">${icons.chart}</span><div><small>已发布成绩</small><strong>${data.results.length} 科</strong></div><button data-route="candidate/results">查分</button></article></div><div class="candidate-grid"><section class="panel progress-panel"><div class="panel-title"><h2>我的应考进度</h2><span>自动更新</span></div><div class="candidate-progress">${steps.map((step, index) => `<div class="progress-step ${step[1] ? 'done' : index === steps.findIndex(item => !item[1]) ? 'current' : ''}"><i>${step[1] ? '✓' : index + 1}</i><div><strong>${step[0]}</strong><small>${step[2]}</small></div></div>`).join('')}</div></section><section class="panel compact-notices"><div class="panel-title"><h2>最近通知</h2><button data-route="candidate/notices">全部通知</button></div>${data.notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section></div>`;
}
function candidateProfile(data, onboarding = false) {
const { profile, schools = [], classes = [], workflow } = data;
const step = workflow?.currentStepDetail;
const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber;
return `<section class="panel form-panel ${onboarding ? 'onboarding-profile' : ''}">${workflow ? `<div class="candidate-flow-note"><span>当前审批</span><strong>${h(step?.name || statusLabels[workflow.status])}</strong><small>${workflow.assignee ? `${h(workflow.assignee.displayName)} 处理` : '流程已结束'}</small></div>` : ''}<form class="profile-form" data-form="candidate-profile"><div class="form-section-title"><span>01</span><div><h2>身份信息</h2><p>姓名和证件号码须与有效证件完全一致。</p></div></div><div class="form-grid"><label><span>考生姓名 *</span><input name="name" required value="${h(profile?.name)}"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option ${profile?.gender === '男' ? 'selected' : ''}>男</option><option ${profile?.gender === '女' ? 'selected' : ''}>女</option></select></label><label><span>证件号码 *</span><input name="idNumber" required value="${h(idNumber)}"></label><label><span>出生日期</span><input name="birthDate" type="date" value="${h(profile?.birthDate)}"></label><label><span>籍贯 *</span><input name="nativePlace" required value="${h(profile?.nativePlace)}" placeholder="例如:江苏海州"></label><label><span>民族</span><input name="ethnicity" value="${h(profile?.ethnicity)}" placeholder="例如:汉族"></label></div><div class="form-section-title"><span>02</span><div><h2>学校与班级</h2><p>学校和班级决定资料审批范围。</p></div></div><div class="form-grid"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}" ${profile?.schoolId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请选择班级</option>${classes.filter(item => item.schoolId === profile?.schoolId).map(item => `<option value="${h(item.id)}" ${profile?.classId === item.id ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div><div class="form-section-title"><span>03</span><div><h2>家庭与联系信息</h2><p>用于考试通知、身份复核和紧急联系。</p></div></div><div class="form-grid"><label><span>手机号 *</span><input name="phone" required value="${h(profile?.phone)}"></label><label><span>电子邮箱 *</span><input name="email" type="email" required value="${h(profile?.email)}"></label><label class="wide-field"><span>家庭住址 *</span><input name="address" required value="${h(profile?.address)}" placeholder="请填写省、市、区及详细门牌"></label><label><span>邮政编码</span><input name="postalCode" value="${h(profile?.postalCode)}"></label><label><span>监护人姓名</span><input name="guardianName" value="${h(profile?.guardianName)}"></label><label><span>监护人电话</span><input name="guardianPhone" value="${h(profile?.guardianPhone)}"></label><label><span>紧急联系人</span><input name="emergencyContact" value="${h(profile?.emergencyContact)}"></label><label><span>紧急联系电话</span><input name="emergencyPhone" value="${h(profile?.emergencyPhone)}"></label></div>${profile?.reviewNote ? `<div class="review-note ${profile.status}"><strong>审核意见</strong><p>${h(profile.reviewNote)}</p></div>` : ''}<div class="form-actions"><p>${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}</p><button class="solid-button" type="submit">${onboarding ? '提交个人信息' : '保存并提交审批'}</button></div></form></section>`;
}
function candidateExams(data) {
return `<div class="exam-application-list">${data.exams.map(exam => `<article class="apply-card ${exam.registration ? 'registered' : ''}"><header><div><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</div><small>${exam.registrationCount || 0} 人已报名</small></header><div class="apply-card-main"><div class="apply-copy"><h2>${h(exam.name)}</h2><p>${h(exam.description)}</p><dl><div><dt>报名期限</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>计分规则</dt><dd>总分 ${h(exam.totalScore)} · ${h(passPolicyText(exam))}</dd></div><div><dt>考点安排</dt><dd>${h(exam.location)}</dd></div></dl></div><form class="subject-selector" data-form="exam-registration"><input type="hidden" name="examId" value="${h(exam.id)}"><div class="subject-title"><strong>选择报考科目</strong><span>可多选</span></div><div class="subject-options">${exam.subjects.map(subject => `<label><input type="checkbox" name="subjectIds" value="${h(subject.id)}" ${exam.registration?.subjectIds.includes(subject.id) ? 'checked disabled' : ''}><span><i>${h(subject.name.slice(0, 1))}</i><b>${h(subject.name)}</b><small>${h(subject.date)} · ${h(subject.start)} · 满分 ${h(subject.fullScore)}</small><em>${money(subject.fee)}</em></span></label>`).join('') || '<p class="empty-state">科目安排尚未发布</p>'}</div>${exam.registration ? `<div class="registered-banner">${icons.check}<span>已提交报名 · ${exam.registration.subjectIds.length} 个科目</span>${badge(exam.registration.status)}</div>` : `<div class="subject-total"><span>已选 <b data-subject-count>0</b> 科</span><strong data-subject-fee>满分 0 · ¥0.00</strong></div><button class="solid-button" type="submit" ${exam.registrationState !== 'open' || data.profileStatus !== 'approved' || !exam.subjects.length ? 'disabled' : ''}>${data.profileStatus !== 'approved' ? '资料审核通过后可报名' : exam.registrationState === 'open' ? '提交考试报名' : statusLabels[exam.registrationState]}</button>`}</form></div></article>`).join('')}</div>`;
}
function candidateRegistrations(registrations) {
if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名');
const card = reg => `<article class="registration-card"><header><div><span class="exam-code">${h(reg.exam.code)}</span><h2>${h(reg.exam.name)}</h2></div>${badge(reg.exam.archivedAt ? 'archived' : reg.status)}</header><div class="registration-info"><dl><div><dt>账户报名号</dt><dd class="mono">${h(reg.registrationNumber || state.user.candidateNumber)}</dd></div><div><dt>当前审批</dt><dd>${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}</dd></div><div><dt>应缴金额</dt><dd>${money(reg.amountDue || 0)}</dd></div><div><dt>缴费状态</dt><dd>${badge(reg.paymentStatus)}${reg.paidAt ? `<small>${formatDate(reg.paidAt, true)} · ${h(reg.paidByName || '班级负责人')}</small>` : ''}</dd></div></dl><div class="selected-subjects"><strong>已选科目</strong><div>${reg.subjects.map(subject => `<span>${h(subject.name)}<small>${h(subject.date)} ${h(subject.start)}</small></span>`).join('')}</div></div></div><footer><p>${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : reg.paymentStatus === 'unpaid' ? '报名已通过,请线下完成缴费并等待班级负责人确认。' : '缴费已经确认,请留意准考证下载通知。'}</p>${reg.admitCard && !reg.exam.archivedAt ? `<button class="text-button" data-route="candidate/admit">查看准考证 →</button>` : ''}</footer></article>`;
const current = registrations.filter(reg => !reg.exam.archivedAt);
const archived = registrations.filter(reg => reg.exam.archivedAt);
return `<div class="registration-cards">${current.map(card).join('')}</div>${archived.length ? `<details class="candidate-archive-fold"><summary><span><strong>历史报名记录</strong><small>${archived.length} 场归档考试 · 点击查阅</small></span><b>${archived.length}</b></summary><div class="registration-cards">${archived.map(card).join('')}</div></details>` : ''}`;
}
function candidateAdmit(registrations) {
const cards = registrations.filter(reg => reg.admitCard);
return cards.length ? `<div class="admit-list">${cards.map(reg => {
const now = Date.now();
const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime();
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
const subjectRows = reg.subjects.map(subject => {
const assignment = assignments.get(subject.id) || {};
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(assignment.examRoomCode || '')} · ${h(assignment.roomName || assignment.room || '')}${h(assignment.roomCode || '')}· ${h(assignment.building || '')} ${h(assignment.floor || '')} · ${h(assignment.seat || '')}</small></span>`;
}).join('');
const ticket = `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(reg.exam.archivedAt ? 'archived' : open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd><strong>${h(reg.admitCard.testCenter)}</strong><small>${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}</small></dd></div><div><dt>逐科详细安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open && !reg.exam.archivedAt ? '' : 'disabled'}>${reg.exam.archivedAt ? '已归档' : open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>${reg.exam.archivedAt ? '历史准考证仅供查阅' : '下载后请使用 A4 纸横向打印'}</small></div></article>`;
return reg.exam.archivedAt ? `<details class="candidate-archive-fold admit-archive-fold"><summary><span><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)} · ${formatDate(reg.exam.archivedAt, true)} 归档</small></span><b>查看历史准考证</b></summary>${ticket}</details>` : ticket;
}).join('')}</div>` : emptyState('', '', 'candidate/registrations', '');
}
function candidateResults(data) {
const { results, summaries = [] } = data;
if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知');
const grouped = Object.groupBy ? Object.groupBy(results, item => item.examId) : results.reduce((acc, item) => ((acc[item.examId] ||= []).push(item), acc), {});
const completeSummaries = summaries.filter(item => item.complete);
const overview = `<section class="candidate-result-overview"><article><small>已发布考试</small><strong>${Object.keys(grouped).length}</strong><span>场</span></article><article><small>已发布科目</small><strong>${results.length}</strong><span>科</span></article><article><small>整场已合格</small><strong>${completeSummaries.filter(item => item.qualified === true).length}</strong><span>场</span></article><article><small>复议处理中</small><strong>${results.filter(item => item.appeal?.status === 'pending').length}</strong><span>项</span></article></section>`;
return `${overview}<div class="result-groups">${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([, items]) => {
const examName = items[0].examName;
const summary = summaries.find(item => item.examId === items[0].examId);
const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线';
const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `${summary.rank} / ${summary.cohortSize}` : summary ? passPolicyText(summary) : '';
const scores = items.map(item => {
const appeal = item.appeal;
const latestAction = appeal?.actions?.at(-1);
const appealPanel = item.archivedAt
? `<div class="score-appeal-state archived-score-lock">${badge('archived')}<small>本场成绩已永久锁定,复议入口已关闭</small></div>`
: appeal?.status === 'pending'
? `<div class="score-appeal-state">${badge('pending')}<small>${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}</small></div>`
: appeal?.status === 'approved'
? `<div class="score-appeal-state">${badge('approved')}<small>${h(latestAction?.note || '复议流程已完成')}</small></div>`
: `${appeal ? `<div class="score-appeal-state">${badge('rejected')}<small>${h(latestAction?.note || '可补充理由后重新提交')}</small></div>` : ''}<form class="score-appeal-form" data-form="score-appeal"><input type="hidden" name="resultId" value="${h(item.id)}"><textarea name="reason" rows="2" minlength="5" maxlength="500" required placeholder="填写成绩复议理由(至少 5 个字)"></textarea><button class="row-action primary" type="submit">${appeal ? '重新申请复议' : '申请成绩复议'}</button></form>`;
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><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>`;
}
function candidateAdmissions(data) {
const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取结束' };
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
const notificationCards = (data.notifications || []).map(notification => {
const invalid = ['withdrawn', 'forfeited'].includes(notification.placementStatus);
return `<article class="admission-notification ${invalid ? 'invalid' : ''}"><div class="admission-notification-mark"><span>ADMISSION</span><strong>${invalid ? '失' : '录'}</strong></div><div class="admission-notification-copy"><header><div><span>${invalid ? '录取状态已更新' : '录取结果已发布'}</span><h2>${h(notification.payload?.title || '录取结果通知')}</h2></div><time>${formatDate(notification.createdAt, true)}</time></header><p>${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}</p><dl><div><dt>录取学校</dt><dd>${h(notification.schoolName || '招生学校')}</dd></div><div><dt>招生类别</dt><dd>${h(notification.categoryName || '以录取通知书为准')}</dd></div><div><dt>所属考试</dt><dd>${h(notification.examName || '—')}</dd></div>${notification.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(notification.noticeNumber)}</dd></div>` : ''}</dl></div><span class="admission-notification-status">${invalid ? '资格已失效' : '正式录取'}</span></article>`;
}).join('');
return `${notificationCards ? `<section class="admission-notification-stack" aria-label="录取结果通知">${notificationCards}</section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
const choices = item.preference?.payload?.choices || [];
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && item.supplementEligible !== false;
const placementSchool = item.placementSchool?.name || item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '招生学校';
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
const progressIndex = item.status === 'supplementary' ? 1 : item.status === 'reporting' ? 3 : Math.max(0, progressSteps.indexOf(item.status));
const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {};
const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
const choiceRow = (choice, preferenceType, index) => {
const eligiblePlans = item.plans.filter(plan => plan.categories.some(category => category.preferenceTypes?.includes(preferenceType)));
const plan = eligiblePlans.find(entry => entry.schoolId === choice.schoolId);
const categoryOptions = (plan?.categories || []).filter(category => category.preferenceTypes?.includes(preferenceType) && ((preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining) > 0 || category.code === choice.categoryCode));
const disabled = preferenceType === 'indicator' && !indicatorEligible;
return `<div class="preference-choice-row ${preferenceType}" data-preference-type="${preferenceType}"><b>${preferenceType === 'indicator' ? '指标' : index + 1}</b><label><span>${preferenceType === 'indicator' ? '' : ` ${index + 1} · `}</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}" ${disabled ? 'disabled' : ''}><option value="">${disabled ? '' : ''}</option>${eligiblePlans.map(entry => `<option value="${h(entry.schoolId)}" ${entry.schoolId === choice.schoolId ? 'selected' : ''}>${h(entry.schoolCode)} · ${h(entry.schoolName)}</option>`).join('')}</select></label><label><span></span><select name="choiceCategory" ${plan && !disabled ? '' : 'disabled'}><option value="">${plan ? '' : ''}</option>${categoryOptions.map(category => `<option value="${h(category.code)}" ${category.code === choice.categoryCode ? 'selected' : ''}>${h(category.name)}${category.specialtyCategory ? `${h(specialtyLabel(category.specialtyCategory, category.specialtyType))}` : ''} · ${h(preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining)}</option>`).join('')}</select></label></div>`;
};
const choiceRows = choiceRow(indicatorChoice, 'indicator', 0) + Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => choiceRow(generalChoices[index] || {}, 'general', index)).join('');
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); const schoolCode = choice.schoolCode || plan?.schoolCode || ''; const schoolName = choice.schoolName || plan?.schoolName || choice.schoolId; const categoryName = choice.categoryName || category?.name || choice.categoryCode; return `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b><i><strong>${h(schoolName)}</strong><small>${h([schoolCode, categoryName].filter(Boolean).join(' · '))}</small></i></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>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<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.supplementEligible === false ? 'warning' : ''}">${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}</div>`}</section>`;
}).join('')}</div>`;
}
function candidateNotices(notices) {
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
}
function accountSecurity(totp = {}) {
const account = h(state.user?.candidateNumber || state.user?.username);
const type = state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户';
const password = `<section class="panel account-security-panel"><div class="account-security-copy"><span>LOGIN PASSWORD</span><h2>修改登录密码</h2><p>密码修改成功后立即生效。请使用至少 8 位、且与当前密码不同的新密码。</p><dl><div><dt>当前账号</dt><dd class="mono">${account}</dd></div><div><dt>账户类型</dt><dd>${type}</dd></div></dl></div><form class="stack-form account-password-form" data-form="account-password"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>新密码</span><input name="newPassword" type="password" autocomplete="new-password" minlength="8" required></label><label><span>再次输入新密码</span><input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required></label><button class="solid-button large" type="submit">保存新密码</button></form></section>`;
const totpPanel = totp.enabled
? `<section class="panel account-security-panel totp-security-panel enabled"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>TOTP 二次验证已开启</h2><p>登录密码验证通过后,还需要输入验证器应用生成的 6 位动态验证码。</p><div class="totp-signal"><i></i><strong>保护中</strong><span>剩余 ${h(totp.recoveryCodesRemaining)} 个恢复码</span></div></div><div class="totp-security-actions"><details><summary>重新生成恢复码</summary><form class="stack-form account-password-form" data-form="totp-recovery-codes"><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="solid-button" type="submit">生成新的恢复码</button></form></details><details class="danger-details"><summary>关闭二次验证</summary><form class="stack-form account-password-form" data-form="totp-disable"><p>关闭后,账户将仅使用密码登录。</p><label><span>当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><label><span>动态验证码或恢复码</span><input name="code" autocomplete="one-time-code" required></label><button class="danger-button" type="submit">确认关闭二次验证</button></form></details></div></section>`
: `<section class="panel account-security-panel totp-security-panel"><div class="account-security-copy"><span>TWO-STEP VERIFICATION</span><h2>添加 TOTP 二次验证</h2><p>使用 Microsoft Authenticator、Google Authenticator、1Password 等验证器应用扫码。即使密码泄露,没有动态验证码也无法登录。</p></div><form class="stack-form account-password-form" data-form="totp-setup"><label><span>确认当前密码</span><input name="currentPassword" type="password" autocomplete="current-password" required></label><p class="form-hint">绑定时会显示二维码和手动密钥;验证成功后请立即保存恢复码。</p><button class="solid-button large" type="submit">开始绑定验证器</button></form></section>`;
return `<div class="account-security-stack">${password}${totpPanel}</div>`;
}
return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity };
}
+168
View File
@@ -0,0 +1,168 @@
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);
}
function loadImage(source) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('防伪二维码加载失败'));
image.src = source;
});
}
async function drawQrCode(ctx, dataUrl, x, y, size) {
if (!dataUrl) return;
const image = await loadImage(dataUrl);
ctx.fillStyle = '#ffffff';
ctx.fillRect(x - 10, y - 10, size + 20, size + 20);
ctx.drawImage(image, x, y, size, size);
}
export async function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl, verificationQr }) {
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, 2035, footerY + 135, { size: 21, color: '#53627b', align: 'right', maxWidth: 820 });
await drawQrCode(ctx, verificationQr, 2075, footerY + 26, 180);
drawText(ctx, `生成时间 ${new Date().toLocaleString('zh-CN')}`, 2310, 3435, { size: 22, color: '#8a94a6', align: 'right' });
downloadCanvasPdf(canvas, `${exam.name}-${candidate.name}-成绩单.pdf`);
}
export async function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl, verificationQr, noticeNumber }) {
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, `通知书编号:${noticeNumber || placement.payload.noticeNumber || '—'}`, 2180, 835, { size: 27, weight: 600, color: '#655d53', align: 'right', maxWidth: 1250 });
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' });
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, 1940, 3185, { size: 20, color: '#71695f', align: 'right', maxWidth: 820 });
await drawQrCode(ctx, verificationQr, 1995, 3055, 175);
drawText(ctx, organization?.name || '考试服务平台', 1240, 3380, { size: 23, color: '#8a8177', align: 'center' });
downloadCanvasPdf(canvas, `${school.name}-${candidate.name}-录取通知书.pdf`);
}
@@ -0,0 +1,161 @@
import { filterTableItems } from './table-state.js';
export function createPublicViews(context) {
const {
state,
app,
h,
formatDate,
dateRange,
badge,
money,
passPolicyText,
statusLabels,
icons,
api,
renderError,
emptyState
} = context;
function brand() {
return `<a class="brand" href="#home" data-route="home"><span class="brand-symbol"><i></i><i></i><i></i></span><span><strong>衡准</strong><small>EXAM SERVICE</small></span></a>`;
}
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="#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() {
app.classList.remove('admin-readable');
const { notices, exams, stats, organization } = state.publicData;
const siteCopy = state.publicData.siteCopy || {};
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
const topNotice = notices[0];
app.innerHTML = `${publicHeader()}<main class="public-main">
<section class="hero">
<div class="hero-grid">
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-route="notice/${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
</div>
</section>
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>招生录取公示已纳入通知公告,可按类别统一查询。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-route="notice/${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div><button class="notice-archive-link" data-route="notices">查看全部通知与录取公示 ${icons.arrow}</button></div></section>
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
}
function noticeDocuments(data = state.publicAnnouncements) {
const ordinary = (state.publicData.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` }));
const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` }));
const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: item.round ? `${item.round} 轮录取名单` : '最终录取名单', title: item.title || `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` }));
const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
const reports = (data.reports || []).map(item => ({ ...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示', subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况', title: item.title, summary: item.summary }));
return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
function publicPaged(items, key, pageSize = 50) {
const filtered = filterTableItems(state, items, key);
state.tablePages ||= {};
const current = state.tablePages[key] || { page: 1, pageSize };
const size = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : pageSize;
const totalPages = Math.max(1, Math.ceil(filtered.length / size));
const page = Math.min(totalPages, Math.max(1, Number(current.page || 1)));
state.tablePages[key] = { page, pageSize: size };
return { items: filtered.slice((page - 1) * size, page * size), total: filtered.length, totalPages, page, pageSize: size, key };
}
function publicPagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
const end = Math.min(meta.total, meta.page * meta.pageSize);
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button><button type="button" class="active" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page}">${meta.page}</button><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
}
function renderPublicQualification(document) {
const key = `publicQualification-${document.documentId}`;
const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key);
return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名或特长类型"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button data-action="status-filter" data-target="${h(key)}" data-status="eligible">有资格</button><button data-action="status-filter" data-target="${h(key)}" data-status="ineligible">无资格</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${page.items.map(row => `<tr data-status="${h(row.status)}"><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">没有符合条件的资格记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
}
function renderPublicAdmission(document) {
const key = `publicAdmission-${document.documentId}`;
const page = publicPaged(document.rows, key);
const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))];
const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))];
return `<p class="document-lead">${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
}
function renderDocumentBody(document) {
if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
if (document.documentType === 'plan') return `<p class="document-lead">招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。</p><div class="table-scroll"><table><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>其中定向指标</th><th>指标分配</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.code)}</td><td><strong>${h(row.name)}</strong><small>${h(row.specialtyLabel || '普通 / 政策类')}</small></td><td><strong>${h(row.quota)} 人</strong></td><td>${h(row.indicatorQuota || 0)} 人</td><td>${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `<span>${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人</span>`).join('<br>') : '无定向指标'}</td></tr>`).join('')}</tbody></table></div>${document.note ? `<p class="document-note"><strong>计划说明:</strong>${h(document.note)}</p>` : ''}`;
if (document.documentType === 'qualification') return renderPublicQualification(document);
if (document.documentType === 'admission') return renderPublicAdmission(document);
if (document.documentType === 'reporting') {
const stats = document.statistics || {};
return `<p class="document-lead">本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。</p><div class="reporting-public-stats"><article><span>招生计划</span><strong>${h(stats.totalQuota || 0)}</strong><small>人</small></article><article><span>正式录取</span><strong>${h(stats.finalCount || 0)}</strong><small>人</small></article><article><span>已报到</span><strong>${h(stats.reportedCount || 0)}</strong><small>人</small></article><article><span>计划完成率</span><strong>${h(stats.reportingRate || 0)}%</strong><small>按实际报到</small></article></div><p class="document-note"><strong>学校说明:</strong>${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}</p>`;
}
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
}
function renderNoticeCenter(data = state.publicAnnouncements, selectedId = '') {
app.classList.remove('admin-readable');
const documents = noticeDocuments(data);
const selected = documents.find(item => item.documentId === selectedId);
const organization = state.publicData.organization || {};
if (selected) {
app.innerHTML = `${publicHeader()}<main class="public-main notice-document-page"><div class="notice-breadcrumb"><button data-route="notices">通知公告</button><span>/</span><strong>${h(selected.subtype)}</strong></div><article class="notice-document"><header><span>${h(selected.category)} · ${h(selected.subtype)}</span><h1>${h(selected.title)}</h1><p>${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}</p></header><section>${renderDocumentBody(selected)}</section><footer><button class="ghost-button" data-route="notices">返回通知公告列表</button></footer></article></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>公开信息以本页面正式发布内容为准</span></footer>`;
return;
}
const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))];
const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部';
const searched = filterTableItems(state, documents, 'publicNoticeDirectory');
const filtered = category === '全部' ? searched : searched.filter(item => item.category === category);
const pageSize = 8;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1)));
state.noticeCategory = category; state.noticePage = page;
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize);
app.innerHTML = `${publicHeader()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="data-toolbar notice-directory-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="publicNoticeDirectory" placeholder="搜索全部通知、公示标题、分类或摘要"></label><button class="row-action" data-action="clear-table-filters" data-target="publicNoticeDirectory">清除搜索</button></div><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}></button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span></span></footer>`;
}
function renderHeroTicket(exam) {
const status = exam.registrationState;
return `<article class="hero-ticket"><div class="ticket-main"><header><span>${badge(status)}</span><small>${h(exam.code)}</small></header><p>UPCOMING EXAM</p><h2>${h(exam.name)}</h2><dl><div><dt>报名时间</dt><dd>${dateRange(exam.registrationStart, exam.registrationEnd)}</dd></div><div><dt>考试时间</dt><dd>${dateRange(exam.examStart, exam.examEnd)}</dd></div><div><dt>考试地点</dt><dd>${h(exam.location)}</dd></div></dl><div class="subject-chips">${exam.subjects.slice(0, 5).map(subject => `<span>${h(subject.name)}</span>`).join('')}${exam.subjects.length > 5 ? `<span>+${exam.subjects.length - 5}</span>` : ''}</div></div><div class="ticket-stub"><span>报名人数</span><strong>${h(exam.registrationCount || 0)}</strong><i></i><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${status === 'open' ? '立即报名' : '查看详情'}</button></div></article>`;
}
function renderNoticeRow(notice) {
return `<button class="notice-row" data-route="notice/${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
}
function renderPublicExam(exam) {
return `<article class="public-exam-card"><header><span class="exam-code">${h(exam.code)}</span>${badge(exam.registrationState)}</header><h3>${h(exam.name)}</h3><p>${h(exam.description)}</p><div class="exam-meta"><span><b>报名</b>${dateRange(exam.registrationStart, exam.registrationEnd)}</span><span><b>考试</b>${dateRange(exam.examStart, exam.examEnd)}</span><span><b>总分</b>${h(exam.totalScore)} 分 · ${h(passPolicyText(exam))}</span></div><footer><span>${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名</span><button data-route="${state.user?.role === 'candidate' ? 'candidate/exams' : 'login'}">${exam.registrationState === 'open' ? '选择科目' : '查看考试'} ${icons.arrow}</button></footer></article>`;
}
function renderAuth(kind) {
app.classList.remove('admin-readable');
const login = kind === 'login';
const selfRegistration = state.publicData.selfRegistrationEnabled;
const authNotice = login && state.authNotice ? `<div class="auth-session-notice" role="status"><strong>需要重新登录</strong><span>${h(state.authNotice)}</span></div>` : '';
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>${document.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(document.noticeNumber)}</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>`;
}
function registerForm() {
const schools = state.publicData.schools || [];
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, renderVerification };
}
@@ -0,0 +1,42 @@
import { chinaRegions } from '../data/china-regions.js';
const option = (value, label, selected = false) => `<option value="${value}" ${selected ? 'selected' : ''}>${label}</option>`;
export function regionSelects(region = {}, { required = true, className = 'region-selects' } = {}) {
const province = chinaRegions.find(item => item.code === region.provinceCode);
const city = province?.cities.find(item => item.code === region.cityCode);
const requiredText = required ? 'required' : '';
return `<div class="${className}" data-region-group>
<label><span>省 / 自治区 / 直辖市${required ? ' *' : ''}</span><select name="provinceCode" data-region-level="province" ${requiredText}><option value="">请选择省份</option>${chinaRegions.map(item => option(item.code, item.name, item.code === region.provinceCode)).join('')}</select></label>
<label><span>市 / 州${required ? ' *' : ''}</span><select name="cityCode" data-region-level="city" ${requiredText}><option value="">请先选择省份</option>${(province?.cities || []).map(item => option(item.code, item.name, item.code === region.cityCode)).join('')}</select></label>
<label><span>区 / 县${required ? ' *' : ''}</span><select name="districtCode" data-region-level="district" ${requiredText}><option value="">请先选择城市</option>${(city?.districts || []).map(item => option(item.code, item.name, item.code === region.districtCode)).join('')}</select></label>
</div>`;
}
export function updateRegionSelects(select) {
const group = select.closest('[data-region-group]');
if (!group) return;
const provinceSelect = group.querySelector('[name="provinceCode"]');
const citySelect = group.querySelector('[name="cityCode"]');
const districtSelect = group.querySelector('[name="districtCode"]');
const province = chinaRegions.find(item => item.code === provinceSelect?.value);
if (select.dataset.regionLevel === 'province') {
citySelect.innerHTML = `<option value="">请选择城市</option>${(province?.cities || []).map(item => option(item.code, item.name)).join('')}`;
districtSelect.innerHTML = '<option value="">请先选择城市</option>';
} else if (select.dataset.regionLevel === 'city') {
const city = province?.cities.find(item => item.code === citySelect?.value);
districtSelect.innerHTML = `<option value="">请选择区县</option>${(city?.districts || []).map(item => option(item.code, item.name)).join('')}`;
}
}
export function mountRegionSelects(root, region = {}, options = {}) {
const address = root?.querySelector('[name="address"]');
if (!address || root.querySelector('[data-region-group]')) return;
address.closest('label')?.insertAdjacentHTML('beforebegin', regionSelects(region, options));
}
export function formatRegionAddress(region = {}) {
const parts = [region.provinceName, region.cityName, region.districtName]
.filter((value, index, values) => value && value !== values[index - 1]);
return [...parts, region.address].filter(Boolean).join('');
}
+20
View File
@@ -0,0 +1,20 @@
export const state = {
user: null,
profile: null,
publicData: { organization: {}, notices: [], exams: [], stats: {} },
publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
noticeCategory: '全部',
noticePage: 1,
permissions: [],
scopeLabel: '',
authNotice: '',
pageData: null,
resultExamFilter: '',
resultSubjectFilter: '',
resultExamCatalog: null,
resultImportPreview: null,
reportingImportSummaries: {},
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);
}
+58
View File
@@ -0,0 +1,58 @@
export const statusLabels = {
pending: '待审核', approved: '已通过', rejected: '需修改',
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
super: '超级管理员', school: '校级管理员', class: '班级管理员'
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
reporting: '考生报到中', supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', forfeited: '未报到失效', final: '正式录取', unread: '未读', submitted: '已提交', pending_approval: '待审批'
};
export const icons = {
home: '<svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5v8a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
user: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4.5 21a7.5 7.5 0 0 1 15 0"/></svg>',
exam: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>',
ticket: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 0 0 4v6h18v-6a2 2 0 0 0 0-4V5H3zM8 5v12"/></svg>',
chart: '<svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>',
bell: '<svg viewBox="0 0 24 24"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 22h4"/></svg>',
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 4a4 4 0 0 1 0 8M18 15a6 6 0 0 1 4 6"/></svg>',
check: '<svg viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
logout: '<svg viewBox="0 0 24 24"><path d="M14 8V4H4v16h10v-4M10 12h11M18 9l3 3-3 3"/></svg>',
menu: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m16 16 5 5"/></svg>',
arrow: '<svg viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>'
};
export function h(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
}
export function passPolicyText(exam) {
const value = Number(exam?.passValue ?? 60);
return {
fixed_score: `总分达到 ${value}`,
score_ratio: `总成绩排名前 ${value}%`,
rank_percent: `总成绩排名前 ${value}%`,
subject_scores: '所有报考科目均达单科线',
none: '仅发布成绩,不判定合格'
}[exam?.passPolicy || 'rank_percent'];
}
export function formatDate(value, withTime = false) {
if (!value) return '待定';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return h(value);
return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}
export function dateRange(start, end) {
return `${formatDate(start)}${formatDate(end)}`;
}
export function badge(status) {
return `<span class="status status-${h(status)}">${h(statusLabels[status] || status)}</span>`;
}
export function money(value) {
return `¥${Number(value || 0).toFixed(2)}`;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
export const specialtyCatalog = Object.freeze([
Object.freeze({
code: 'sports',
name: '体育',
types: Object.freeze([
Object.freeze({ code: 'track_field', name: '田径' }),
Object.freeze({ code: 'basketball', name: '篮球' }),
Object.freeze({ code: 'football', name: '足球' }),
Object.freeze({ code: 'volleyball', name: '排球' }),
Object.freeze({ code: 'table_tennis', name: '乒乓球' }),
Object.freeze({ code: 'badminton', name: '羽毛球' }),
Object.freeze({ code: 'swimming', name: '游泳' }),
Object.freeze({ code: 'martial_arts', name: '武术' }),
Object.freeze({ code: 'aerobics_cheer', name: '健美操与啦啦操' })
])
}),
Object.freeze({
code: 'arts',
name: '艺术',
types: Object.freeze([
Object.freeze({ code: 'vocal_music', name: '声乐' }),
Object.freeze({ code: 'instrumental_music', name: '器乐' }),
Object.freeze({ code: 'dance', name: '舞蹈' }),
Object.freeze({ code: 'fine_arts', name: '美术' }),
Object.freeze({ code: 'calligraphy', name: '书法' }),
Object.freeze({ code: 'drama_broadcasting', name: '戏剧与播音' })
])
})
]);
const categoryMap = new Map(specialtyCatalog.map(category => [category.code, category]));
const typeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.code, { ...type, categoryCode: category.code, categoryName: category.name }])));
const legacyTypeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.name, { category: category.code, type: type.code }])));
export function specialtyCategory(code) {
return categoryMap.get(String(code || '')) || null;
}
export function specialtyType(code) {
return typeMap.get(String(code || '')) || null;
}
export function isValidSpecialty(categoryCode, typeCode) {
if (!categoryCode && !typeCode) return true;
const category = specialtyCategory(categoryCode);
const type = specialtyType(typeCode);
return Boolean(category && type && type.categoryCode === category.code);
}
export function resolveProfileSpecialty(profile = {}) {
if (isValidSpecialty(profile.specialtyCategory, profile.specialtyType) && profile.specialtyCategory) {
return { category: profile.specialtyCategory, type: profile.specialtyType };
}
const legacy = (Array.isArray(profile.specialtyTypes) ? profile.specialtyTypes : []).map(value => legacyTypeMap.get(String(value))).find(Boolean);
return legacy || { category: '', type: '' };
}
export function specialtyLabel(categoryCode, typeCode) {
const category = specialtyCategory(categoryCode);
const type = specialtyType(typeCode);
if (!category) {
const legacy = legacyTypeMap.get(String(typeCode || ''));
return legacy ? specialtyLabel(legacy.category, legacy.type) : '';
}
return type?.categoryCode === category.code ? `${category.name}·${type.name}` : category.name;
}
export function candidateEligibleForCategory(profile, category) {
const legacy = !category?.specialtyCategory ? legacyTypeMap.get(String(category?.specialtyType || '')) : null;
const requiredCategory = category?.specialtyCategory || legacy?.category || '';
const requiredType = legacy?.type || category?.specialtyType || '';
if (!requiredCategory) return true;
const qualification = resolveProfileSpecialty(profile);
if (qualification.category !== requiredCategory) return false;
return !requiredType || qualification.type === requiredType;
}
File diff suppressed because one or more lines are too long