防伪+成绩单+录取通知书

This commit is contained in:
2026-07-22 08:31:14 +08:00 Unverified
parent 6f2cf2ded4
commit d029931c31
19 changed files with 552 additions and 34 deletions
+27 -12
View File
@@ -1,6 +1,7 @@
import { formatRegionAddress } from './region-select.mjs';
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
import { filterTableItems } from './table-state.mjs';
export const numberSegmentMeta = {
year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'],
@@ -78,7 +79,7 @@ export function createAdminViews(context) {
const container = app.querySelector('.portal-content');
if (!container || !data.preferences?.length) return;
const preferencePage = paged(data.preferences, 'adminPreferenceTable');
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('')}</tbody></table></div>${pagination(preferencePage)}</section>`);
container.insertAdjacentHTML('beforeend', `<section class="panel data-panel admission-preference-ledger"><div class="panel-title"><div><h2>考生志愿只读台账</h2><p>仅超级管理员可查看;系统不提供任何管理员修改入口。</p></div><span>${data.preferences.length} 份</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPreferenceTable" placeholder="搜索考生、报名号、考试、志愿学校或类别"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="adminPreferenceTable"><option value="">全部考试</option>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="adminPreferenceTable">清除筛选</button></div></div><div class="table-scroll"><table id="adminPreferenceTable"><thead><tr><th>报名号 / 考生</th><th>考试</th><th>轮次</th><th>志愿顺序</th><th>本人提交时间</th></tr></thead><tbody>${preferencePage.items.map(item => `<tr data-exam="${h(item.examId)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small></td><td>${h(data.exams.find(exam => exam.id === item.examId)?.name || item.examId)}</td><td>第 ${h(item.payload.round || 1)} 轮</td><td>${item.choices.map((choice, index) => `${index + 1}. ${h(choice.schoolName)} · ${h(choice.categoryCode)}`).join('<br>')}</td><td>${formatDate(item.payload.submittedAt, true)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的志愿记录</td></tr>'}</tbody></table></div>${pagination(preferencePage)}</section>`);
}
function adminDashboard(data) {
@@ -99,10 +100,11 @@ export function createAdminViews(context) {
function adminSchoolsV2(data) {
const schools = data.schools || [];
const schoolPage = paged(schools, 'schoolTable', 20);
const sourceCount = schools.filter(item => item.active && item.isSourceSchool).length;
const admissionCount = schools.filter(item => item.active && item.isAdmissionSchool).length;
const roles = item => `${item.isSourceSchool ? '<span class="school-role source">生源校</span>' : ''}${item.isAdmissionSchool ? '<span class="school-role admission">招生校</span>' : ''}`;
return `<section class="center-summary school-type-summary"><div><span>学校总数</span><strong>${schools.length}</strong></div><div><span>启用生源校</span><strong>${sourceCount}</strong></div><div><span>启用招生校</span><strong>${admissionCount}</strong></div><div><span>在册考生</span><strong>${schools.reduce((sum, item) => sum + item.candidateCount, 0)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolTable" placeholder="搜索学校名称、代码、类型或地址"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolTable" data-status="active">启用</button><button data-action="status-filter" data-target="schoolTable" data-status="inactive">停用</button></div></div><div class="table-scroll"><table id="schoolTable"><thead><tr><th>学校 / 代码</th><th>学校类型</th><th>地址</th><th>班级</th><th>管理员</th><th>考生</th><th>考点</th><th>状态</th><th>操作</th></tr></thead><tbody>${schools.map(item => `<tr data-status="${item.active ? 'active' : 'inactive'}"><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td><div class="school-role-tags">${roles(item)}</div></td><td>${h(item.address || '未填写')}</td><td>${item.classCount}</td><td>${item.adminCount}</td><td>${item.candidateCount}</td><td>${item.centerCount}</td><td>${badge(item.active ? 'approved' : 'closed')}</td><td><div class="candidate-account-actions"><button class="row-action" data-action="edit-school" data-id="${h(item.id)}">编辑</button><button class="row-action" data-action="toggle-school" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用' : '启用'}</button></div></td></tr>`).join('') || '<tr><td colspan="9" class="empty-state">没有学校,请先创建学校档案。</td></tr>'}</tbody></table></div></section>`;
return `<section class="center-summary school-type-summary"><div><span>学校总数</span><strong>${schools.length}</strong></div><div><span>启用生源校</span><strong>${sourceCount}</strong></div><div><span>启用招生校</span><strong>${admissionCount}</strong></div><div><span>在册考生</span><strong>${schools.reduce((sum, item) => sum + item.candidateCount, 0)}</strong></div></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolTable" placeholder="搜索学校名称、代码、类型或地址"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolTable" data-status="active">启用</button><button data-action="status-filter" data-target="schoolTable" data-status="inactive">停用</button></div></div><div class="table-scroll"><table id="schoolTable"><thead><tr><th>学校 / 代码</th><th>学校类型</th><th>地址</th><th>班级</th><th>管理员</th><th>考生</th><th>考点</th><th>状态</th><th>操作</th></tr></thead><tbody>${schoolPage.items.map(item => `<tr data-status="${item.active ? 'active' : 'inactive'}"><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td><div class="school-role-tags">${roles(item)}</div></td><td>${h(item.address || '未填写')}</td><td>${item.classCount}</td><td>${item.adminCount}</td><td>${item.candidateCount}</td><td>${item.centerCount}</td><td>${badge(item.active ? 'approved' : 'closed')}</td><td><div class="candidate-account-actions"><button class="row-action" data-action="edit-school" data-id="${h(item.id)}">编辑</button><button class="row-action" data-action="toggle-school" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}">${item.active ? '停用' : '启用'}</button></div></td></tr>`).join('') || '<tr><td colspan="9" class="empty-state">没有符合条件的学校。</td></tr>'}</tbody></table></div>${pagination(schoolPage)}</section>`;
}
function adminSchoolOrganization(data) {
@@ -144,6 +146,7 @@ export function createAdminViews(context) {
}
function paged(items, key, defaultPageSize = 50) {
items = filterTableItems(state, items, key);
const current = state.tablePages[key] || {};
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
const total = items.length;
@@ -230,8 +233,8 @@ export function createAdminViews(context) {
const accountRows = accountPage.items.map(account => `<tr data-status="${account.active ? 'active' : 'disabled'}" data-school="${h(account.schoolId)}"><td><div class="person-cell"><span>${h((account.displayName || '招').slice(0, 1))}</span><div><strong>${h(account.displayName)}</strong><small>${h(account.id)}</small></div></div></td><td><strong class="mono">${h(account.username)}</strong></td><td><strong>${h(account.schoolName || '未绑定')}</strong><small class="mono">${h(account.schoolCode || '')}</small></td><td><span class="status status-${account.active ? 'approved' : 'closed'}">${account.active ? '已启用' : '已停用'}</span></td><td>${formatDate(account.createdAt, true)}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admission-account-password" data-id="${h(account.id)}">重置密码</button><button class="row-action ${account.active ? 'danger' : 'primary'}" data-action="toggle-admission-account" data-id="${h(account.id)}" data-active="${account.active ? 'false' : 'true'}">${account.active ? '停用账户' : '重新启用'}</button></div></td></tr>`).join('');
const accounts = `<section class="panel admission-account-panel admission-account-management"><div class="panel-title"><div><h2>招生学校账户管理</h2><p>创建并维护各招生学校登录账户;停用会立即结束该账户现有会话。</p></div><span>${data.schoolAccounts.filter(item => item.active).length} 个启用 / 共 ${data.schoolAccounts.length} 个</span></div><form data-form="admission-account"><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><div class="field-row"><label><span>登录账号 *</span><input name="username" required></label><label><span>初始密码 *</span><input name="password" type="password" minlength="8" required></label></div><label><span>显示名称</span><input name="displayName" placeholder="学校招生办公室"></label><button class="solid-button" type="submit">创建招生学校账号</button></form><div class="account-management-divider"><strong>已建账户</strong><span>保留历史记录,不提供物理删除</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admissionAccountTable" placeholder="搜索当前页的学校、账号或显示名称"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="admissionAccountTable"><option value="">全部招生学校</option>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.name)}</option>`).join('')}</select></div></div><div class="filter-pills account-status-pills"><button type="button" class="active" data-action="status-filter" data-target="admissionAccountTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="active">已启用</button><button type="button" data-action="status-filter" data-target="admissionAccountTable" data-status="disabled">已停用</button></div><div class="table-scroll"><table id="admissionAccountTable"><thead><tr><th>账户</th><th>登录账号</th><th>绑定招生学校</th><th>状态</th><th>创建时间</th><th>账户操作</th></tr></thead><tbody>${accountRows || '<tr><td colspan="6" class="empty-state">还没有招生学校账户</td></tr>'}</tbody></table></div>${pagination(accountPage)}</section>`;
const planForm = `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>代招生校上传计划</h2><p>每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。</p></div></div><form data-form="admission-plan"><div class="field-row"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><label><span>招生学校 *</span><select name="schoolId" required>${data.admissionSchools.map(school => `<option value="${h(school.id)}">${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label></div>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="可填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">代上传并审核通过</button></form></section>`;
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="table-scroll"><table id="adminAdmissionPlanTable"><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)}`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
const placements = `<section class="panel data-panel"><div class="panel-title"><h2>投档与退档监督</h2><span>志愿只读,管理员均无修改入口</span></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
const plans = `<section class="panel data-panel"><div class="panel-title"><div><h2>招生计划审核</h2><p>核对类别资格、计划总数和指标分配。</p></div><span>${pendingPlans.length} 份待审</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminAdmissionPlanTable" placeholder="搜索考试、学校、招生类别或指标学校"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="adminAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="adminAdmissionPlanTable"><thead><tr><th>考试 / 学校</th><th>计划构成</th><th>指标分配</th><th>状态</th><th>操作</th></tr></thead><tbody>${planPage.items.map(plan => `<tr data-status="${h(plan.status)}"><td><strong>${h(plan.examName)}</strong><small>${h(plan.schoolName)}</small></td><td>${plan.payload.categories.map(category => `<strong>${h(category.name)} ${h(category.quota)} 人</strong><small>${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)}`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${plan.status === 'pending' ? `<button class="row-action primary" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="approved">通过</button><button class="row-action" data-action="admission-plan-review" data-id="${h(plan.id)}" data-status="rejected">退回</button>` : h(plan.payload.reviewNote || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">暂无招生计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
const placements = `<section class="panel data-panel"><div class="panel-title"><div><h2>投档与退档监督</h2><p>支持跨页搜索考生、报名号、学校与类别;志愿保持只读。</p></div><span>共 ${data.placements.length} 条</span></div><div class="data-toolbar placement-supervision-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminPlacementTable" placeholder="搜索考生、报名号、投档学校、类别或退档理由"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminPlacementTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="school_review">学校审核中</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawal_pending">退档待审</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="final">正式录取</button><button data-action="status-filter" data-target="adminPlacementTable" data-status="withdrawn">已退档</button></div><button class="row-action" data-action="clear-table-filters" data-target="adminPlacementTable">清除筛选</button></div><div class="table-scroll"><table id="adminPlacementTable"><thead><tr><th>考生</th><th>成绩 / 志愿</th><th>投档学校</th><th>类别</th><th>状态</th><th>退档审核</th></tr></thead><tbody>${placementPage.items.map(item => `<tr data-status="${h(item.status)}"><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)}</small><small>${h(item.candidate.specialtyLabel || '普通生')}</small></td><td>${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿</td><td>${h(item.schoolName)}</td><td>${h(item.payload.categoryName)}</td><td>${badge(item.status)}</td><td>${item.status === 'withdrawal_pending' ? `<small>${h(item.payload.withdrawalReason)}</small><button class="row-action primary" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="true">批准</button><button class="row-action" data-action="withdrawal-review" data-id="${h(item.id)}" data-approved="false">驳回</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未产生投档记录</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
return `<section class="admission-command-banner"><div><span>ADMISSION COMMAND</span><h2>中考招生录取控制台</h2><p>学校代码、资格类别、招生计划和指标名额在一条可审计链路中完成。</p></div><dl><div><dt>待审计划</dt><dd>${pendingPlans.length}</dd></div><div><dt>学校审核中</dt><dd>${data.placements.filter(item => item.status === 'school_review').length}</dd></div><div><dt>退档待审</dt><dd>${withdrawals.length}</dd></div><div><dt>正式录取</dt><dd>${data.placements.filter(item => item.status === 'final').length}</dd></div></dl></section><div class="admission-admin-grid single">${settings}</div>${accounts}${planForm}${plans}${placements}`;
}
@@ -250,9 +253,17 @@ export function createAdminViews(context) {
}
function adminNotices(notices, publications = []) {
const noticeRows = notices.map(notice => `<tr data-filter-row data-status="ordinary ${h(notice.status)} ${notice.status === 'published' ? 'visible' : ''}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td><strong>手动通知</strong><small>${h(notice.category)}</small></td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : notice.status === 'published' ? '通知目录' : '尚未展示'}</td><td>${badge(notice.status)}</td><td><div class="notice-row-actions">${notice.status === 'draft' ? `<button class="row-action primary" data-action="edit-notice" data-id="${h(notice.id)}">编辑</button>` : ''}<button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '隐藏' : '发布'}</button></div></td></tr>`).join('');
const publicationRows = publications.map(item => `<tr data-filter-row data-status="system ${h(item.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>自动公示</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishedAt,true)}</td><td>通知目录</td><td>${badge(item.status)}</td><td><button class="row-action ${item.visible ? '' : 'primary'}" data-action="toggle-publication" data-id="${h(item.id)}" data-source-type="${h(item.sourceType)}" data-visible="${item.visible ? 'false' : 'true'}">${item.visible ? '隐藏' : '显示'}</button></td></tr>`).join('');
return `<section class="panel data-panel"><div class="data-toolbar notice-admin-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知、公示标题或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="visible">正在显示</button><button data-action="status-filter" data-target="noticeTable" data-status="hidden">已隐藏</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="noticeTable" data-status="system">自动公示</button></div><p>自动公示的内容由招生录取流程生成,这里只控制是否公开显示。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>标题</th><th>来源 / 分类</th><th>发布人</th><th>发布时间</th><th>展示位置</th><th>状态</th><th>操作</th></tr></thead><tbody>${noticeRows}${publicationRows || (!noticeRows ? '<tr><td colspan="7" class="empty-state">暂无通知或系统公示</td></tr>' : '')}</tbody></table></div></section>`;
const entries = [
...notices.map(data => ({ kind: 'notice', status: `ordinary ${data.status} ${data.status === 'published' ? 'visible' : 'hidden'}`, data })),
...publications.map(data => ({ kind: 'publication', status: `system ${data.status} ${data.visible ? 'visible' : 'hidden'}`, data }))
];
const noticePage = paged(entries, 'noticeTable', 20);
const rows = noticePage.items.map(entry => {
const item = entry.data;
if (entry.kind === 'notice') return `<tr data-filter-row data-status="${h(entry.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>手动通知</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishAt || item.createdAt,true)}</td><td>${item.pinned ? '<span class="pin-label">首页置顶</span>' : item.status === 'published' ? '通知目录' : '尚未展示'}</td><td>${badge(item.status)}</td><td><div class="notice-row-actions">${item.status === 'draft' ? `<button class="row-action primary" data-action="edit-notice" data-id="${h(item.id)}">编辑</button>` : ''}<button class="row-action" data-action="toggle-notice" data-id="${h(item.id)}" data-status="${item.status === 'published' ? 'draft' : 'published'}">${item.status === 'published' ? '隐藏' : '发布'}</button></div></td></tr>`;
return `<tr data-filter-row data-status="${h(entry.status)}"><td><strong>${h(item.title)}</strong><small>${h(item.summary)}</small></td><td><strong>自动公示</strong><small>${h(item.category)}</small></td><td>${h(item.author)}</td><td>${formatDate(item.publishedAt,true)}</td><td>通知目录</td><td>${badge(item.status)}</td><td><button class="row-action ${item.visible ? '' : 'primary'}" data-action="toggle-publication" data-id="${h(item.id)}" data-source-type="${h(item.sourceType)}" data-visible="${item.visible ? 'false' : 'true'}">${item.visible ? '隐藏' : '显示'}</button></td></tr>`;
}).join('');
return `<section class="panel data-panel"><div class="data-toolbar notice-admin-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索全部通知、公示标题或分类"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="noticeTable" data-status="all">全部</button><button data-action="status-filter" data-target="noticeTable" data-status="visible">正在显示</button><button data-action="status-filter" data-target="noticeTable" data-status="hidden">已隐藏</button><button data-action="status-filter" data-target="noticeTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="noticeTable" data-status="system">自动公示</button></div><p>自动公示的内容由招生录取流程生成,这里只控制是否公开显示。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>标题</th><th>来源 / 分类</th><th>发布人</th><th>发布时间</th><th>展示位置</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无符合条件的通知或系统公示</td></tr>'}</tbody></table></div>${pagination(noticePage)}</section>`;
}
function adminAdmit(data) {
@@ -329,28 +340,32 @@ export function createAdminViews(context) {
}
function adminUsers(data) {
return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel admin-account-ledger"><div class="data-toolbar"><p>管理员账户不物理删除;停用会立即结束现有会话,并完整保留审批和审计记录。</p></div><div class="table-scroll"><table><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th><th>账户操作</th></tr></thead><tbody>${data.admins.map(item => { const self = item.id === state.user.id; return `<tr><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admin-password" data-id="${h(item.id)}" ${self ? 'disabled title="当前账号请到账户安全修改密码"' : ''}>重置密码</button><button class="row-action ${item.active ? 'danger' : 'primary'}" data-action="toggle-admin-account" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}" ${self ? 'disabled title="不能停用当前账号"' : ''}>${item.active ? '停用账户' : '重新启用'}</button></div></td></tr>`; }).join('')}</tbody></table></div></section>`;
const adminPage = paged(data.admins || [], 'adminAccountTable', 20);
return `<section class="panel registration-policy"><div><span>SELF REGISTRATION</span><h2>考生自主注册</h2><p>${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}</p></div><form data-form="self-registration-setting"><input type="hidden" name="enabled" value="${data.selfRegistrationEnabled ? 'false' : 'true'}"><span class="policy-state ${data.selfRegistrationEnabled ? 'open' : ''}">${data.selfRegistrationEnabled ? '已开放' : '已关闭'}</span><button class="${data.selfRegistrationEnabled ? 'ghost-button' : 'solid-button'}" type="submit">${data.selfRegistrationEnabled ? '关闭自主注册' : '开启自主注册'}</button></form></section><section class="panel data-panel admin-account-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="adminAccountTable" placeholder="搜索姓名、账号、层级、学校或班级"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="adminAccountTable" data-status="all">全部</button><button data-action="status-filter" data-target="adminAccountTable" data-status="active">已启用</button><button data-action="status-filter" data-target="adminAccountTable" data-status="disabled">已停用</button></div><p>账户不物理删除,停用后历史审批记录仍保留。</p></div><div class="table-scroll"><table id="adminAccountTable"><thead><tr><th>管理员</th><th>登录账号</th><th>层级</th><th>绑定范围</th><th>状态</th><th>账户操作</th></tr></thead><tbody>${adminPage.items.map(item => { const self = item.id === state.user.id; return `<tr data-status="${item.active ? 'active' : 'disabled'}"><td><div class="person-cell"><span>${h(item.displayName.slice(0, 1))}</span><div><strong>${h(item.displayName)}</strong><small>${h(item.id)}</small></div></div></td><td class="mono">${h(item.username)}</td><td><span class="admin-level level-${h(item.adminLevel)}">${h(item.levelName)}</span></td><td><strong>${h(item.schoolName || '全局')}</strong><small>${h(item.className || '')}</small></td><td>${item.active ? badge('approved') : badge('closed')}</td><td><div class="admin-account-actions"><button class="row-action" data-action="reset-admin-password" data-id="${h(item.id)}" ${self ? 'disabled title="当前账号请到账户安全修改密码"' : ''}>重置密码</button><button class="row-action ${item.active ? 'danger' : 'primary'}" data-action="toggle-admin-account" data-id="${h(item.id)}" data-active="${item.active ? 'false' : 'true'}" ${self ? 'disabled title="不能停用当前账号"' : ''}>${item.active ? '停用账户' : '重新启用'}</button></div></td></tr>`; }).join('') || '<tr><td colspan="6" class="empty-state">没有符合条件的管理员</td></tr>'}</tbody></table></div>${pagination(adminPage)}</section>`;
}
function adminCenters(data) {
const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' };
const detailAddresses = new Map(data.centers.map(center => [center.id, center.address]));
data.centers.forEach(center => { center.address = formatRegionAddress(center); });
const cards = data.centers.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
const centerPage = paged(data.centers, 'centerDossierGrid', 20);
const cards = centerPage.items.map(center => `<article class="panel center-dossier"><header><div><span>${h(center.schoolName)} · <b class="mono">${h(center.code)}</b></span><h2>${h(center.name)}</h2></div><div>${center.pendingChange ? '<span class="pending-mark">变更审批中</span>' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}<button class="row-action" data-action="edit-center" data-id="${h(center.id)}" ${center.pendingChange ? 'disabled title="已有待审批变更"' : ''}>提交变更</button></div></header><div class="center-metrics"><div><small>结构化考场</small><strong>${center.rooms.length}</strong><span>个</span></div><div><small>启用席位</small><strong>${center.totalCapacity}</strong><span>席</span></div><div><small>开放时间</small><strong>${h(center.gateOpenTime || '未设')}</strong></div></div><dl class="center-profile"><div><dt>详细地址</dt><dd>${h(center.address)}</dd></div><div><dt>考点负责人</dt><dd>${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}</dd></div><div><dt>应急电话</dt><dd>${h(center.emergencyPhone || '未填写')}</dd></div><div><dt>交通提示</dt><dd>${h(center.transport || '未填写')}</dd></div></dl><div class="room-table-wrap"><table class="room-table"><thead><tr><th>考场</th><th>位置</th><th>类型</th><th>容量</th><th>座位编排</th><th>状态</th></tr></thead><tbody>${center.rooms.map(room => `<tr><td><strong>${h(room.name)}</strong><small class="mono">${h(room.code)}</small></td><td>${h(room.building)} · ${h(room.floor || '楼层未填')}</td><td>${h(roomTypeNames[room.roomType] || room.roomType)}</td><td>${h(room.capacity)} 席</td><td>${h(room.seatPlan || '按现场座次表编排')}</td><td>${badge(room.status === 'active' ? 'approved' : 'closed')}</td></tr>`).join('')}</tbody></table></div><footer><span>${h(center.notes || '无补充说明')}</span><time>更新于 ${formatDate(center.updatedAt, true)}</time></footer></article>`).join('');
data.centers.forEach(center => { center.address = detailAddresses.get(center.id); });
const requests = data.changeRequests || [];
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="center-dossier-grid">${cards || emptyState('还没有正式考点', '提交考点和考场档案,经流程审批后会显示在这里。')}</div><section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="table-scroll"><table><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requests.map(item => `<tr><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无考点变更申请</td></tr>'}</tbody></table></div></section>`;
const requestPage = paged(requests, 'centerChangeTable', 20);
return `${excelToolbar('centers', { label: '考点考场档案' })}<section class="center-summary"><div><span>正式考点</span><strong>${data.centers.length}</strong></div><div><span>结构化考场</span><strong>${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}</strong></div><div><span>待审批变更</span><strong>${requests.filter(item => item.status === 'pending').length}</strong></div></section><div class="panel data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="centerDossierGrid" placeholder="搜索考点、学校、考场、负责人或地址"></label></div><div class="center-dossier-grid" id="centerDossierGrid">${cards || emptyState('没有符合条件的考点', '可清除搜索后查看全部正式考点。')}</div>${pagination(centerPage)}<section class="panel center-change-ledger"><div class="panel-title"><div><h2>考点变更台账</h2><p>新增和修改均保留申请快照,审批通过后才更新正式档案。</p></div><button class="row-action" data-route="admin/flows">进入流程中心</button></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="centerChangeTable" placeholder="搜索变更类型、考点、学校或责任人"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="centerChangeTable" data-status="all">全部</button><button data-action="status-filter" data-target="centerChangeTable" data-status="pending">待审批</button><button data-action="status-filter" data-target="centerChangeTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="centerChangeTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="centerChangeTable"><thead><tr><th>申请类型</th><th>考点</th><th>学校</th><th>考场数</th><th>提交时间</th><th>当前状态</th><th>责任人</th></tr></thead><tbody>${requestPage.items.map(item => `<tr data-status="${h(item.status)}"><td>${item.requestType === 'create' ? '新增考点' : '修改档案'}</td><td><strong>${h(item.name)}</strong><small class="mono">${h(item.code)}</small></td><td>${h(item.schoolName)}</td><td>${item.rooms.length} 个</td><td>${formatDate(item.createdAt, true)}</td><td>${badge(item.status)}</td><td>${h(item.workflow?.assignee?.displayName || '流程已结束')}</td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">没有符合条件的考点变更申请</td></tr>'}</tbody></table></div>${pagination(requestPage)}</section>`;
}
function adminAccountBatches(data) {
const classes = data.classes || [];
const batches = data.batches || [];
const batchPage = paged(batches, 'accountBatchLedger', 20);
const form = `<section class="panel batch-quota-panel"><div class="batch-quota-head"><div><span>SCHOOL ACCOUNT REQUEST</span><h2>按班级申领报名号</h2><p>只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。</p></div><div><small>单批上限</small><strong>500</strong><span>个账户</span></div></div><form data-form="candidate-account-batch"><div class="quota-grid">${classes.map(item => `<label><span><strong>${h(item.name)}</strong><small>${h(item.grade)}</small></span><span class="quota-input"><input type="number" min="0" max="200" value="0" data-class-id="${h(item.id)}"><em>人</em></span></label>`).join('')}</div><div class="batch-submit-bar"><p><strong>审批通过后生成</strong><span>固定报名号、随机初始密码、待补录考生账户</span></p><button class="solid-button" type="submit">提交批量申领</button></div></form></section>`;
const ledger = batches.map(batch => {
const ledger = batchPage.items.map(batch => {
const resultRows = batch.status === 'approved' ? `<div class="credential-sheet"><header><div><strong>账号下发清单</strong><span>${batch.totalCount} 个账号 · 考生首次登录必须改密</span></div><button class="row-action" data-action="excel-download" data-resource="account_results" data-batch-id="${h(batch.id)}">导出 Excel</button></header><div class="table-scroll"><table><thead><tr><th>序号</th><th>班级</th><th>固定报名号 / 账户</th><th>初始密码</th></tr></thead><tbody>${batch.items.map((item, index) => `<tr><td>${index + 1}</td><td>${h(item.className)}</td><td class="mono"><strong>${h(item.candidateNumber)}</strong></td><td class="mono credential-password">${h(item.initialPassword)}</td></tr>`).join('')}</tbody></table></div></div>` : '';
return `<article class="panel account-batch-card ${h(batch.status)}"><header><div><span class="mono">${h(batch.id)}</span><h2>${h(batch.schoolName)} · ${batch.totalCount} 个报名号</h2><p>${formatDate(batch.createdAt, true)}${h(batch.requesterName)} 提交</p></div>${badge(batch.status)}</header><div class="batch-quota-summary">${batch.quotas.map(item => `<span><strong>${h(item.className)}</strong><em>${item.count} 人</em></span>`).join('')}</div><div class="batch-flow-line"><span>当前进度</span><strong>${h(batch.workflow?.currentStepDetail?.name || statusLabels[batch.status])}</strong><small>${batch.workflow?.assignee ? `责任人:${h(batch.workflow.assignee.displayName)}` : batch.status === 'approved' ? '已生成并返回全部账户凭据' : '流程已结束'}</small></div>${batch.reviewNote ? `<div class="batch-review-note"><strong>审批意见</strong><span>${h(batch.reviewNote)}</span></div>` : ''}${resultRows}</article>`;
}).join('');
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div>${ledger || emptyState('没有申领批次', '在上方按班级填写人数并提交审批。')}</section>`;
return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}<section class="account-batch-ledger"><div class="ledger-title"><div><span>REQUEST LEDGER</span><h2>申领批次与返回结果</h2></div><p>结果只在最终批准后生成;报名号随后作为考生长期账户。</p></div><div class="panel data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="accountBatchLedger" placeholder="搜索学校、班级、批次号、提交人或状态"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="accountBatchLedger" data-status="all">全部</button><button data-action="status-filter" data-target="accountBatchLedger" data-status="pending">审批中</button><button data-action="status-filter" data-target="accountBatchLedger" data-status="approved">已通过</button><button data-action="status-filter" data-target="accountBatchLedger" data-status="rejected">已退回</button></div></div><div id="accountBatchLedger">${ledger || emptyState('没有符合条件的申领批次', '可清除筛选后查看全部记录。')}</div>${pagination(batchPage)}</section>`;
}
function adminNumberRules(data) {
+12 -4
View File
@@ -1,20 +1,21 @@
export function createAdmissionViews(context) {
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
function shell(page, content, title, description) {
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav>${nav.map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
const groups = [...new Set(nav.map(item => item[3]))];
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav class="portal-nav-groups">${groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(item => item[3] === group).map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</section>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
}
async function renderAdmission(page) {
if (state.user?.role !== 'admission_school') return requireLogin();
if (!nav.some(item => item[0] === page)) page = 'dashboard';
const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] };
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
try {
const endpoint = page === 'dashboard' ? 'context' : page;
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : placements(data);
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : noticeTemplate(data);
app.innerHTML = shell(page, content, ...meta[page]);
} catch (error) { renderError(error); }
}
@@ -24,6 +25,7 @@ export function createAdmissionViews(context) {
}
function paged(items, key, defaultPageSize = 50) {
items = filterTableItems(state, items, key);
const current = state.tablePages[key] || {};
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
const total = items.length;
@@ -33,6 +35,11 @@ export function createAdmissionViews(context) {
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
}
function noticeTemplate(data) {
const template = data.template || {};
return `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><i>录取专用章</i></div><p>右侧为 A4 通知书预览;正式下载件会自动写入考生、考试、录取类别及防伪查询码。</p></article></section>`;
}
function pagination(meta) {
if (!meta || meta.total <= meta.pageSize) return '';
const start = (meta.page - 1) * meta.pageSize + 1;
@@ -59,3 +66,4 @@ export function createAdmissionViews(context) {
}
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs';
import { filterTableItems } from './table-state.mjs';
+8 -3
View File
@@ -38,7 +38,12 @@ export function createCandidateViews(context) {
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
const roleName = role === 'admin' ? '管理后台' : '考生中心';
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
const groupFor = id => role === 'candidate'
? ({ dashboard: '个人总览', profile: '账户与档案', security: '账户与档案', exams: '考试服务', registrations: '考试服务', admit: '考试服务', results: '考试服务', admissions: '招生录取', notices: '招生录取' }[id] || '其他')
: ({ dashboard: '运行总览', schools: '组织与账户', organization: '组织与账户', admins: '组织与账户', 'account-batches': '组织与账户', candidates: '报名考务', registrations: '报名考务', payments: '报名考务', admit: '报名考务', exams: '考试与成绩', results: '考试与成绩', admissions: '招生录取', 'indicator-qualifications': '招生录取', notices: '招生录取', centers: '场所与流程', flows: '场所与流程', 'flow-design': '系统配置', 'number-rules': '系统配置', security: '系统配置' }[id] || '其他');
const groups = [...new Set(nav.map(([id]) => groupFor(id)))];
const navHtml = groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</section>`).join('');
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav class="portal-nav-groups">${navHtml}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
}
function portalHeadingAction(role, page) {
@@ -188,7 +193,7 @@ export function createCandidateViews(context) {
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
}).join('');
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} ` : ` ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small></small><strong>${h(summary?.total ?? '')}<em> / ${h(summary?.fullScore ?? '')}</em></strong><i></i></span><span><small></small><strong>${h(summary?.featureScore ?? 0)}</strong><i></i></span><span><small></small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small></small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} </em></strong><i>${summary?.complete ? '' : ''}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '' : ''}</p><strong> ${items.length} </strong></footer></section>`;
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} ` : ` ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small></small><strong>${h(summary?.total ?? '')}<em> / ${h(summary?.fullScore ?? '')}</em></strong><i></i></span><span><small></small><strong>${h(summary?.featureScore ?? 0)}</strong><i></i></span><span><small></small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small></small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} </em></strong><i>${summary?.complete ? '' : ''}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '' : ''}</p><div class="result-footer-actions"><strong> ${items.length} </strong><button class="solid-button" data-action="download-score-report" data-exam-id="${h(items[0].examId)}"> PDF </button></div></footer></section>`;
return items[0].archivedAt ? `<details class="candidate-archive-fold result-archive-fold"><summary><span><strong>${h(examName)}</strong><small>${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定</small></span><b>历史成绩</b></summary>${panel}</details>` : panel;
}).join('')}</div>`;
}
@@ -216,7 +221,7 @@ export function createCandidateViews(context) {
const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); return `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b>${h(plan ? `${plan.schoolCode} · ${plan.schoolName} · ${category?.name || choice.categoryCode}` : `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).join('');
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,通知已发送' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small></div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)}`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong><small>${item.placement.status === 'final' ? '已正式录取,可下载正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout">${item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'}</div>`}</section>`;
}).join('')}</div>`;
}
+150
View File
@@ -0,0 +1,150 @@
const A4 = { width: 2480, height: 3508 };
function roundRect(ctx, x, y, width, height, radius = 18) {
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
}
function fitText(ctx, text, maxWidth, initialSize, weight = 400) {
let size = initialSize;
do {
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
if (ctx.measureText(String(text)).width <= maxWidth) return size;
size -= 2;
} while (size > 24);
return size;
}
function drawText(ctx, text, x, y, { size = 36, weight = 400, color = '#17213f', align = 'left', maxWidth } = {}) {
if (maxWidth) size = fitText(ctx, text, maxWidth, size, weight);
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
ctx.fillStyle = color;
ctx.textAlign = align;
ctx.textBaseline = 'alphabetic';
ctx.fillText(String(text ?? ''), x, y, maxWidth);
}
function jpegPdf(dataUrl, width, height) {
const binary = atob(dataUrl.split(',')[1]);
const image = Uint8Array.from(binary, char => char.charCodeAt(0));
const encoder = new TextEncoder();
const chunks = [];
const offsets = [0];
let length = 0;
const add = value => { const bytes = typeof value === 'string' ? encoder.encode(value) : value; chunks.push(bytes); length += bytes.length; };
add('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
const object = (id, body) => { offsets[id] = length; add(`${id} 0 obj\n${body}\nendobj\n`); };
object(1, '<< /Type /Catalog /Pages 2 0 R >>');
object(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
object(3, '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>');
offsets[4] = length;
add(`4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.length} >>\nstream\n`);
add(image); add('\nendstream\nendobj\n');
const stream = 'q\n595.28 0 0 841.89 0 0 cm\n/Im0 Do\nQ';
object(5, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
const xref = length;
add(`xref\n0 6\n0000000000 65535 f \n`);
for (let id = 1; id <= 5; id += 1) add(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`);
add(`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);
const output = new Uint8Array(length);
let cursor = 0;
for (const chunk of chunks) { output.set(chunk, cursor); cursor += chunk.length; }
return new Blob([output], { type: 'application/pdf' });
}
function downloadCanvasPdf(canvas, filename) {
const blob = jpegPdf(canvas.toDataURL('image/jpeg', .94), canvas.width, canvas.height);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 3000);
}
export function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl }) {
const canvas = document.createElement('canvas');
Object.assign(canvas, A4);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f5f8fb'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#14234b'; ctx.fillRect(0, 0, canvas.width, 270);
ctx.fillStyle = '#c94b45'; ctx.fillRect(170, 234, 250, 12);
drawText(ctx, organization?.name || '考试服务平台', 170, 112, { size: 42, weight: 700, color: '#ffffff' });
drawText(ctx, '考 生 成 绩 单', 170, 205, { size: 76, weight: 700, color: '#ffffff' });
drawText(ctx, exam.code, 2300, 115, { size: 32, weight: 600, color: '#9eadce', align: 'right' });
drawText(ctx, exam.name, 2300, 192, { size: 38, weight: 500, color: '#ffffff', align: 'right', maxWidth: 1120 });
const box = (x, y, w, h, fill = '#ffffff') => { roundRect(ctx, x, y, w, h, 22); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#dce4ec'; ctx.lineWidth = 2; ctx.stroke(); };
box(170, 330, 2140, 300);
const meta = [['姓名', candidate.name], ['报名号', candidate.candidateNumber], ['考试', exam.name], ['发布时间', summary?.publishedAt ? new Date(summary.publishedAt).toLocaleString('zh-CN') : '以系统记录为准']];
meta.forEach(([label, value], index) => {
const x = 225 + (index % 2) * 1050, y = 420 + Math.floor(index / 2) * 115;
drawText(ctx, label, x, y, { size: 28, color: '#77839a' });
drawText(ctx, value, x + 155, y, { size: 34, weight: 600, maxWidth: 800 });
});
box(170, 690, 2140, 320, '#eaf4f1');
const totals = [['总分', `${summary?.total ?? '—'} / ${summary?.fullScore ?? '—'}`], ['特征分', summary?.featureScore ?? 0], ['合格结论', summary?.qualified == null ? '不判定' : summary.qualified ? '合格' : '未合格'], ['发布进度', `${summary?.publishedSubjects ?? results.length} / ${summary?.subjectCount ?? results.length}`]];
totals.forEach(([label, value], index) => {
const x = 235 + index * 520;
drawText(ctx, label, x, 790, { size: 28, color: '#5d766f' });
drawText(ctx, value, x, 900, { size: 48, weight: 700, color: '#173b35', maxWidth: 440 });
});
drawText(ctx, '科目成绩与等级排名', 170, 1115, { size: 42, weight: 700 });
drawText(ctx, '等级与排名均以系统正式发布数据为准', 2310, 1115, { size: 25, color: '#7b8598', align: 'right' });
const cols = 2, gap = 34, cardW = (2140 - gap) / cols, cardH = Math.min(300, Math.max(220, (1760 - Math.ceil(results.length / cols) * 20) / Math.ceil(results.length / cols)));
results.forEach((item, index) => {
const col = index % cols, row = Math.floor(index / cols), x = 170 + col * (cardW + gap), y = 1180 + row * (cardH + 20);
box(x, y, cardW, cardH);
drawText(ctx, item.subjectName, x + 42, y + 72, { size: 38, weight: 700, maxWidth: cardW - 450 });
drawText(ctx, item.qualified == null ? '不判定' : item.qualified ? '达线' : '未达线', x + cardW - 42, y + 70, { size: 27, weight: 600, color: item.qualified === false ? '#b43d38' : '#2d7462', align: 'right' });
drawText(ctx, item.score, x + 42, y + 158, { size: 62, weight: 700 });
drawText(ctx, `/ ${item.fullScore}`, x + 190, y + 156, { size: 28, color: '#8993a6' });
drawText(ctx, `${item.grade} · 第 ${item.rank} / ${item.cohortSize} 名 · 前 ${item.rankPercent}%`, x + 42, y + 220, { size: 28, color: '#455068', maxWidth: cardW - 84 });
drawText(ctx, item.passText || '不设单科线', x + 42, y + cardH - 30, { size: 24, color: '#7c8798', maxWidth: cardW - 84 });
});
const footerY = 3100;
box(170, footerY, 2140, 235, '#f0f3f7');
drawText(ctx, '防伪查询码', 225, footerY + 70, { size: 28, color: '#6f7a8e' });
drawText(ctx, verificationCode, 225, footerY + 135, { size: 38, weight: 700, color: '#17213f' });
drawText(ctx, '登录考试服务平台,在“文书防伪查询”中输入本码核验。', 225, footerY + 188, { size: 24, color: '#667085' });
drawText(ctx, verificationUrl, 2250, footerY + 135, { size: 22, color: '#53627b', align: 'right', maxWidth: 1050 });
drawText(ctx, `生成时间 ${new Date().toLocaleString('zh-CN')}`, 2310, 3435, { size: 22, color: '#8a94a6', align: 'right' });
downloadCanvasPdf(canvas, `${exam.name}-${candidate.name}-成绩单.pdf`);
}
export function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl }) {
const canvas = document.createElement('canvas'); Object.assign(canvas, A4);
const ctx = canvas.getContext('2d');
const primary = template.primaryColor || '#8d2028', accent = template.accentColor || '#c9a45b';
ctx.fillStyle = '#fffdf8'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = primary; ctx.lineWidth = 10; ctx.strokeRect(90, 90, 2300, 3328);
ctx.strokeStyle = accent; ctx.lineWidth = 3; ctx.strokeRect(120, 120, 2240, 3268);
drawText(ctx, template.eyebrow || 'ADMISSION NOTICE', 1240, 350, { size: 30, weight: 600, color: accent, align: 'center' });
drawText(ctx, template.title || '录 取 通 知 书', 1240, 560, { size: 96, weight: 700, color: primary, align: 'center', maxWidth: 1950 });
drawText(ctx, school.name, 1240, 700, { size: 42, weight: 600, align: 'center', maxWidth: 1900 });
drawText(ctx, `${candidate.name} 同学:`, 300, 1040, { size: 48, weight: 700 });
const body = (template.body || '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。')
.replaceAll('{{考生姓名}}', candidate.name).replaceAll('{{考试名称}}', exam.name).replaceAll('{{录取学校}}', school.name).replaceAll('{{录取类别}}', placement.payload.categoryName || '招生类别');
const lines = [];
for (const paragraph of body.split(/\n+/)) {
let line = '';
for (const char of paragraph) {
ctx.font = '400 42px "Microsoft YaHei", sans-serif';
if (ctx.measureText(line + char).width > 1840) { lines.push(line); line = char; } else line += char;
}
if (line) lines.push(line); lines.push('');
}
lines.slice(0, 13).forEach((line, index) => drawText(ctx, line, 320, 1210 + index * 82, { size: 42, color: '#332f2c' }));
drawText(ctx, template.footer || '请妥善保管本通知书,报到时出示。', 300, 2550, { size: 32, color: '#6c6257', maxWidth: 1700 });
drawText(ctx, school.name, 2080, 2750, { size: 38, weight: 700, color: primary, align: 'right' });
drawText(ctx, new Date().toLocaleDateString('zh-CN'), 2080, 2820, { size: 30, color: '#5f5951', align: 'right' });
ctx.beginPath(); ctx.arc(1940, 2670, 160, 0, Math.PI * 2); ctx.strokeStyle = `${primary}99`; ctx.lineWidth = 12; ctx.stroke();
drawText(ctx, '录取专用章', 1940, 2682, { size: 34, weight: 700, color: `${primary}bb`, align: 'center' });
roundRect(ctx, 240, 3040, 2000, 210, 20); ctx.fillStyle = '#f4efe5'; ctx.fill();
drawText(ctx, '防伪查询码', 300, 3115, { size: 26, color: '#756b5e' });
drawText(ctx, verificationCode, 300, 3185, { size: 35, weight: 700 });
drawText(ctx, verificationUrl, 2160, 3185, { size: 21, color: '#71695f', align: 'right', maxWidth: 1000 });
drawText(ctx, organization?.name || '考试服务平台', 1240, 3380, { size: 23, color: '#8a8177', align: 'center' });
downloadCanvasPdf(canvas, `${school.name}-${candidate.name}-录取通知书.pdf`);
}
+10 -2
View File
@@ -20,7 +20,7 @@ export function createPublicViews(context) {
}
function publicHeader() {
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#verify" data-route="verify">文书防伪查询</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
}
function renderHome() {
@@ -99,6 +99,14 @@ export function createPublicViews(context) {
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><div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
app.innerHTML = `${publicHeader()}<main class="public-main verification-page"><section class="verification-hero"><div><p class="overline">DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><p>输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。</p></div><form data-form="document-verification"><label><span>防伪查询码</span><input name="code" value="${h(code)}" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button class="solid-button" type="submit">立即核验 ${icons.arrow}</button></form></section>${outcome}<section class="verification-notice"><strong>安全提示</strong><p>查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。</p></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>系统签名实时核验</span></footer>`;
}
function loginForm() {
return `<form class="stack-form" data-form="login"><label><span>报名号 / 管理员账号</span><input name="username" autocomplete="username" required placeholder="例如 2026-HZ01-F-0001"></label><label><span>密码</span><input name="password" type="password" autocomplete="current-password" required placeholder="首次登录请输入学校下发的初始密码"></label><button class="solid-button large" type="submit">登录系统 ${icons.arrow}</button></form>`;
@@ -109,5 +117,5 @@ export function createPublicViews(context) {
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
}
return { brand, renderHome, renderNoticeCenter, renderAuth };
return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
}
+1
View File
@@ -14,5 +14,6 @@ export const state = {
resultExamCatalog: null,
resultImportPreview: null,
tablePages: {},
tableFilters: {},
loading: false
};
+44
View File
@@ -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);
}