志愿
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
||||
export function createAdmissionViews(context) {
|
||||
const { state, app, h, formatDate, badge, icons, api, renderError, brand } = context;
|
||||
const nav = [['dashboard','工作台','home'],['plans','招生计划','exam'],['placements','投档审核','check']];
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
async function renderAdmission(page) {
|
||||
if (state.user?.role !== 'admission_school') return navigate('login');
|
||||
if (!nav.some(item => item[0] === page)) page = 'dashboard';
|
||||
const meta = { dashboard:['招生工作台','查看本校计划与待审核投档概况。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'] };
|
||||
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);
|
||||
app.innerHTML = shell(page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
function dashboard(data) {
|
||||
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section><div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button></section></div>`;
|
||||
}
|
||||
|
||||
function plans(data) {
|
||||
return `<section class="panel admission-plan-console"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId">${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><label><span>招生类别 *</span><textarea name="categoriesText" rows="5" required placeholder="普通生 | 120 | 体育特长生 | 8 | 田径"></textarea></label><label><span>计划说明</span><textarea name="note" rows="2"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="table-scroll"><table><thead><tr><th>考试</th><th>类别计划</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${data.plans.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人`).join('<br>')}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function placements(data) {
|
||||
return `<section class="panel data-panel"><div class="panel-title"><div><h2>本校投档名单</h2><p>显示投档所需的考生信息与当次成绩,不包含其余志愿。</p></div><span>${data.placements.length} 人</span></div><div class="table-scroll"><table><thead><tr><th>考生</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>审核</th></tr></thead><tbody>${data.placements.map(item => `<tr><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small></td><td>${h((item.candidate.specialtyTypes || []).join('、') || '普通生')}<small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>总分 ${h(item.payload.totalScore)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写特殊理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div></section>`;
|
||||
}
|
||||
return { renderAdmission };
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function createCandidateViews(context) {
|
||||
|
||||
const candidateNav = [
|
||||
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
|
||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'],
|
||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'],
|
||||
['security', '账户安全', 'user']
|
||||
];
|
||||
function adminNavForUser() {
|
||||
@@ -30,7 +30,7 @@ export function createCandidateViews(context) {
|
||||
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'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], 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) {
|
||||
@@ -54,6 +54,12 @@ export function createCandidateViews(context) {
|
||||
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;
|
||||
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"><label><span>特长生类型</span><input name="specialtyTypes" value="${h((profile.specialtyTypes || []).join('、'))}" placeholder="例如:田径、声乐"></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';
|
||||
@@ -76,6 +82,7 @@ export function createCandidateViews(context) {
|
||||
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;
|
||||
@@ -87,23 +94,24 @@ export function createCandidateViews(context) {
|
||||
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' : 'registrations';
|
||||
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), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
|
||||
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') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
|
||||
if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); }
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
@@ -182,6 +190,20 @@ export function createCandidateViews(context) {
|
||||
}).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 options = item.plans.flatMap(plan => plan.categories.filter(category => category.remaining > 0 || choices.some(choice => choice.schoolId === plan.schoolId && choice.categoryCode === category.code)).map(category => ({ value: `${plan.schoolId}|${category.code}`, label: `${plan.schoolName} · ${category.name}`, specialtyType: category.specialtyType, remaining: category.remaining })));
|
||||
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));
|
||||
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><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">${Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => { const selected = choices[index] ? `${choices[index].schoolId}|${choices[index].categoryCode}` : ''; return `<label><b>${index + 1}</b><select name="choices"><option value="">${index ? '可不填' : '请选择第一志愿'}</option>${options.map(option => `<option value="${h(option.value)}" ${option.value === selected ? 'selected' : ''}>${h(option.label)}${option.specialtyType ? `(限 ${h(option.specialtyType)})` : ''} · 余 ${h(option.remaining)}</option>`).join('')}</select></label>`; }).join('')}</div><button class="solid-button" type="submit">保存本人志愿</button></form>` : choices.length ? `<div class="locked-preferences"><strong>已锁定志愿顺序</strong>${choices.map((choice, index) => { const option = options.find(entry => entry.value === `${choice.schoolId}|${choice.categoryCode}`); return `<span><b>${index + 1}</b>${h(option?.label || `${choice.schoolId} · ${choice.categoryCode}`)}</span>`; }).join('')}</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>`;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ 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="#home-notices" data-action="scroll-to" data-target="home-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' ? '管理后台' : '考生中心'}</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="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#home-admissions" data-action="scroll-to" data-target="home-admissions">录取公示</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
app.classList.remove('admin-readable');
|
||||
const { notices, exams, stats, organization } = state.publicData;
|
||||
const { notices, exams, stats, organization, admissionAnnouncements = [] } = state.publicData;
|
||||
const siteCopy = state.publicData.siteCopy || {};
|
||||
const featured = exams.find(exam => exam.registrationState === 'open') || exams[0];
|
||||
const topNotice = notices[0];
|
||||
@@ -37,6 +37,7 @@ export function createPublicViews(context) {
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
|
||||
${admissionAnnouncements.length ? `<section class="content-section public-admission-section" id="home-admissions"><div class="section-heading"><div><p class="overline">ADMISSION DISCLOSURE</p><h2>录取结果公示</h2></div><p>报名号、姓名、总成绩与录取学校透明公开;证件号和联系方式已脱敏。</p></div>${admissionAnnouncements.map(announcement => `<article class="panel public-admission-board"><header><div><span>${formatDate(announcement.completedAt)}</span><h3>${h(announcement.examName)}</h3></div><strong>${announcement.rows.length} 人录取</strong></header><div class="table-scroll"><table><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th><th>身份核验</th></tr></thead><tbody>${announcement.rows.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td><td class="mono">${h(row.idNumberMasked)}</td></tr>`).join('')}</tbody></table></div></article>`).join('')}</section>` : ''}
|
||||
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
|
||||
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
|
||||
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const state = {
|
||||
user: null,
|
||||
profile: null,
|
||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||
publicData: { organization: {}, notices: [], exams: [], admissionAnnouncements: [], stats: {} },
|
||||
permissions: [],
|
||||
scopeLabel: '',
|
||||
pageData: null,
|
||||
|
||||
@@ -3,6 +3,8 @@ export const statusLabels = {
|
||||
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
||||
supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', final: '正式录取', unread: '未读'
|
||||
};
|
||||
|
||||
export const icons = {
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
||||
const adminId = 'usr_admin';
|
||||
const createdAt = nowIso();
|
||||
return {
|
||||
meta: { version: 17, createdAt },
|
||||
meta: { version: 18, createdAt },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||
schools: [], classes: [],
|
||||
@@ -83,6 +83,6 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
||||
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 }
|
||||
]
|
||||
}],
|
||||
workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], auditLogs: []
|
||||
workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], admissionRecords: [], auditLogs: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
],
|
||||
workflowInstances: [],
|
||||
workflowActions: [],
|
||||
admissionRecords: [],
|
||||
auditLogs: [
|
||||
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
||||
]
|
||||
|
||||
@@ -167,6 +167,16 @@ export function createMysqlAdapter(context) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 17;
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 18) {
|
||||
await pool.query("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'candidate', 'admission_school') NOT NULL");
|
||||
const [profileColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_types', 'specialty_certificate', 'policy_eligibility')");
|
||||
const existingProfileColumns = new Set(profileColumns.map(item => item.Field));
|
||||
if (!existingProfileColumns.has('specialty_types')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()) AFTER guardian_phone');
|
||||
if (!existingProfileColumns.has('specialty_certificate')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_certificate VARCHAR(255) NULL AFTER specialty_types');
|
||||
if (!existingProfileColumns.has('policy_eligibility')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN policy_eligibility VARCHAR(255) NULL AFTER specialty_certificate');
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 18;
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
@@ -349,7 +359,7 @@ export function createMysqlAdapter(context) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 17, ?, ?, ?)
|
||||
VALUES (1, 18, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
|
||||
+37
-2
@@ -47,7 +47,7 @@ export const sqliteSchema = `
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
candidate_number TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')),
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
|
||||
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
@@ -90,6 +90,9 @@ export const sqliteSchema = `
|
||||
postal_code TEXT,
|
||||
guardian_name TEXT,
|
||||
guardian_phone TEXT,
|
||||
specialty_types TEXT NOT NULL DEFAULT '[]',
|
||||
specialty_certificate TEXT,
|
||||
policy_eligibility TEXT,
|
||||
profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
review_note TEXT,
|
||||
@@ -426,6 +429,18 @@ export const sqliteSchema = `
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admission_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification')),
|
||||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end);
|
||||
@@ -446,6 +461,7 @@ export const sqliteSchema = `
|
||||
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
|
||||
BEFORE INSERT ON results
|
||||
@@ -536,7 +552,7 @@ export const mysqlSchema = [
|
||||
username VARCHAR(100) NOT NULL,
|
||||
candidate_number VARCHAR(120) NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin', 'candidate') NOT NULL,
|
||||
role ENUM('admin', 'candidate', 'admission_school') NOT NULL,
|
||||
admin_level ENUM('super', 'school', 'class') NULL,
|
||||
school_id VARCHAR(64) NULL,
|
||||
class_id VARCHAR(64) NULL,
|
||||
@@ -586,6 +602,9 @@ export const mysqlSchema = [
|
||||
postal_code VARCHAR(20) NULL,
|
||||
guardian_name VARCHAR(100) NULL,
|
||||
guardian_phone VARCHAR(60) NULL,
|
||||
specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()),
|
||||
specialty_certificate VARCHAR(255) NULL,
|
||||
policy_eligibility VARCHAR(255) NULL,
|
||||
profile_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
review_note VARCHAR(500) NULL,
|
||||
@@ -987,6 +1006,22 @@ export const mysqlSchema = [
|
||||
CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admission_records (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
kind ENUM('setting', 'plan', 'preference', 'placement', 'notification') NOT NULL,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
school_id VARCHAR(64) NULL,
|
||||
status VARCHAR(40) NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_admission_records_lookup (kind, exam_id, school_id, user_id, status),
|
||||
CONSTRAINT fk_admission_record_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admission_record_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admission_record_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
actor_id VARCHAR(64) NULL,
|
||||
|
||||
@@ -32,6 +32,8 @@ export function createSqliteAdapter(context) {
|
||||
ensureColumns('users', [
|
||||
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
|
||||
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['totp_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['totp_secret_encrypted', 'TEXT'],
|
||||
['totp_recovery_codes', "TEXT NOT NULL DEFAULT '[]'"], ['totp_last_used_step', 'INTEGER'],
|
||||
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
||||
]);
|
||||
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
|
||||
@@ -39,7 +41,8 @@ export function createSqliteAdapter(context) {
|
||||
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
|
||||
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
|
||||
['district_code', 'TEXT'], ['district_name', 'TEXT']
|
||||
['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
|
||||
['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
|
||||
]);
|
||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||||
ensureColumns('exams', [
|
||||
@@ -68,6 +71,32 @@ export function createSqliteAdapter(context) {
|
||||
ensureColumns('admit_card_subjects', [
|
||||
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"]
|
||||
]);
|
||||
if (tableExists('users')) {
|
||||
const usersSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'").get()?.sql || '';
|
||||
if (!usersSql.includes('admission_school')) {
|
||||
connection.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE users_v18 (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
|
||||
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)), totp_secret_encrypted TEXT,
|
||||
totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_last_used_step INTEGER, archived_at TEXT,
|
||||
archived_by TEXT REFERENCES users_v18(id) ON DELETE RESTRICT, display_name TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
INSERT INTO users_v18 SELECT id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
|
||||
active, must_change_password, COALESCE(totp_enabled, 0), totp_secret_encrypted, COALESCE(totp_recovery_codes, '[]'),
|
||||
totp_last_used_step, archived_at, archived_by, display_name, created_at FROM users;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_v18 RENAME TO users;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
if (tableExists('workflow_definitions')) {
|
||||
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
|
||||
if (!definitionSql.includes('candidate_account_batch')) {
|
||||
@@ -246,13 +275,10 @@ export function createSqliteAdapter(context) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
|
||||
connection.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1));
|
||||
ALTER TABLE users ADD COLUMN totp_secret_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER;
|
||||
UPDATE schema_metadata SET schema_version = 17 WHERE id = 1;
|
||||
`);
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
@@ -414,7 +440,7 @@ export function createSqliteAdapter(context) {
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 17, ?, ?, ?)
|
||||
VALUES (1, 18, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
||||
import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||
|
||||
export function createAdminRoutes(context) {
|
||||
const {
|
||||
@@ -108,6 +109,16 @@ export function createAdminRoutes(context) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeAdmissionCategories(input) {
|
||||
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
||||
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
|
||||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyType: cleanText(item.specialtyType, 80),
|
||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||||
})).filter(item => item.sourceSchoolId && item.quota > 0)
|
||||
})).filter(item => item.code && item.name && item.quota > 0);
|
||||
}
|
||||
|
||||
async function handleAdmin(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admin/')) return false;
|
||||
const user = await requireUser(request, response, 'admin');
|
||||
@@ -128,6 +139,122 @@ export function createAdminRoutes(context) {
|
||||
classes: db.classes
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
|
||||
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] }));
|
||||
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '', examName: db.exams.find(item => item.id === plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan) }));
|
||||
const placements = admissionRecords(db, 'placement').map(placement => {
|
||||
const account = db.users.find(item => item.id === placement.userId) || {};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [] }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' };
|
||||
});
|
||||
const preferences = admissionRecords(db, 'preference').map(preference => {
|
||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
||||
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => ({ ...choice, schoolName: db.schools.find(item => item.id === choice.schoolId)?.name || '' })) };
|
||||
});
|
||||
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(safeUser);
|
||||
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), exams: db.exams.filter(item => !item.archivedAt) });
|
||||
}
|
||||
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
||||
const body = await readJson(request);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
||||
const username = cleanText(body.username, 80);
|
||||
const password = String(body.password || '');
|
||||
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
|
||||
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '登录账号已存在');
|
||||
const account = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admission_school', schoolId: school.id, displayName: cleanText(body.displayName, 80) || `${school.name}招生办`, active: true, createdAt: nowIso() };
|
||||
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
|
||||
return sendJson(response, 201, { ok: true, account: safeUser(account) });
|
||||
}
|
||||
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
|
||||
if (settingMatch && request.method === 'PUT') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
|
||||
const exam = db.exams.find(item => item.id === settingMatch[1] && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||||
const body = await readJson(request);
|
||||
const status = admissionPhases.has(body.status) ? body.status : 'draft';
|
||||
const now = nowIso();
|
||||
const setting = admissionSetting(db, exam.id) || { id: uid('admission_setting'), kind: 'setting', examId: exam.id, userId: user.id, schoolId: null, createdAt: now };
|
||||
setting.status = status;
|
||||
setting.updatedAt = now;
|
||||
setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
|
||||
if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间');
|
||||
await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`));
|
||||
return sendJson(response, 200, { ok: true, setting });
|
||||
}
|
||||
if (pathname === '/api/admin/admission-plans' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划');
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
||||
const categories = normalizeAdmissionCategories(body.categories);
|
||||
if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划');
|
||||
if (categories.some(item => item.indicatorAllocations.reduce((sum, allocation) => sum + allocation.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过类别计划人数');
|
||||
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID');
|
||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||
const now = nowIso();
|
||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
|
||||
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
|
||||
if (planReviewMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核招生计划');
|
||||
const plan = admissionRecords(db, 'plan').find(item => item.id === planReviewMatch[1]);
|
||||
if (!plan) return sendError(response, 404, '招生计划不存在');
|
||||
const body = await readJson(request);
|
||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||
plan.status = body.status;
|
||||
plan.updatedAt = nowIso();
|
||||
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
|
||||
return sendJson(response, 200, { ok: true, plan });
|
||||
}
|
||||
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
|
||||
if (actionMatch && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以执行投档与录取操作');
|
||||
const setting = admissionSetting(db, actionMatch[1]);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开启志愿填报');
|
||||
const action = actionMatch[2];
|
||||
if (action === 'match') {
|
||||
if (!['closed', 'supplementary'].includes(setting.status)) return sendError(response, 409, '请先结束当前填报阶段再投档');
|
||||
const placements = buildVolunteerPlacements(db, setting, { uid, nowIso });
|
||||
setting.status = 'school_review'; setting.updatedAt = nowIso(); setting.payload.progress = `第 ${setting.payload.round || 1} 轮投档完成,${placements.length} 人已发送招生学校审核`;
|
||||
await database.saveAdmissionRecords([setting, ...placements], logAction(db, user, '执行分数优先志愿投档', `${setting.examId} · ${placements.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, setting, placementCount: placements.length });
|
||||
}
|
||||
if (action === 'finalize') {
|
||||
const placements = admissionRecords(db, 'placement', setting.examId);
|
||||
if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理');
|
||||
const now = nowIso();
|
||||
const admitted = placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now }));
|
||||
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
|
||||
setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果已经通知并自动公示'; setting.payload.completedAt = now;
|
||||
await database.saveAdmissionRecords([setting, ...admitted, ...notifications], logAction(db, user, '结束录取并发布结果', `${setting.examId} · ${admitted.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows({ ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }, setting.examId) });
|
||||
}
|
||||
const body = await readJson(request);
|
||||
const now = nowIso();
|
||||
setting.status = 'supplementary'; setting.updatedAt = now; setting.payload = { ...setting.payload, round: Number(setting.payload.round || 1) + 1, preferenceStart: cleanText(body.preferenceStart, 35) || now, preferenceEnd: cleanText(body.preferenceEnd, 35), progress: '招生计划未满,补录志愿填报进行中' };
|
||||
await database.saveAdmissionRecord(setting, logAction(db, user, '开启补录', `${setting.examId} · 第 ${setting.payload.round} 轮`));
|
||||
return sendJson(response, 200, { ok: true, setting });
|
||||
}
|
||||
const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/);
|
||||
if (withdrawalMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档');
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending');
|
||||
if (!placement) return sendError(response, 404, '待审核退档申请不存在');
|
||||
const body = await readJson(request);
|
||||
placement.status = body.approved === true ? 'withdrawn' : 'admitted';
|
||||
placement.updatedAt = nowIso();
|
||||
placement.payload.withdrawalReviewNote = cleanText(body.reviewNote, 500);
|
||||
await database.saveAdmissionRecord(placement, logAction(db, user, body.approved === true ? '批准退档' : '驳回退档', placement.id));
|
||||
return sendJson(response, 200, { ok: true, placement });
|
||||
}
|
||||
|
||||
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/);
|
||||
if (excelMatch && request.method === 'GET') {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||
|
||||
function normalizeCategories(input, cleanText) {
|
||||
const source = Array.isArray(input) ? input : [];
|
||||
return source.map((item, index) => ({
|
||||
code: cleanText(item.code || `category_${index + 1}`, 40),
|
||||
name: cleanText(item.name, 80),
|
||||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
|
||||
specialtyType: cleanText(item.specialtyType, 80),
|
||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||||
})).filter(item => item.sourceSchoolId && item.quota > 0)
|
||||
})).filter(item => item.code && item.name && item.quota > 0);
|
||||
}
|
||||
|
||||
export function createAdmissionRoutes(context) {
|
||||
const { database, readDb, sendJson, sendError, readJson, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
|
||||
|
||||
async function handleAdmission(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admission/')) return false;
|
||||
const user = await requireUser(request, response, 'admission_school');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
const school = db.schools.find(item => item.id === user.schoolId && item.active);
|
||||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
||||
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/plans') {
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan) }));
|
||||
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||||
const categories = normalizeCategories(body.categories, cleanText);
|
||||
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
|
||||
if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过该类别计划人数');
|
||||
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId)))) return sendError(response, 400, '指标分配中包含无效的生源学校 ID');
|
||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
|
||||
const now = nowIso();
|
||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now };
|
||||
Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } });
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/placements') {
|
||||
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
||||
const account = db.users.find(entry => entry.id === item.userId) || {};
|
||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||
const registration = db.registrations.find(entry => entry.examId === item.examId && entry.userId === item.userId);
|
||||
const results = db.results.filter(entry => entry.registrationId === registration?.id && entry.published).map(result => {
|
||||
const exam = db.exams.find(entry => entry.id === item.examId);
|
||||
return { subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score };
|
||||
});
|
||||
return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyTypes: profile.specialtyTypes || [], specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, results };
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, school, placements });
|
||||
}
|
||||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && placementMatch) {
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
||||
if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在');
|
||||
const body = await readJson(request);
|
||||
const decision = cleanText(body.decision, 30);
|
||||
const note = cleanText(body.note, 500);
|
||||
if (decision === 'accept') placement.status = 'admitted';
|
||||
else if (decision === 'withdraw') {
|
||||
if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由');
|
||||
placement.status = 'withdrawal_pending';
|
||||
placement.payload.withdrawalReason = note;
|
||||
} else return sendError(response, 400, '请选择接收或申请退档');
|
||||
placement.payload.schoolDecisionNote = note;
|
||||
placement.updatedAt = nowIso();
|
||||
await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`));
|
||||
return sendJson(response, 200, { ok: true, placement });
|
||||
}
|
||||
return sendError(response, 404, '招生学校功能接口不存在');
|
||||
}
|
||||
|
||||
return handleAdmission;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||
|
||||
export function createCandidateRoutes(context) {
|
||||
const {
|
||||
@@ -81,8 +82,9 @@ export function createCandidateRoutes(context) {
|
||||
}
|
||||
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
|
||||
const body = await readJson(request);
|
||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
|
||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone', 'specialtyCertificate', 'policyEligibility'];
|
||||
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
||||
profile.specialtyTypes = [...new Set(String(body.specialtyTypes || '').split(/[,,]/).map(item => cleanText(item, 40)).filter(Boolean))].slice(0, 10);
|
||||
const region = resolveRegion(body);
|
||||
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
|
||||
Object.assign(profile, region);
|
||||
@@ -151,6 +153,44 @@ export function createCandidateRoutes(context) {
|
||||
}, { ttlSeconds: resultsCacheTtlSeconds });
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
|
||||
const settings = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
|
||||
const exam = db.exams.find(item => item.id === setting.examId);
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const preference = activePreference(db, setting.examId, user.id, round);
|
||||
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
|
||||
const plans = approvedPlans(db, setting.examId).map(plan => ({
|
||||
id: plan.id, schoolId: plan.schoolId, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '',
|
||||
categories: remainingPlanQuota(db, plan)
|
||||
}));
|
||||
return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id) };
|
||||
}).filter(item => item.exam);
|
||||
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
|
||||
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
||||
}
|
||||
const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
|
||||
if (request.method === 'PUT' && preferenceMatch) {
|
||||
const setting = admissionSetting(db, preferenceMatch[1]);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
|
||||
if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
|
||||
const now = Date.now();
|
||||
if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
|
||||
if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
|
||||
if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
|
||||
const body = await readJson(request);
|
||||
const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
|
||||
const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40) }));
|
||||
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
|
||||
if (new Set(choices.map(item => `${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同一学校和招生类别不能重复填报');
|
||||
const plans = approvedPlans(db, setting.examId);
|
||||
if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode)))) return sendError(response, 400, '志愿中包含未审核通过的学校或招生类别');
|
||||
const round = Number(setting.payload.round || 1);
|
||||
const nowValue = nowIso();
|
||||
const preference = activePreference(db, setting.examId, user.id, round) || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
|
||||
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue } });
|
||||
await database.saveAdmissionRecord(preference);
|
||||
return sendJson(response, 200, { ok: true, preference, message: '志愿已由本人保存' });
|
||||
}
|
||||
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
|
||||
if (request.method === 'POST' && scoreAppealMatch) {
|
||||
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs';
|
||||
|
||||
export function createPublicRoutes(context) {
|
||||
const {
|
||||
@@ -57,7 +58,8 @@ export function createPublicRoutes(context) {
|
||||
const db = await readDb();
|
||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
|
||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||
const admissionAnnouncements = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', completedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }));
|
||||
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, admissionAnnouncements, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||
});
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'supplementary', 'completed']);
|
||||
|
||||
export function admissionRecords(db, kind, examId = null) {
|
||||
return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId));
|
||||
}
|
||||
|
||||
export function admissionSetting(db, examId) {
|
||||
return admissionRecords(db, 'setting', examId)[0] || null;
|
||||
}
|
||||
|
||||
export function candidateTotalScore(db, examId, userId) {
|
||||
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
|
||||
if (!registration) return null;
|
||||
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
|
||||
if (!registration.subjectIds.length || registration.subjectIds.some(id => !results.some(result => result.subjectId === id))) return null;
|
||||
return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
|
||||
}
|
||||
|
||||
export function activePreference(db, examId, userId, round) {
|
||||
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
|
||||
}
|
||||
|
||||
export function approvedPlans(db, examId) {
|
||||
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
|
||||
}
|
||||
|
||||
export function planSummary(plan) {
|
||||
const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : [];
|
||||
return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) };
|
||||
}
|
||||
|
||||
export function publicAdmissionRows(db, examId) {
|
||||
return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => {
|
||||
const user = db.users.find(entry => entry.id === item.userId) || {};
|
||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||
const school = db.schools.find(entry => entry.id === item.schoolId) || {};
|
||||
return {
|
||||
registrationNumber: user.candidateNumber || '',
|
||||
name: profile.name || user.displayName || '',
|
||||
totalScore: Number(item.payload?.totalScore || 0),
|
||||
admittedSchool: school.name || '',
|
||||
categoryName: item.payload?.categoryName || '',
|
||||
idNumberMasked: profile.idNumber ? `${profile.idNumber.slice(0, 3)}***********${profile.idNumber.slice(-2)}` : '',
|
||||
phoneMasked: profile.phone ? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}` : ''
|
||||
};
|
||||
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
|
||||
}
|
||||
|
||||
function categoryKey(schoolId, code) {
|
||||
return `${schoolId}|${code}`;
|
||||
}
|
||||
|
||||
export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
||||
const examId = setting.examId;
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const plans = approvedPlans(db, examId);
|
||||
const categories = new Map();
|
||||
for (const plan of plans) for (const category of plan.payload?.categories || []) {
|
||||
categories.set(categoryKey(plan.schoolId, category.code), { plan, category });
|
||||
}
|
||||
|
||||
const existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn');
|
||||
const occupied = new Map();
|
||||
const occupiedIndicators = new Map();
|
||||
const occupiedGeneral = new Map();
|
||||
for (const placement of existing) {
|
||||
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
|
||||
occupied.set(key, (occupied.get(key) || 0) + 1);
|
||||
if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
|
||||
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
|
||||
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
||||
} else occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
const preferences = admissionRecords(db, 'preference', examId).filter(item => Number(item.payload?.round || 1) === round && item.status === 'submitted');
|
||||
const candidates = preferences.map(preference => {
|
||||
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||
return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) };
|
||||
}).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)))
|
||||
.sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
|
||||
|
||||
const created = [];
|
||||
for (const candidate of candidates) {
|
||||
const specialtyTypes = Array.isArray(candidate.profile.specialtyTypes) ? candidate.profile.specialtyTypes : [];
|
||||
for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) {
|
||||
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
|
||||
if (!target) continue;
|
||||
const { category } = target;
|
||||
if (category.specialtyType && !specialtyTypes.includes(category.specialtyType)) continue;
|
||||
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
||||
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
|
||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
||||
let quotaBucket = null;
|
||||
if (allocation) {
|
||||
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
|
||||
if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) {
|
||||
quotaBucket = `indicator:${candidate.profile.schoolId}`;
|
||||
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
||||
}
|
||||
}
|
||||
if (!quotaBucket) {
|
||||
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
|
||||
if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue;
|
||||
quotaBucket = 'general';
|
||||
occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
|
||||
}
|
||||
occupied.set(key, (occupied.get(key) || 0) + 1);
|
||||
created.push({
|
||||
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
|
||||
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
|
||||
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
|
||||
totalScore: candidate.score, quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export function remainingPlanQuota(db, plan) {
|
||||
return (plan.payload?.categories || []).map(category => {
|
||||
const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn').length;
|
||||
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user