233 lines
41 KiB
JavaScript
233 lines
41 KiB
JavaScript
import { mountRegionSelects } from './region-select.mjs';
|
||
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs';
|
||
|
||
export function createCandidateViews(context) {
|
||
const {
|
||
state,
|
||
app,
|
||
h,
|
||
formatDate,
|
||
dateRange,
|
||
badge,
|
||
money,
|
||
passPolicyText,
|
||
statusLabels,
|
||
icons,
|
||
api,
|
||
renderError,
|
||
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'], ['flows', '流程中心', 'check'], core[4], security];
|
||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
|
||
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['admissions', '招生录取', 'check'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
|
||
}
|
||
|
||
function portalShell(role, page, content, title, description) {
|
||
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>`;
|
||
}
|
||
|
||
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 navigate('login');
|
||
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' ? { notices: state.publicData.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><strong>已发布 ${items.length} 科</strong></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: '招生学校审核中', supplementary: '补录填报中', completed: '录取结束' };
|
||
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
|
||
return `${data.notifications?.length ? `<section class="panel admission-notification"><strong>${h(data.notifications[0].payload.title)}</strong><p>${h(data.notifications[0].payload.message)}</p><small>${formatDate(data.notifications[0].createdAt, true)}</small></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;
|
||
const placementSchool = item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '';
|
||
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
|
||
const progressIndex = item.status === 'supplementary' ? 1 : Math.max(0, progressSteps.indexOf(item.status));
|
||
const choiceRows = Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => {
|
||
const choice = choices[index] || {};
|
||
const plan = item.plans.find(entry => entry.schoolId === choice.schoolId);
|
||
const categoryOptions = (plan?.categories || []).filter(category => category.remaining > 0 || category.code === choice.categoryCode);
|
||
return `<div class="preference-choice-row"><b>${index + 1}</b><label><span>招生学校代码 / 学校</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}"><option value="">${index ? '可不填' : '请选择第一志愿学校'}</option>${item.plans.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'}><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(category.remaining)}</option>`).join('')}</select></label></div>`;
|
||
}).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); return `<span><b>${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) || '普通生';
|
||
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><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>按学校代码填写志愿</strong><small>先匹配招生学校,再选择该校对本人开放的招生类别;仅你本人可保存和修改。</small></div><span>最多 ${h(item.payload.maxChoices)} 个</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿</button></form>` : choices.length ? `<div class="locked-preferences"><strong>已锁定志愿顺序</strong>${lockedRows}</div>` : '<div class="read-only-callout">当前不能填报:请等待成绩完整发布或志愿填报窗口开放。</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 };
|
||
}
|