Add structured admission plans and school role management
This commit is contained in:
@@ -261,15 +261,17 @@ npm run seed-test-data:mysql -- --force
|
|||||||
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
|
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
|
||||||
|
|
||||||
1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。
|
1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。
|
||||||
2. 招生学校账号上传本校普通生、特长生与指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
|
2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
|
||||||
3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。
|
3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。
|
||||||
4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。
|
4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。
|
||||||
5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
||||||
6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。
|
6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。
|
||||||
|
|
||||||
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案另含特长生类型、特长证明编号和政策资格说明。
|
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
|
||||||
|
|
||||||
数据结构版本为 v18,新增 `admission_records` 关系表并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。
|
||||||
|
|
||||||
|
数据结构版本为 v19,`admission_records` 关系表支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
||||||
|
|
||||||
## 手动测试数据账号
|
## 手动测试数据账号
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { api } from './src/client/api.mjs';
|
|||||||
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
|
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
|
||||||
import { createCandidateViews } from './src/client/candidate-views.mjs';
|
import { createCandidateViews } from './src/client/candidate-views.mjs';
|
||||||
import { createAdmissionViews } from './src/client/admission-views.mjs';
|
import { createAdmissionViews } from './src/client/admission-views.mjs';
|
||||||
|
import { admissionCategoryEditor, indicatorAllocationEditor } from './src/client/admission-plan-editor.mjs';
|
||||||
import { createPublicViews } from './src/client/public-views.mjs';
|
import { createPublicViews } from './src/client/public-views.mjs';
|
||||||
import { state } from './src/client/state.mjs';
|
import { state } from './src/client/state.mjs';
|
||||||
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
|
import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs';
|
||||||
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
|
import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs';
|
||||||
|
import { specialtyCatalog } from './src/data/specialty-types.mjs';
|
||||||
|
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
const modalRoot = document.querySelector('#modalRoot');
|
const modalRoot = document.querySelector('#modalRoot');
|
||||||
@@ -184,6 +186,12 @@ document.addEventListener('click', async event => {
|
|||||||
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
|
setModal(`<div class="modal-head"><div><span>${h(notice.category)}</span><h2>${h(notice.title)}</h2><p>${formatDate(notice.publishAt,true)} · ${h(notice.author)}</p></div><button data-action="close-modal">×</button></div><article class="notice-content">${contentHtml}</article><div class="modal-foot"><button class="ghost-button" data-action="close-modal">关闭</button></div>`); return;
|
||||||
}
|
}
|
||||||
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; }
|
||||||
|
if (action === 'download-admitted-candidates') {
|
||||||
|
const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value;
|
||||||
|
if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载');
|
||||||
|
window.location.href = `/api/admission/placements/export?examId=${encodeURIComponent(examId)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === 'admission-plan-review') {
|
if (action === 'admission-plan-review') {
|
||||||
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
|
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
|
||||||
if (reviewNote == null) return;
|
if (reviewNote == null) return;
|
||||||
@@ -213,6 +221,26 @@ document.addEventListener('click', async event => {
|
|||||||
await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } });
|
await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } });
|
||||||
toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute();
|
toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute();
|
||||||
}
|
}
|
||||||
|
if (action === 'add-admission-category') {
|
||||||
|
const sources = state.pageData?.sourceSchools || [];
|
||||||
|
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove-admission-category') {
|
||||||
|
const list = target.closest('[data-admission-categories]');
|
||||||
|
if (list?.children.length <= 1) return toast('至少保留一个招生类别');
|
||||||
|
target.closest('.admission-category-editor')?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'add-indicator-allocation') {
|
||||||
|
const sources = state.pageData?.sourceSchools || [];
|
||||||
|
target.closest('.indicator-allocation-editor')?.querySelector('[data-indicator-allocations]')?.insertAdjacentHTML('beforeend', indicatorAllocationEditor(h, sources));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove-indicator-allocation') {
|
||||||
|
target.closest('.indicator-allocation-row')?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
|
if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value;
|
const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value;
|
||||||
@@ -458,6 +486,35 @@ document.addEventListener('change', event => {
|
|||||||
classSelect.innerHTML = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
|
classSelect.innerHTML = `<option value="">请选择班级</option>${classes.filter(item => item.schoolId === event.target.value).map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (event.target.matches('[data-action="specialty-category"]')) {
|
||||||
|
const typeSelect = event.target.closest('.admission-category-editor')?.querySelector('[data-specialty-type]') || event.target.closest('form')?.querySelector('[data-specialty-type]');
|
||||||
|
const category = specialtyCatalog.find(item => item.code === event.target.value);
|
||||||
|
if (typeSelect) {
|
||||||
|
typeSelect.disabled = !category;
|
||||||
|
typeSelect.innerHTML = `<option value="">${category ? '请选择小类' : '请先选择大类'}</option>${(category?.types || []).map(item => `<option value="${h(item.code)}">${h(item.name)}</option>`).join('')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-action="plan-category-kind"]')) {
|
||||||
|
const editor = event.target.closest('.admission-category-editor');
|
||||||
|
const specialtyFields = editor?.querySelector('[data-plan-specialty]');
|
||||||
|
const enabled = event.target.value === 'specialty';
|
||||||
|
specialtyFields?.classList.toggle('hidden', !enabled);
|
||||||
|
specialtyFields?.querySelectorAll('select').forEach(select => { select.disabled = !enabled || (select.hasAttribute('data-specialty-type') && !specialtyFields.querySelector('[name="categorySpecialtyCategory"]')?.value); });
|
||||||
|
}
|
||||||
|
if (event.target.matches('[name="categoryName"]')) {
|
||||||
|
const title = event.target.closest('.admission-category-editor')?.querySelector('header strong');
|
||||||
|
if (title) title.textContent = event.target.value.trim() || '新类别';
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-action="preference-school"]')) {
|
||||||
|
const row = event.target.closest('.preference-choice-row');
|
||||||
|
const categorySelect = row?.querySelector('[name="choiceCategory"]');
|
||||||
|
const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId);
|
||||||
|
const plan = admission?.plans?.find(item => item.schoolId === event.target.value);
|
||||||
|
if (categorySelect) {
|
||||||
|
categorySelect.disabled = !plan;
|
||||||
|
categorySelect.innerHTML = `<option value="">${plan ? '请选择招生类别' : '请先按代码选择学校'}</option>${(plan?.categories || []).filter(item => item.remaining > 0).map(item => `<option value="${h(item.code)}">${h(item.name)} · 余 ${h(item.remaining)}</option>`).join('')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target);
|
if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target);
|
||||||
if (event.target.matches('[data-action="admin-level"]')) {
|
if (event.target.matches('[data-action="admin-level"]')) {
|
||||||
const form = event.target.closest('form');
|
const form = event.target.closest('form');
|
||||||
@@ -560,9 +617,7 @@ document.addEventListener('submit', async event => {
|
|||||||
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
|
const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) });
|
||||||
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
|
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
|
||||||
} else if (kind === 'volunteer-preference') {
|
} else if (kind === 'volunteer-preference') {
|
||||||
const choices = new FormData(form).getAll('choices').filter(Boolean).map(value => {
|
const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value })).filter(item => item.schoolId && item.categoryCode);
|
||||||
const [schoolId, categoryCode] = String(value).split('|'); return { schoolId, categoryCode };
|
|
||||||
});
|
|
||||||
await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
|
await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
|
||||||
toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute();
|
toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute();
|
||||||
} else if (kind === 'exam-registration') {
|
} else if (kind === 'exam-registration') {
|
||||||
@@ -595,12 +650,13 @@ document.addEventListener('submit', async event => {
|
|||||||
form.reset(); toast('招生学校账号已创建'); renderRoute();
|
form.reset(); toast('招生学校账号已创建'); renderRoute();
|
||||||
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
|
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
|
||||||
const body = formObject(form);
|
const body = formObject(form);
|
||||||
body.categories = String(body.categoriesText || '').split(/\r?\n/).map((line, index) => {
|
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
|
||||||
const [name, quota, specialtyType = '', indicators = ''] = line.split('|').map(item => item.trim());
|
const specialty = editor.querySelector('[name="categoryKind"]').value === 'specialty';
|
||||||
const indicatorAllocations = indicators.split(/[,,]/).map(entry => { const [sourceSchoolId, count] = entry.split(':').map(item => item.trim()); return { sourceSchoolId, quota: Number(count) }; }).filter(item => item.sourceSchoolId && item.quota > 0);
|
const indicatorAllocations = [...editor.querySelectorAll('.indicator-allocation-row')].map(row => ({ sourceSchoolId: row.querySelector('[name="indicatorSchool"]').value, quota: Number(row.querySelector('[name="indicatorQuota"]').value || 0) })).filter(item => item.sourceSchoolId && item.quota > 0);
|
||||||
return { code: `category_${index + 1}`, name, quota: Number(quota), specialtyType, indicatorAllocations };
|
return { code: `category_${index + 1}`, name: editor.querySelector('[name="categoryName"]').value.trim(), quota: Number(editor.querySelector('[name="categoryQuota"]').value || 0), isSpecialty: specialty, specialtyCategory: specialty ? editor.querySelector('[name="categorySpecialtyCategory"]').value : '', specialtyType: specialty ? editor.querySelector('[name="categorySpecialtyType"]').value : '', indicatorAllocations };
|
||||||
}).filter(item => item.name && item.quota > 0);
|
}).filter(item => item.name && item.quota > 0);
|
||||||
if (!body.categories.length) throw new Error('请按示例填写至少一行有效招生计划');
|
if (!body.categories.length) throw new Error('请至少添加一个有效招生类别');
|
||||||
|
if (body.categories.some(item => item.isSpecialty && (!item.specialtyCategory || !item.specialtyType))) throw new Error('特长生类别必须同时选择对应的大类和小类');
|
||||||
await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body });
|
await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body });
|
||||||
toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute();
|
toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute();
|
||||||
} else if (kind === 'placement-review') {
|
} else if (kind === 'placement-review') {
|
||||||
@@ -608,7 +664,7 @@ document.addEventListener('submit', async event => {
|
|||||||
await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body });
|
await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body });
|
||||||
toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute();
|
toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute();
|
||||||
} else if (kind === 'school-form') {
|
} else if (kind === 'school-form') {
|
||||||
const body = formObject(form); body.active = form.active.checked;
|
const body = formObject(form); body.active = form.active.checked; body.isSourceSchool = form.isSourceSchool.checked; body.isAdmissionSchool = form.isAdmissionSchool.checked;
|
||||||
await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
|
await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body });
|
||||||
closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute();
|
closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute();
|
||||||
} else if (kind === 'school-class') {
|
} else if (kind === 'school-class') {
|
||||||
@@ -713,6 +769,10 @@ document.addEventListener('submit', async event => {
|
|||||||
const body = formObject(form); body.published = form.published.checked;
|
const body = formObject(form); body.published = form.published.checked;
|
||||||
await api('/api/admin/results', { method: 'POST', body });
|
await api('/api/admin/results', { method: 'POST', body });
|
||||||
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
|
toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute();
|
||||||
|
} else if (kind === 'feature-score-entry') {
|
||||||
|
const body = formObject(form); body.featureScore = Number(body.featureScore || 0);
|
||||||
|
await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body });
|
||||||
|
toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute();
|
||||||
} else if (kind === 'result-import-commit') {
|
} else if (kind === 'result-import-commit') {
|
||||||
const rows = state.resultImportPreview?.rows || [];
|
const rows = state.resultImportPreview?.rows || [];
|
||||||
if (!rows.length) throw new Error('没有可提交的成绩预览');
|
if (!rows.length) throw new Error('没有可提交的成绩预览');
|
||||||
@@ -730,7 +790,7 @@ function openAdminForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSchoolForm(school = null) {
|
function openSchoolForm(school = null) {
|
||||||
setModal(`<div class="modal-head"><div><span>SCHOOL DIRECTORY</span><h2>${school ? '编辑学校档案' : '创建学校'}</h2><p>学校代码用于管理员范围、报名号规则和数据识别,建议使用稳定且唯一的简称。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-form">${school ? `<input type="hidden" name="id" value="${h(school.id)}">` : ''}<div class="field-row"><label><span>学校名称 *</span><input name="name" required maxlength="100" value="${h(school?.name || '')}" placeholder="例如 海州市第四中学"></label><label><span>学校代码 *</span><input name="code" required maxlength="40" pattern="[A-Za-z0-9_-]+" value="${h(school?.code || '')}" placeholder="例如 HZ04"></label></div><label><span>学校地址</span><input name="address" maxlength="200" value="${h(school?.address || '')}" placeholder="省、市、区县及详细地址"></label><label class="agreement"><input type="checkbox" name="active" ${school?.active === false ? '' : 'checked'}><span>启用该学校,可在公开入口选择并继续绑定管理员、班级和考点</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${school ? '保存学校' : '创建学校'}</button></div></form>`);
|
setModal(`<div class="modal-head"><div><span>SCHOOL DIRECTORY</span><h2>${school ? '编辑学校档案' : '创建学校'}</h2><p>学校代码统一标识学校;同一学校可以同时承担生源校和招生校职责。</p></div><button data-action="close-modal">×</button></div><form class="modal-form" data-form="school-form">${school ? `<input type="hidden" name="id" value="${h(school.id)}">` : ''}<div class="field-row"><label><span>学校名称 *</span><input name="name" required maxlength="100" value="${h(school?.name || '')}" placeholder="例如 海州市第四中学"></label><label><span>学校代码 *</span><input name="code" required maxlength="40" pattern="[A-Za-z0-9_-]+" value="${h(school?.code || '')}" placeholder="例如 HZ04"></label></div><label><span>学校地址</span><input name="address" maxlength="200" value="${h(school?.address || '')}" placeholder="省、市、区县及详细地址"></label><section class="school-role-selector"><strong>学校类型(至少选择一项)</strong><div><label><input type="checkbox" name="isSourceSchool" ${school?.isSourceSchool === false ? '' : 'checked'}><span><b>生源校</b><small>可建立班级、管理考生,并接收指标名额</small></span></label><label><input type="checkbox" name="isAdmissionSchool" ${school?.isAdmissionSchool === false ? '' : 'checked'}><span><b>招生校</b><small>可配置招生账号、上传计划并审核投档</small></span></label></div></section><label class="agreement"><input type="checkbox" name="active" ${school?.active === false ? '' : 'checked'}><span>启用该学校;公开报名入口只显示已启用的生源校</span></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="close-modal">取消</button><button type="submit" class="solid-button">${school ? '保存学校' : '创建学校'}</button></div></form>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSchoolClassForm(schoolClass = null) {
|
function openSchoolClassForm(schoolClass = null) {
|
||||||
|
|||||||
+35
-17
@@ -60,7 +60,7 @@ export function buildSeedOperations(state) {
|
|||||||
const nullable = value => value == null || value === '' ? null : value;
|
const nullable = value => value == null || value === '' ? null : value;
|
||||||
|
|
||||||
add(
|
add(
|
||||||
'UPDATE schema_metadata SET schema_version = 18, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
'UPDATE schema_metadata SET schema_version = 19, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||||
state.meta?.createdAt || new Date().toISOString()
|
state.meta?.createdAt || new Date().toISOString()
|
||||||
);
|
);
|
||||||
@@ -71,8 +71,9 @@ export function buildSeedOperations(state) {
|
|||||||
|
|
||||||
for (const school of state.schools) {
|
for (const school of state.schools) {
|
||||||
add(
|
add(
|
||||||
'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
school.id, school.name, school.code, nullable(school.address), school.active === false ? 0 : 1
|
school.id, school.name, school.code, nullable(school.address), school.isSourceSchool === false ? 0 : 1,
|
||||||
|
school.isAdmissionSchool === false ? 0 : 1, school.active === false ? 0 : 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +102,9 @@ export function buildSeedOperations(state) {
|
|||||||
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
||||||
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
||||||
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
||||||
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
guardian_phone, specialty_category, specialty_type, specialty_types, specialty_certificate, policy_eligibility,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone,
|
profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone,
|
||||||
nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
|
nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
|
||||||
nullable(profile.classId), nullable(profile.provinceCode), nullable(profile.provinceName),
|
nullable(profile.classId), nullable(profile.provinceCode), nullable(profile.provinceName),
|
||||||
@@ -110,6 +112,8 @@ export function buildSeedOperations(state) {
|
|||||||
nullable(profile.address),
|
nullable(profile.address),
|
||||||
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate),
|
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate),
|
||||||
nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone),
|
nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone),
|
||||||
|
nullable(profile.specialtyCategory), nullable(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []),
|
||||||
|
nullable(profile.specialtyCertificate), nullable(profile.policyEligibility),
|
||||||
profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote),
|
profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote),
|
||||||
nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt
|
nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt
|
||||||
);
|
);
|
||||||
@@ -151,12 +155,12 @@ export function buildSeedOperations(state) {
|
|||||||
add(
|
add(
|
||||||
`INSERT INTO registrations (
|
`INSERT INTO registrations (
|
||||||
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
|
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
|
||||||
registration_number, number_rule_id
|
registration_number, number_rule_id, feature_score
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
registration.id, registration.userId, registration.examId, registration.status,
|
registration.id, registration.userId, registration.examId, registration.status,
|
||||||
registration.paymentStatus, nullable(registration.paidAt), nullable(registration.paidBy), registration.createdAt,
|
registration.paymentStatus, nullable(registration.paidAt), nullable(registration.paidBy), registration.createdAt,
|
||||||
nullable(registration.reviewedAt), nullable(registration.reviewNote),
|
nullable(registration.reviewedAt), nullable(registration.reviewNote),
|
||||||
nullable(registration.registrationNumber), nullable(registration.numberRuleId)
|
nullable(registration.registrationNumber), nullable(registration.numberRuleId), Number(registration.featureScore || 0)
|
||||||
);
|
);
|
||||||
for (const subjectId of registration.subjectIds) {
|
for (const subjectId of registration.subjectIds) {
|
||||||
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
|
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
|
||||||
@@ -466,6 +470,8 @@ function stateFromRows(rows) {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
address: row.address || '',
|
address: row.address || '',
|
||||||
|
isSourceSchool: row.is_source_school == null ? true : Boolean(row.is_source_school),
|
||||||
|
isAdmissionSchool: row.is_admission_school == null ? true : Boolean(row.is_admission_school),
|
||||||
active: Boolean(row.active)
|
active: Boolean(row.active)
|
||||||
})),
|
})),
|
||||||
classes: rows.classes.map(row => ({
|
classes: rows.classes.map(row => ({
|
||||||
@@ -522,6 +528,8 @@ function stateFromRows(rows) {
|
|||||||
postalCode: row.postal_code || '',
|
postalCode: row.postal_code || '',
|
||||||
guardianName: row.guardian_name || '',
|
guardianName: row.guardian_name || '',
|
||||||
guardianPhone: row.guardian_phone || '',
|
guardianPhone: row.guardian_phone || '',
|
||||||
|
specialtyCategory: row.specialty_category || '',
|
||||||
|
specialtyType: row.specialty_type || '',
|
||||||
specialtyTypes: parseJson(row.specialty_types, []),
|
specialtyTypes: parseJson(row.specialty_types, []),
|
||||||
specialtyCertificate: row.specialty_certificate || '',
|
specialtyCertificate: row.specialty_certificate || '',
|
||||||
policyEligibility: row.policy_eligibility || '',
|
policyEligibility: row.policy_eligibility || '',
|
||||||
@@ -578,6 +586,7 @@ function stateFromRows(rows) {
|
|||||||
reviewNote: row.review_note || '',
|
reviewNote: row.review_note || '',
|
||||||
registrationNumber: row.registration_number || '',
|
registrationNumber: row.registration_number || '',
|
||||||
numberRuleId: row.number_rule_id || null,
|
numberRuleId: row.number_rule_id || null,
|
||||||
|
featureScore: Number(row.feature_score || 0),
|
||||||
admitCard: admitCards.get(row.id) || null
|
admitCard: admitCards.get(row.id) || null
|
||||||
})),
|
})),
|
||||||
results: rows.results.map(row => ({
|
results: rows.results.map(row => ({
|
||||||
@@ -956,7 +965,7 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
|
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
|
||||||
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
|
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
|
||||||
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
|
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
|
||||||
specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?,
|
specialty_category = ?, specialty_type = ?, specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?,
|
||||||
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
|
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
|
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
|
||||||
@@ -965,7 +974,8 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
optional(profile.address), optional(profile.schoolId),
|
optional(profile.address), optional(profile.schoolId),
|
||||||
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
|
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
|
||||||
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
|
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
|
||||||
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), JSON.stringify(profile.specialtyTypes || []),
|
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), optional(profile.specialtyCategory),
|
||||||
|
optional(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []),
|
||||||
optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
|
optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
|
||||||
optional(profile.reviewerId), profile.updatedAt, profile.id
|
optional(profile.reviewerId), profile.updatedAt, profile.id
|
||||||
),
|
),
|
||||||
@@ -1011,12 +1021,12 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
const operations = [operation(
|
const operations = [operation(
|
||||||
`INSERT INTO registrations (
|
`INSERT INTO registrations (
|
||||||
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
|
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
|
||||||
registration_number, number_rule_id
|
registration_number, number_rule_id, feature_score
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
registration.id, registration.userId, registration.examId, registration.status,
|
registration.id, registration.userId, registration.examId, registration.status,
|
||||||
registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.createdAt,
|
registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.createdAt,
|
||||||
optional(registration.reviewedAt), optional(registration.reviewNote),
|
optional(registration.reviewedAt), optional(registration.reviewNote),
|
||||||
optional(registration.registrationNumber), optional(registration.numberRuleId)
|
optional(registration.registrationNumber), optional(registration.numberRuleId), Number(registration.featureScore || 0)
|
||||||
)];
|
)];
|
||||||
for (const subjectId of registration.subjectIds) {
|
for (const subjectId of registration.subjectIds) {
|
||||||
operations.push(operation(
|
operations.push(operation(
|
||||||
@@ -1230,6 +1240,12 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
auditOperation(log)
|
auditOperation(log)
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
async updateFeatureScore(registration, log) {
|
||||||
|
await transaction([
|
||||||
|
operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id),
|
||||||
|
auditOperation(log)
|
||||||
|
]);
|
||||||
|
},
|
||||||
async saveAdmissionRecord(record, log = null) {
|
async saveAdmissionRecord(record, log = null) {
|
||||||
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
|
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
|
||||||
`INSERT INTO admission_records (
|
`INSERT INTO admission_records (
|
||||||
@@ -1259,12 +1275,14 @@ function createRepository({ client, location, read, transaction, close }) {
|
|||||||
async saveSchool(school, isNew, log) {
|
async saveSchool(school, isNew, log) {
|
||||||
const change = isNew
|
const change = isNew
|
||||||
? operation(
|
? operation(
|
||||||
'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
school.id, school.name, school.code, optional(school.address), school.active ? 1 : 0
|
school.id, school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0,
|
||||||
|
school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0
|
||||||
)
|
)
|
||||||
: operation(
|
: operation(
|
||||||
'UPDATE schools SET name = ?, code = ?, address = ?, active = ? WHERE id = ?',
|
'UPDATE schools SET name = ?, code = ?, address = ?, is_source_school = ?, is_admission_school = ?, active = ? WHERE id = ?',
|
||||||
school.name, school.code, optional(school.address), school.active ? 1 : 0, school.id
|
school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0,
|
||||||
|
school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0, school.id
|
||||||
);
|
);
|
||||||
await transaction([change, auditOperation(log)]);
|
await transaction([change, auditOperation(log)]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,6 +108,21 @@ const resourceSpecs = {
|
|||||||
['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''],
|
['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''],
|
||||||
['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, '']
|
['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, '']
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
admitted_candidates: {
|
||||||
|
title: '录取考生信息表', sheet: '录取考生',
|
||||||
|
columns: [
|
||||||
|
['candidateNumber', '报名号', 26, ''], ['name', '姓名', 14, ''], ['gender', '性别', 10, ''],
|
||||||
|
['idNumber', '证件号码', 24, ''], ['phone', '手机号', 18, ''], ['email', '邮箱', 24, ''],
|
||||||
|
['birthDate', '出生日期', 14, ''], ['ethnicity', '民族', 12, ''], ['nativePlace', '籍贯', 18, ''],
|
||||||
|
['sourceSchoolCode', '生源学校代码', 16, ''], ['sourceSchool', '生源学校', 26, ''], ['className', '班级', 18, ''],
|
||||||
|
['address', '家庭住址', 36, ''], ['guardianName', '监护人', 14, ''], ['guardianPhone', '监护人电话', 18, ''],
|
||||||
|
['specialty', '特长生资格', 20, ''], ['specialtyCertificate', '特长证明编号', 20, ''], ['policyEligibility', '政策资格说明', 24, ''],
|
||||||
|
['featureScore', '特征分', 12, ''], ['subjectScores', '各科成绩', 42, ''], ['totalScore', '考生总成绩', 14, ''],
|
||||||
|
['admittedSchool', '录取学校', 26, ''], ['categoryName', '录取类别', 20, ''], ['preferenceOrder', '志愿序号', 12, '']
|
||||||
|
],
|
||||||
|
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
||||||
|
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -47,11 +47,13 @@ const staticFiles = new Set([
|
|||||||
'/src/client/admin-views.mjs',
|
'/src/client/admin-views.mjs',
|
||||||
'/src/client/candidate-views.mjs',
|
'/src/client/candidate-views.mjs',
|
||||||
'/src/client/admission-views.mjs',
|
'/src/client/admission-views.mjs',
|
||||||
|
'/src/client/admission-plan-editor.mjs',
|
||||||
'/src/client/public-views.mjs',
|
'/src/client/public-views.mjs',
|
||||||
'/src/client/state.mjs',
|
'/src/client/state.mjs',
|
||||||
'/src/client/ui.mjs',
|
'/src/client/ui.mjs',
|
||||||
'/src/client/region-select.mjs',
|
'/src/client/region-select.mjs',
|
||||||
'/src/data/china-regions.mjs'
|
'/src/data/china-regions.mjs',
|
||||||
|
'/src/data/specialty-types.mjs'
|
||||||
]);
|
]);
|
||||||
const vendorStaticFiles = new Map([
|
const vendorStaticFiles = new Map([
|
||||||
['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')],
|
['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')],
|
||||||
@@ -98,7 +100,7 @@ const initializeDatabase = () => createBaseDatabase({
|
|||||||
});
|
});
|
||||||
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
||||||
const cache = await createRedisCache();
|
const cache = await createRedisCache();
|
||||||
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateExam', 'archiveExam']);
|
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateExam', 'archiveExam']);
|
||||||
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
|
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
|
||||||
const namespaces = ['public'];
|
const namespaces = ['public'];
|
||||||
const instance = args[0];
|
const instance = args[0];
|
||||||
@@ -423,6 +425,7 @@ function examResultSummary(db, registration) {
|
|||||||
complete,
|
complete,
|
||||||
publishedSubjects: published.length,
|
publishedSubjects: published.length,
|
||||||
subjectCount: subjects.length,
|
subjectCount: subjects.length,
|
||||||
|
featureScore: Number(registration.featureScore || 0),
|
||||||
total,
|
total,
|
||||||
fullScore,
|
fullScore,
|
||||||
scoreRatio: Number(scoreRatio.toFixed(2)),
|
scoreRatio: Number(scoreRatio.toFixed(2)),
|
||||||
@@ -459,7 +462,7 @@ function logAction(db, user, action, detail) {
|
|||||||
const excelResourceNames = {
|
const excelResourceNames = {
|
||||||
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
|
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
|
||||||
account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账',
|
account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账',
|
||||||
admit_cards: '准考证信息台账'
|
admit_cards: '准考证信息台账', admitted_candidates: '录取考生信息'
|
||||||
};
|
};
|
||||||
|
|
||||||
function admissionRowsForRegistrations(db, registrations) {
|
function admissionRowsForRegistrations(db, registrations) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
|||||||
|
import { specialtyCatalog } from '../data/specialty-types.mjs';
|
||||||
|
|
||||||
|
export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) {
|
||||||
|
return `<div class="indicator-allocation-row"><label><span>生源学校</span><select name="indicatorSchool"><option value="">请选择生源校</option>${sourceSchools.map(school => `<option value="${h(school.id)}" ${school.id === allocation.sourceSchoolId ? 'selected' : ''}>${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><label><span>分配名额</span><input name="indicatorQuota" type="number" min="1" step="1" value="${h(allocation.quota || '')}" placeholder="人数"></label><button type="button" class="row-action" data-action="remove-indicator-allocation">移除</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function admissionCategoryEditor(h, sourceSchools = [], category = {}) {
|
||||||
|
const specialty = Boolean(category.specialtyCategory);
|
||||||
|
const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory);
|
||||||
|
return `<article class="admission-category-editor"><header><div><span>招生类别</span><strong>${h(category.name || '新类别')}</strong></div><button type="button" data-action="remove-admission-category">移除类别</button></header><div class="admission-category-fields"><label><span>类别名称 *</span><input name="categoryName" required maxlength="80" value="${h(category.name || '')}" placeholder="例如:普通生、艺术特长生"></label><label><span>计划人数 *</span><input name="categoryQuota" required type="number" min="1" step="1" value="${h(category.quota || '')}" placeholder="人数"></label><label><span>类别性质</span><select name="categoryKind" data-action="plan-category-kind"><option value="general" ${specialty ? '' : 'selected'}>普通 / 政策类</option><option value="specialty" ${specialty ? 'selected' : ''}>特长生</option></select></label><div class="specialty-plan-fields ${specialty ? '' : 'hidden'}" data-plan-specialty><label><span>特长大类 *</span><select name="categorySpecialtyCategory" data-action="specialty-category" ${specialty ? '' : 'disabled'}><option value="">请选择大类</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyCategory ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长小类 *</span><select name="categorySpecialtyType" data-specialty-type ${specialty && selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyType ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div></div><section class="indicator-allocation-editor"><div class="indicator-allocation-head"><div><strong>指标分配</strong><small>可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。</small></div><button type="button" class="row-action" data-action="add-indicator-allocation">添加生源校指标</button></div><div data-indicator-allocations>${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}</div></section></article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) {
|
||||||
|
const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }];
|
||||||
|
return `<section class="admission-categories-builder"><div class="admission-builder-head"><div><strong>招生类别与计划</strong><small>逐项设置类别、资格范围和生源校指标。</small></div><button type="button" class="row-action primary" data-action="add-admission-category">添加招生类别</button></div><div data-admission-categories>${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}</div></section>`;
|
||||||
|
}
|
||||||
@@ -24,11 +24,14 @@ export function createAdmissionViews(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function plans(data) {
|
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>`;
|
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="table-scroll"><table><thead><tr><th>考试</th><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 => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function placements(data) {
|
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>`;
|
const exportBar = data.completedExams?.length ? `<section class="panel admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息 Excel</strong><small>仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。</small></div><label><span>已完成考试</span><select name="exportExamId">${data.completedExams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><button class="solid-button" data-action="download-admitted-candidates">下载 Excel</button></section>` : '';
|
||||||
|
return `${exportBar}<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.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>总分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写特殊理由"><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 };
|
return { renderAdmission };
|
||||||
}
|
}
|
||||||
|
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
|
||||||
|
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mountRegionSelects } from './region-select.mjs';
|
import { mountRegionSelects } from './region-select.mjs';
|
||||||
|
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
|
||||||
export function createCandidateViews(context) {
|
export function createCandidateViews(context) {
|
||||||
const {
|
const {
|
||||||
@@ -58,7 +59,9 @@ export function createCandidateViews(context) {
|
|||||||
function mountAdmissionProfileFields(profile = {}) {
|
function mountAdmissionProfileFields(profile = {}) {
|
||||||
const actions = app.querySelector('.profile-form .form-actions');
|
const actions = app.querySelector('.profile-form .form-actions');
|
||||||
if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
|
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>`);
|
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) {
|
function onboardingShell(stage, content) {
|
||||||
@@ -185,7 +188,7 @@ export function createCandidateViews(context) {
|
|||||||
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
|
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>`;
|
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('');
|
}).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(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。'}</p><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>特征分</small><strong>${h(summary?.featureScore ?? 0)}</strong><i>独立于考试科目</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}</p><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;
|
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>`;
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
@@ -196,11 +199,18 @@ export function createCandidateViews(context) {
|
|||||||
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 => {
|
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 choices = item.preference?.payload?.choices || [];
|
||||||
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null;
|
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 placementSchool = item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '';
|
||||||
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
|
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
|
||||||
const progressIndex = item.status === 'supplementary' ? 1 : Math.max(0, progressSteps.indexOf(item.status));
|
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>`;
|
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>`;
|
}).join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
|||||||
const adminId = 'usr_admin';
|
const adminId = 'usr_admin';
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
return {
|
return {
|
||||||
meta: { version: 18, createdAt },
|
meta: { version: 19, createdAt },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||||
schools: [], classes: [],
|
schools: [], classes: [],
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export const specialtyCatalog = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
code: 'sports',
|
||||||
|
name: '体育',
|
||||||
|
types: Object.freeze([
|
||||||
|
Object.freeze({ code: 'track_field', name: '田径' }),
|
||||||
|
Object.freeze({ code: 'basketball', name: '篮球' }),
|
||||||
|
Object.freeze({ code: 'football', name: '足球' }),
|
||||||
|
Object.freeze({ code: 'volleyball', name: '排球' }),
|
||||||
|
Object.freeze({ code: 'table_tennis', name: '乒乓球' }),
|
||||||
|
Object.freeze({ code: 'badminton', name: '羽毛球' }),
|
||||||
|
Object.freeze({ code: 'swimming', name: '游泳' }),
|
||||||
|
Object.freeze({ code: 'martial_arts', name: '武术' }),
|
||||||
|
Object.freeze({ code: 'aerobics_cheer', name: '健美操与啦啦操' })
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
code: 'arts',
|
||||||
|
name: '艺术',
|
||||||
|
types: Object.freeze([
|
||||||
|
Object.freeze({ code: 'vocal_music', name: '声乐' }),
|
||||||
|
Object.freeze({ code: 'instrumental_music', name: '器乐' }),
|
||||||
|
Object.freeze({ code: 'dance', name: '舞蹈' }),
|
||||||
|
Object.freeze({ code: 'fine_arts', name: '美术' }),
|
||||||
|
Object.freeze({ code: 'calligraphy', name: '书法' }),
|
||||||
|
Object.freeze({ code: 'drama_broadcasting', name: '戏剧与播音' })
|
||||||
|
])
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const categoryMap = new Map(specialtyCatalog.map(category => [category.code, category]));
|
||||||
|
const typeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.code, { ...type, categoryCode: category.code, categoryName: category.name }])));
|
||||||
|
const legacyTypeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.name, { category: category.code, type: type.code }])));
|
||||||
|
|
||||||
|
export function specialtyCategory(code) {
|
||||||
|
return categoryMap.get(String(code || '')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function specialtyType(code) {
|
||||||
|
return typeMap.get(String(code || '')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSpecialty(categoryCode, typeCode) {
|
||||||
|
if (!categoryCode && !typeCode) return true;
|
||||||
|
const category = specialtyCategory(categoryCode);
|
||||||
|
const type = specialtyType(typeCode);
|
||||||
|
return Boolean(category && type && type.categoryCode === category.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileSpecialty(profile = {}) {
|
||||||
|
if (isValidSpecialty(profile.specialtyCategory, profile.specialtyType) && profile.specialtyCategory) {
|
||||||
|
return { category: profile.specialtyCategory, type: profile.specialtyType };
|
||||||
|
}
|
||||||
|
const legacy = (Array.isArray(profile.specialtyTypes) ? profile.specialtyTypes : []).map(value => legacyTypeMap.get(String(value))).find(Boolean);
|
||||||
|
return legacy || { category: '', type: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function specialtyLabel(categoryCode, typeCode) {
|
||||||
|
const category = specialtyCategory(categoryCode);
|
||||||
|
const type = specialtyType(typeCode);
|
||||||
|
if (!category) {
|
||||||
|
const legacy = legacyTypeMap.get(String(typeCode || ''));
|
||||||
|
return legacy ? specialtyLabel(legacy.category, legacy.type) : '';
|
||||||
|
}
|
||||||
|
return type?.categoryCode === category.code ? `${category.name}·${type.name}` : category.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function candidateEligibleForCategory(profile, category) {
|
||||||
|
const legacy = !category?.specialtyCategory ? legacyTypeMap.get(String(category?.specialtyType || '')) : null;
|
||||||
|
const requiredCategory = category?.specialtyCategory || legacy?.category || '';
|
||||||
|
const requiredType = legacy?.type || category?.specialtyType || '';
|
||||||
|
if (!requiredCategory) return true;
|
||||||
|
const qualification = resolveProfileSpecialty(profile);
|
||||||
|
if (qualification.category !== requiredCategory) return false;
|
||||||
|
return !requiredType || qualification.type === requiredType;
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export function createMysqlAdapter(context) {
|
|||||||
hasSchemaMetadata = metadataRows.length > 0;
|
hasSchemaMetadata = metadataRows.length > 0;
|
||||||
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
|
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
|
||||||
}
|
}
|
||||||
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17].includes(existingSchemaVersion))) {
|
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19].includes(existingSchemaVersion))) {
|
||||||
for (const table of [...mysqlTableNames].reverse()) {
|
for (const table of [...mysqlTableNames].reverse()) {
|
||||||
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
|
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
|
||||||
}
|
}
|
||||||
@@ -177,6 +177,20 @@ export function createMysqlAdapter(context) {
|
|||||||
await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1');
|
await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1');
|
||||||
metadataRows[0].schema_version = 18;
|
metadataRows[0].schema_version = 18;
|
||||||
}
|
}
|
||||||
|
if (Number(metadataRows[0]?.schema_version || 1) < 19) {
|
||||||
|
const [schoolColumns] = await pool.query("SHOW COLUMNS FROM schools WHERE Field IN ('is_source_school', 'is_admission_school')");
|
||||||
|
const existingSchoolColumns = new Set(schoolColumns.map(item => item.Field));
|
||||||
|
if (!existingSchoolColumns.has('is_source_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_source_school BOOLEAN NOT NULL DEFAULT TRUE AFTER address');
|
||||||
|
if (!existingSchoolColumns.has('is_admission_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_admission_school BOOLEAN NOT NULL DEFAULT TRUE AFTER is_source_school');
|
||||||
|
const [specialtyColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_category', 'specialty_type')");
|
||||||
|
const existingSpecialtyColumns = new Set(specialtyColumns.map(item => item.Field));
|
||||||
|
if (!existingSpecialtyColumns.has('specialty_category')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_category VARCHAR(30) NULL AFTER guardian_phone');
|
||||||
|
if (!existingSpecialtyColumns.has('specialty_type')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_type VARCHAR(40) NULL AFTER specialty_category');
|
||||||
|
const [registrationColumns] = await pool.query("SHOW COLUMNS FROM registrations WHERE Field = 'feature_score'");
|
||||||
|
if (!registrationColumns.length) await pool.query('ALTER TABLE registrations ADD COLUMN feature_score DECIMAL(8,2) NOT NULL DEFAULT 0 AFTER number_rule_id');
|
||||||
|
await pool.execute('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1');
|
||||||
|
metadataRows[0].schema_version = 19;
|
||||||
|
}
|
||||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||||
const extension = seed();
|
const extension = seed();
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const sqliteSchema = `
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
code TEXT NOT NULL UNIQUE,
|
code TEXT NOT NULL UNIQUE,
|
||||||
address TEXT,
|
address TEXT,
|
||||||
|
is_source_school INTEGER NOT NULL DEFAULT 1 CHECK (is_source_school IN (0, 1)),
|
||||||
|
is_admission_school INTEGER NOT NULL DEFAULT 1 CHECK (is_admission_school IN (0, 1)),
|
||||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
@@ -90,6 +92,8 @@ export const sqliteSchema = `
|
|||||||
postal_code TEXT,
|
postal_code TEXT,
|
||||||
guardian_name TEXT,
|
guardian_name TEXT,
|
||||||
guardian_phone TEXT,
|
guardian_phone TEXT,
|
||||||
|
specialty_category TEXT,
|
||||||
|
specialty_type TEXT,
|
||||||
specialty_types TEXT NOT NULL DEFAULT '[]',
|
specialty_types TEXT NOT NULL DEFAULT '[]',
|
||||||
specialty_certificate TEXT,
|
specialty_certificate TEXT,
|
||||||
policy_eligibility TEXT,
|
policy_eligibility TEXT,
|
||||||
@@ -174,6 +178,7 @@ export const sqliteSchema = `
|
|||||||
review_note TEXT,
|
review_note TEXT,
|
||||||
registration_number TEXT,
|
registration_number TEXT,
|
||||||
number_rule_id TEXT,
|
number_rule_id TEXT,
|
||||||
|
feature_score REAL NOT NULL DEFAULT 0 CHECK (feature_score >= 0),
|
||||||
UNIQUE (user_id, exam_id)
|
UNIQUE (user_id, exam_id)
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
@@ -521,6 +526,8 @@ export const mysqlSchema = [
|
|||||||
name VARCHAR(160) NOT NULL,
|
name VARCHAR(160) NOT NULL,
|
||||||
code VARCHAR(40) NOT NULL,
|
code VARCHAR(40) NOT NULL,
|
||||||
address VARCHAR(255) NULL,
|
address VARCHAR(255) NULL,
|
||||||
|
is_source_school BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_admission_school BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_schools_name (name),
|
UNIQUE KEY uq_schools_name (name),
|
||||||
@@ -602,6 +609,8 @@ export const mysqlSchema = [
|
|||||||
postal_code VARCHAR(20) NULL,
|
postal_code VARCHAR(20) NULL,
|
||||||
guardian_name VARCHAR(100) NULL,
|
guardian_name VARCHAR(100) NULL,
|
||||||
guardian_phone VARCHAR(60) NULL,
|
guardian_phone VARCHAR(60) NULL,
|
||||||
|
specialty_category VARCHAR(30) NULL,
|
||||||
|
specialty_type VARCHAR(40) NULL,
|
||||||
specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()),
|
specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()),
|
||||||
specialty_certificate VARCHAR(255) NULL,
|
specialty_certificate VARCHAR(255) NULL,
|
||||||
policy_eligibility VARCHAR(255) NULL,
|
policy_eligibility VARCHAR(255) NULL,
|
||||||
@@ -706,6 +715,7 @@ export const mysqlSchema = [
|
|||||||
review_note VARCHAR(500) NULL,
|
review_note VARCHAR(500) NULL,
|
||||||
registration_number VARCHAR(120) NULL,
|
registration_number VARCHAR(120) NULL,
|
||||||
number_rule_id VARCHAR(64) NULL,
|
number_rule_id VARCHAR(64) NULL,
|
||||||
|
feature_score DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_registrations_user_exam (user_id, exam_id),
|
UNIQUE KEY uq_registrations_user_exam (user_id, exam_id),
|
||||||
KEY idx_registrations_status (status),
|
KEY idx_registrations_status (status),
|
||||||
|
|||||||
@@ -37,14 +37,18 @@ export function createSqliteAdapter(context) {
|
|||||||
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
||||||
]);
|
]);
|
||||||
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
|
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
|
||||||
|
ensureColumns('schools', [
|
||||||
|
['is_source_school', 'INTEGER NOT NULL DEFAULT 1'], ['is_admission_school', 'INTEGER NOT NULL DEFAULT 1']
|
||||||
|
]);
|
||||||
ensureColumns('candidate_profiles', [
|
ensureColumns('candidate_profiles', [
|
||||||
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
|
['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'],
|
['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'],
|
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
|
||||||
['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
|
['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
|
||||||
|
['specialty_category', 'TEXT'], ['specialty_type', 'TEXT'],
|
||||||
['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
|
['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
|
||||||
]);
|
]);
|
||||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT'], ['feature_score', 'REAL NOT NULL DEFAULT 0']]);
|
||||||
ensureColumns('exams', [
|
ensureColumns('exams', [
|
||||||
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'],
|
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'],
|
||||||
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
||||||
@@ -280,6 +284,9 @@ export function createSqliteAdapter(context) {
|
|||||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
|
if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
|
||||||
connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
|
connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
|
||||||
}
|
}
|
||||||
|
if (existingSystem && Number(existingSystem.schema_version || 1) < 19) {
|
||||||
|
connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run();
|
||||||
|
}
|
||||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||||
const extension = seed();
|
const extension = seed();
|
||||||
connection.exec('BEGIN IMMEDIATE');
|
connection.exec('BEGIN IMMEDIATE');
|
||||||
|
|||||||
+38
-11
@@ -1,6 +1,7 @@
|
|||||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||||
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
||||||
import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||||
|
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
|
||||||
export function createAdminRoutes(context) {
|
export function createAdminRoutes(context) {
|
||||||
const {
|
const {
|
||||||
@@ -112,7 +113,7 @@ export function createAdminRoutes(context) {
|
|||||||
function normalizeAdmissionCategories(input) {
|
function normalizeAdmissionCategories(input) {
|
||||||
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
||||||
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
|
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),
|
quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyCategory: cleanText(item.specialtyCategory, 30), specialtyType: cleanText(item.specialtyType, 80),
|
||||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
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.sourceSchoolId && item.quota > 0)
|
||||||
@@ -147,7 +148,8 @@ export function createAdminRoutes(context) {
|
|||||||
const placements = admissionRecords(db, 'placement').map(placement => {
|
const placements = admissionRecords(db, 'placement').map(placement => {
|
||||||
const account = db.users.find(item => item.id === placement.userId) || {};
|
const account = db.users.find(item => item.id === placement.userId) || {};
|
||||||
const profile = db.candidateProfiles.find(item => item.userId === 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 qualification = resolveProfileSpecialty(profile);
|
||||||
|
return { ...placement, candidate: { registrationNumber: account.candidateNumber, name: profile.name, idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) }, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '' };
|
||||||
});
|
});
|
||||||
const preferences = admissionRecords(db, 'preference').map(preference => {
|
const preferences = admissionRecords(db, 'preference').map(preference => {
|
||||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||||
@@ -155,12 +157,12 @@ export function createAdminRoutes(context) {
|
|||||||
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 || '' })) };
|
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);
|
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) });
|
return sendJson(response, 200, { ok: true, settings, plans, preferences, placements, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
||||||
}
|
}
|
||||||
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
||||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
|
||||||
const username = cleanText(body.username, 80);
|
const username = cleanText(body.username, 80);
|
||||||
const password = String(body.password || '');
|
const password = String(body.password || '');
|
||||||
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
|
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
|
||||||
@@ -191,11 +193,14 @@ export function createAdminRoutes(context) {
|
|||||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划');
|
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划');
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
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 school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
|
||||||
const categories = normalizeAdmissionCategories(body.categories);
|
const categories = normalizeAdmissionCategories(body.categories);
|
||||||
if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划');
|
if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划');
|
||||||
|
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
|
||||||
|
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
|
||||||
|
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.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.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');
|
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
|
||||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||||
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
@@ -305,11 +310,14 @@ export function createAdminRoutes(context) {
|
|||||||
const name = cleanText(body.name, 100);
|
const name = cleanText(body.name, 100);
|
||||||
const code = cleanText(body.code, 40).toUpperCase();
|
const code = cleanText(body.code, 40).toUpperCase();
|
||||||
const address = cleanText(body.address, 200);
|
const address = cleanText(body.address, 200);
|
||||||
|
const isSourceSchool = body.isSourceSchool !== false;
|
||||||
|
const isAdmissionSchool = body.isAdmissionSchool !== false;
|
||||||
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
|
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
|
||||||
|
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
|
||||||
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
|
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
|
||||||
if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
|
if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
|
||||||
if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
|
if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
|
||||||
const school = { id: uid('school'), name, code, address, active: body.active !== false };
|
const school = { id: uid('school'), name, code, address, isSourceSchool, isAdmissionSchool, active: body.active !== false };
|
||||||
await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`));
|
await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`));
|
||||||
return sendJson(response, 201, { ok: true, school });
|
return sendJson(response, 201, { ok: true, school });
|
||||||
}
|
}
|
||||||
@@ -322,11 +330,14 @@ export function createAdminRoutes(context) {
|
|||||||
const name = cleanText(body.name ?? school.name, 100);
|
const name = cleanText(body.name ?? school.name, 100);
|
||||||
const code = cleanText(body.code ?? school.code, 40).toUpperCase();
|
const code = cleanText(body.code ?? school.code, 40).toUpperCase();
|
||||||
const address = cleanText(body.address ?? school.address, 200);
|
const address = cleanText(body.address ?? school.address, 200);
|
||||||
|
const isSourceSchool = body.isSourceSchool == null ? school.isSourceSchool : Boolean(body.isSourceSchool);
|
||||||
|
const isAdmissionSchool = body.isAdmissionSchool == null ? school.isAdmissionSchool : Boolean(body.isAdmissionSchool);
|
||||||
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
|
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
|
||||||
|
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
|
||||||
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
|
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
|
||||||
if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
|
if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
|
||||||
if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
|
if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
|
||||||
Object.assign(school, { name, code, address, active: body.active == null ? school.active : Boolean(body.active) });
|
Object.assign(school, { name, code, address, isSourceSchool, isAdmissionSchool, active: body.active == null ? school.active : Boolean(body.active) });
|
||||||
await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`));
|
await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`));
|
||||||
return sendJson(response, 200, { ok: true, school });
|
return sendJson(response, 200, { ok: true, school });
|
||||||
}
|
}
|
||||||
@@ -343,6 +354,7 @@ export function createAdminRoutes(context) {
|
|||||||
}
|
}
|
||||||
if (pathname === '/api/admin/classes' && request.method === 'POST') {
|
if (pathname === '/api/admin/classes' && request.method === 'POST') {
|
||||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
|
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
|
||||||
|
if (!db.schools.some(item => item.id === user.schoolId && item.active && item.isSourceSchool)) return sendError(response, 409, '当前学校未设置为已启用的生源校');
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
|
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
|
||||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||||
@@ -374,7 +386,7 @@ export function createAdminRoutes(context) {
|
|||||||
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||||
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
|
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
|
||||||
}));
|
}));
|
||||||
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
return sendJson(response, 200, { ok: true, admins, schools: db.schools.filter(item => item.isSourceSchool), classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
||||||
}
|
}
|
||||||
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
@@ -387,7 +399,7 @@ export function createAdminRoutes(context) {
|
|||||||
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
|
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
|
||||||
const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64);
|
const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64);
|
||||||
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
|
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
|
||||||
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校');
|
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId && item.active && item.isSourceSchool)) return sendError(response, 400, '校级和班级管理员必须绑定已启用的生源校');
|
||||||
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
|
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
|
||||||
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
|
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
|
||||||
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
|
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
|
||||||
@@ -425,7 +437,7 @@ export function createAdminRoutes(context) {
|
|||||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
.map(item => candidateAccountBatchView(db, item));
|
.map(item => candidateAccountBatchView(db, item));
|
||||||
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
|
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
|
||||||
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) });
|
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
||||||
}
|
}
|
||||||
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
|
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
|
||||||
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
|
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
|
||||||
@@ -1293,6 +1305,21 @@ export function createAdminRoutes(context) {
|
|||||||
const result = await commitResultImport(db, user, body.rows);
|
const result = await commitResultImport(db, user, body.rows);
|
||||||
return sendJson(response, 200, { ok: true, ...result });
|
return sendJson(response, 200, { ok: true, ...result });
|
||||||
}
|
}
|
||||||
|
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
|
||||||
|
if (request.method === 'PATCH' && featureScoreMatch) {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const registration = db.registrations.find(item => item.id === featureScoreMatch[1] && item.status === 'approved');
|
||||||
|
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
|
||||||
|
const exam = db.exams.find(item => item.id === registration.examId);
|
||||||
|
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,特征分已永久锁定');
|
||||||
|
const body = await readJson(request);
|
||||||
|
const featureScore = Number(body.featureScore);
|
||||||
|
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, '特征分必须在 0—1000 之间');
|
||||||
|
registration.featureScore = Number(featureScore.toFixed(2));
|
||||||
|
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||||
|
await database.updateFeatureScore(registration, logAction(db, user, '登记特征分', `${profile?.name || registration.userId} · ${exam?.name || registration.examId} · ${registration.featureScore}`));
|
||||||
|
return sendJson(response, 200, { ok: true, registration });
|
||||||
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||||
|
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||||
|
|
||||||
function normalizeCategories(input, cleanText) {
|
function normalizeCategories(input, cleanText) {
|
||||||
const source = Array.isArray(input) ? input : [];
|
const source = Array.isArray(input) ? input : [];
|
||||||
@@ -6,6 +7,7 @@ function normalizeCategories(input, cleanText) {
|
|||||||
code: cleanText(item.code || `category_${index + 1}`, 40),
|
code: cleanText(item.code || `category_${index + 1}`, 40),
|
||||||
name: cleanText(item.name, 80),
|
name: cleanText(item.name, 80),
|
||||||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
|
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
|
||||||
|
specialtyCategory: cleanText(item.specialtyCategory, 30),
|
||||||
specialtyType: cleanText(item.specialtyType, 80),
|
specialtyType: cleanText(item.specialtyType, 80),
|
||||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||||||
@@ -14,14 +16,14 @@ function normalizeCategories(input, cleanText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createAdmissionRoutes(context) {
|
export function createAdmissionRoutes(context) {
|
||||||
const { database, readDb, sendJson, sendError, readJson, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
|
const { database, readDb, sendJson, sendError, readJson, sendWorkbook, buildWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
|
||||||
|
|
||||||
async function handleAdmission(request, response, pathname) {
|
async function handleAdmission(request, response, pathname) {
|
||||||
if (!pathname.startsWith('/api/admission/')) return false;
|
if (!pathname.startsWith('/api/admission/')) return false;
|
||||||
const user = await requireUser(request, response, 'admission_school');
|
const user = await requireUser(request, response, 'admission_school');
|
||||||
if (!user) return true;
|
if (!user) return true;
|
||||||
const db = await readDb();
|
const db = await readDb();
|
||||||
const school = db.schools.find(item => item.id === user.schoolId && item.active);
|
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
||||||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||||||
|
|
||||||
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
||||||
@@ -29,7 +31,7 @@ export function createAdmissionRoutes(context) {
|
|||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admission/plans') {
|
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) }));
|
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) });
|
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
@@ -37,8 +39,11 @@ export function createAdmissionRoutes(context) {
|
|||||||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||||||
const categories = normalizeCategories(body.categories, cleanText);
|
const categories = normalizeCategories(body.categories, cleanText);
|
||||||
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
|
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
|
||||||
|
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
|
||||||
|
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
|
||||||
|
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.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.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');
|
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
|
||||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||||
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
|
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
@@ -56,9 +61,37 @@ export function createAdmissionRoutes(context) {
|
|||||||
const exam = db.exams.find(entry => entry.id === item.examId);
|
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 { 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 };
|
const qualification = resolveProfileSpecialty(profile);
|
||||||
|
return { ...item, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||||||
});
|
});
|
||||||
return sendJson(response, 200, { ok: true, school, placements });
|
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
||||||
|
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && pathname === '/api/admission/placements/export') {
|
||||||
|
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||||
|
const exam = db.exams.find(item => item.id === examId);
|
||||||
|
const setting = admissionRecords(db, 'setting', examId)[0];
|
||||||
|
if (!exam || setting?.status !== 'completed') return sendError(response, 409, '录取工作结束后才能下载正式录取名单');
|
||||||
|
const rows = admissionRecords(db, 'placement', examId).filter(item => item.schoolId === school.id && item.status === 'final').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 === examId && entry.userId === item.userId) || {};
|
||||||
|
const sourceSchool = db.schools.find(entry => entry.id === profile.schoolId) || {};
|
||||||
|
const schoolClass = db.classes.find(entry => entry.id === profile.classId) || {};
|
||||||
|
const qualification = resolveProfileSpecialty(profile);
|
||||||
|
const scoreRows = db.results.filter(entry => entry.registrationId === registration.id && entry.published).map(result => ({ name: exam.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||||
|
return {
|
||||||
|
candidateNumber: account.candidateNumber || registration.registrationNumber || '', name: profile.name || account.displayName || '', gender: profile.gender || '',
|
||||||
|
idNumber: profile.idNumber || '', phone: profile.phone || '', email: profile.email || '', birthDate: profile.birthDate || '', ethnicity: profile.ethnicity || '', nativePlace: profile.nativePlace || '',
|
||||||
|
sourceSchool: sourceSchool.name || profile.school || '', sourceSchoolCode: sourceSchool.code || '', className: schoolClass.name || profile.grade || '',
|
||||||
|
address: [profile.provinceName, profile.cityName, profile.districtName, profile.address].filter(Boolean).join(' '), guardianName: profile.guardianName || profile.emergencyContact || '', guardianPhone: profile.guardianPhone || profile.emergencyPhone || '',
|
||||||
|
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生', specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '',
|
||||||
|
featureScore: Number(registration.featureScore || 0), subjectScores: scoreRows.map(score => `${score.name} ${score.score}`).join(';'), totalScore: Number(item.payload?.totalScore || 0),
|
||||||
|
admittedSchool: school.name, categoryName: item.payload?.categoryName || '', preferenceOrder: Number(item.payload?.preferenceOrder || 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
||||||
|
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
||||||
}
|
}
|
||||||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||||||
if (request.method === 'PATCH' && placementMatch) {
|
if (request.method === 'PATCH' && placementMatch) {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function createAuthRoutes(context) {
|
|||||||
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
|
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
|
||||||
const schoolId = cleanText(body.schoolId, 64);
|
const schoolId = cleanText(body.schoolId, 64);
|
||||||
const classId = cleanText(body.classId, 64);
|
const classId = cleanText(body.classId, 64);
|
||||||
const school = db.schools.find(item => item.id === schoolId && item.active);
|
const school = db.schools.find(item => item.id === schoolId && item.active && item.isSourceSchool);
|
||||||
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
|
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
|
||||||
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||||
const draftProfile = { schoolId, classId, gender };
|
const draftProfile = { schoolId, classId, gender };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { noticeForClient } from '../security/notice-content.mjs';
|
import { noticeForClient } from '../security/notice-content.mjs';
|
||||||
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||||
|
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
|
||||||
|
|
||||||
export function createCandidateRoutes(context) {
|
export function createCandidateRoutes(context) {
|
||||||
const {
|
const {
|
||||||
@@ -78,17 +79,20 @@ export function createCandidateRoutes(context) {
|
|||||||
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
|
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
|
||||||
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||||
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||||
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) });
|
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active) });
|
||||||
}
|
}
|
||||||
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
|
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone', 'specialtyCertificate', 'policyEligibility'];
|
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);
|
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);
|
profile.specialtyCategory = cleanText(body.specialtyCategory, 30);
|
||||||
|
profile.specialtyType = cleanText(body.specialtyType, 40);
|
||||||
|
if (!isValidSpecialty(profile.specialtyCategory, profile.specialtyType)) return sendError(response, 400, '请选择对应的特长生大类和小类');
|
||||||
|
profile.specialtyTypes = profile.specialtyType ? [profile.specialtyType] : [];
|
||||||
const region = resolveRegion(body);
|
const region = resolveRegion(body);
|
||||||
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
|
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
|
||||||
Object.assign(profile, region);
|
Object.assign(profile, region);
|
||||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isSourceSchool);
|
||||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
||||||
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||||
profile.schoolId = school.id;
|
profile.schoolId = school.id;
|
||||||
@@ -159,11 +163,15 @@ export function createCandidateRoutes(context) {
|
|||||||
const round = Number(setting.payload?.round || 1);
|
const round = Number(setting.payload?.round || 1);
|
||||||
const preference = activePreference(db, setting.examId, user.id, round);
|
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 placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
|
||||||
const plans = approvedPlans(db, setting.examId).map(plan => ({
|
const plans = approvedPlans(db, setting.examId).map(plan => {
|
||||||
id: plan.id, schoolId: plan.schoolId, schoolName: db.schools.find(item => item.id === plan.schoolId)?.name || '',
|
const school = db.schools.find(item => item.id === plan.schoolId);
|
||||||
categories: remainingPlanQuota(db, plan)
|
return {
|
||||||
}));
|
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
|
||||||
return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id) };
|
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category))
|
||||||
|
};
|
||||||
|
}).filter(plan => plan.categories.length);
|
||||||
|
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
|
||||||
|
return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile) };
|
||||||
}).filter(item => item.exam);
|
}).filter(item => item.exam);
|
||||||
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
|
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
|
||||||
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
||||||
@@ -183,7 +191,7 @@ export function createCandidateRoutes(context) {
|
|||||||
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
|
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
|
||||||
if (new Set(choices.map(item => `${item.schoolId}|${item.categoryCode}`)).size !== 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);
|
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, '志愿中包含未审核通过的学校或招生类别');
|
if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode && candidateEligibleForCategory(profile, category))))) return sendError(response, 400, '志愿中包含未审核通过或与本人资格不符的招生类别');
|
||||||
const round = Number(setting.payload.round || 1);
|
const round = Number(setting.payload.round || 1);
|
||||||
const nowValue = nowIso();
|
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 };
|
const preference = activePreference(db, setting.examId, user.id, round) || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function createPublicRoutes(context) {
|
|||||||
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 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 }));
|
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 }));
|
||||||
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) }));
|
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 { 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 && item.isSourceSchool), 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);
|
return sendJson(response, 200, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,11 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
|||||||
|
|
||||||
const created = [];
|
const created = [];
|
||||||
for (const candidate of candidates) {
|
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()) {
|
for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) {
|
||||||
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
|
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const { category } = target;
|
const { category } = target;
|
||||||
if (category.specialtyType && !specialtyTypes.includes(category.specialtyType)) continue;
|
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
|
||||||
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
||||||
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
|
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
|
||||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
||||||
@@ -110,7 +109,8 @@ export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
|||||||
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
|
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
|
||||||
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
|
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
|
||||||
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
|
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
|
||||||
totalScore: candidate.score, quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
totalScore: candidate.score, featureScore: Number(db.registrations.find(item => item.examId === examId && item.userId === candidate.preference.userId)?.featureScore || 0),
|
||||||
|
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -125,3 +125,4 @@ export function remainingPlanQuota(db, plan) {
|
|||||||
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
|
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
import { candidateEligibleForCategory, resolveProfileSpecialty } from '../data/specialty-types.mjs';
|
||||||
|
|||||||
+29
@@ -593,3 +593,32 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
|||||||
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
|
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
|
||||||
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
|
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
|
||||||
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
|
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
|
||||||
|
|
||||||
|
/* 招生资格与计划台账:配额卡片是本轮的唯一结构化视觉重点。 */
|
||||||
|
.admission-plan-console.structured { padding:22px; margin-bottom:16px; }
|
||||||
|
.admission-categories-builder { display:grid; gap:12px; }
|
||||||
|
.admission-builder-head,.indicator-allocation-head { display:flex; align-items:center; justify-content:space-between; gap:16px; }
|
||||||
|
.admission-builder-head small,.indicator-allocation-head small { display:block; margin-top:3px; color:var(--muted); }
|
||||||
|
[data-admission-categories] { display:grid; gap:13px; }
|
||||||
|
.admission-category-editor { overflow:hidden; border:1px solid #d8e2ec; border-radius:12px; background:#fbfdfe; }
|
||||||
|
.admission-category-editor > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 14px; color:#fff; background:linear-gradient(100deg,#244e72,#287486); }
|
||||||
|
.admission-category-editor > header div { display:flex; align-items:center; gap:10px; }.admission-category-editor > header span { color:#b9d8df; font:700 10px Consolas,monospace; }.admission-category-editor > header strong { font-size:13px; }
|
||||||
|
.admission-category-editor > header button { border:0; color:#dbeef1; background:transparent; cursor:pointer; }
|
||||||
|
.admission-category-fields { display:grid; grid-template-columns:1.2fr .55fr .7fr; gap:11px; padding:14px; }
|
||||||
|
.specialty-plan-fields { grid-column:1/-1; display:grid; grid-template-columns:1fr 1fr; gap:11px; padding:12px; border-left:3px solid #2b7e89; border-radius:8px; background:#edf7f7; }.specialty-plan-fields.hidden { display:none; }
|
||||||
|
.indicator-allocation-editor { padding:0 14px 14px; }.indicator-allocation-head { padding-top:12px; border-top:1px solid #dfe7ee; }
|
||||||
|
[data-indicator-allocations] { display:grid; gap:8px; margin-top:10px; }.indicator-allocation-row { display:grid; grid-template-columns:1fr 150px auto; align-items:end; gap:9px; padding:10px; border-radius:9px; background:#f1f5f8; }
|
||||||
|
.indicator-allocation-row label,.admission-category-fields label { display:grid; gap:5px; }.indicator-allocation-row label span,.admission-category-fields label > span,.school-role-selector > strong { color:#607087; font-size:11px; font-weight:700; }
|
||||||
|
.indicator-allocation-row input,.indicator-allocation-row select,.admission-category-fields input,.admission-category-fields select { width:100%; min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
|
||||||
|
.preference-choice-row { display:grid; grid-template-columns:34px 1fr 1fr; align-items:end; gap:10px; padding:11px; border:1px solid #dce5eb; border-radius:10px; background:#f8fbfc; }
|
||||||
|
.preference-choice-list .preference-choice-row > b { align-self:center; }.preference-choice-list .preference-choice-row > label { display:grid; grid-template-columns:1fr; align-items:stretch; gap:5px; }.preference-choice-row label > span { color:#687789; font-size:10px; font-weight:700; }
|
||||||
|
.admission-score-strip > span b { margin-left:4px; color:#244e72; }.admission-score-strip > span:not(:first-child) { padding-left:12px; border-left:1px solid #cadde1; }
|
||||||
|
.school-type-summary { grid-template-columns:repeat(4,1fr); }.school-role-tags { display:flex; flex-wrap:wrap; gap:5px; }.school-role { padding:5px 8px; border-radius:99px; font-size:11px; font-weight:700; }.school-role.source { color:#245c72; background:#e5f2f7; }.school-role.admission { color:#3c6651; background:#e5f3eb; }
|
||||||
|
.school-role-selector { display:grid; gap:9px; padding:13px; border:1px solid var(--line); border-radius:10px; background:#f7f9fc; }.school-role-selector > div { display:grid; grid-template-columns:1fr 1fr; gap:9px; }.school-role-selector label { display:grid; grid-template-columns:auto 1fr; gap:9px; padding:11px; border:1px solid #dce3ed; border-radius:8px; background:#fff; }.school-role-selector label span { display:grid; gap:3px; }.school-role-selector small { color:var(--muted); }
|
||||||
|
.feature-score-console { display:grid; grid-template-columns:minmax(260px,.8fr) minmax(420px,1.2fr); align-items:end; gap:24px; margin-bottom:15px; padding:21px; border-left:4px solid #287486; background:linear-gradient(105deg,#fff,#edf7f7); }.feature-score-console > div > span,.admission-export-bar > div > span { color:#2c7180; font:700 10px Consolas,monospace; letter-spacing:1.2px; }.feature-score-console h2 { margin:5px 0; }.feature-score-console p { margin:0; color:var(--muted); line-height:1.7; }.feature-score-console form { display:grid; grid-template-columns:1fr 130px auto; align-items:end; gap:10px; }.feature-score-console label { display:grid; gap:5px; }.feature-score-console select,.feature-score-console input,.admission-export-bar select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
|
||||||
|
.admission-export-bar { display:grid; grid-template-columns:1fr minmax(250px,.5fr) auto; align-items:end; gap:18px; margin-bottom:15px; padding:20px 22px; border-left:4px solid #287486; background:linear-gradient(105deg,#fff,#eef7f8); }.admission-export-bar > div { display:grid; gap:4px; }.admission-export-bar label { display:grid; gap:5px; }.admission-export-bar small { color:var(--muted); }
|
||||||
|
.specialty-qualification-grid select:disabled { color:#8f99a8; background:#f1f3f6; }
|
||||||
|
.result-panel .result-summary { grid-template-columns:repeat(4,1fr); }
|
||||||
|
.result-panel .result-summary.qualified > span:last-child strong,.result-panel .result-summary.unqualified > span:last-child strong { color:var(--navy); }.result-panel .result-summary.qualified > span:nth-child(3) strong { color:#237358; }.result-panel .result-summary.unqualified > span:nth-child(3) strong { color:#a24f43; }
|
||||||
|
@media (max-width:1000px) { .admission-category-fields { grid-template-columns:1fr 1fr; }.admission-category-fields > label:first-child { grid-column:1/-1; }.feature-score-console,.admission-export-bar { grid-template-columns:1fr; }.school-type-summary { grid-template-columns:repeat(2,1fr); } }
|
||||||
|
@media (max-width:620px) { .admission-category-fields,.specialty-plan-fields,.indicator-allocation-row,.preference-choice-row,.school-role-selector > div,.feature-score-console form,.school-type-summary { grid-template-columns:1fr; }.admission-category-fields > label:first-child { grid-column:auto; }.preference-choice-list .preference-choice-row > b { justify-self:start; }.admission-builder-head,.indicator-allocation-head { align-items:flex-start; flex-direction:column; }.result-panel .result-summary { grid-template-columns:1fr; } }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs';
|
import { buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../src/services/volunteer-admission.mjs';
|
||||||
|
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
|
||||||
|
|
||||||
const now = '2026-07-21T08:00:00.000Z';
|
const now = '2026-07-21T08:00:00.000Z';
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -12,7 +13,7 @@ const db = {
|
|||||||
candidateProfiles: [
|
candidateProfiles: [
|
||||||
{ userId: 'u-high', name: '高分考生', schoolId: 'source-a', idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
|
{ userId: 'u-high', name: '高分考生', schoolId: 'source-a', idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
|
||||||
{ userId: 'u-low', name: '次高考生', schoolId: 'source-b', idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
|
{ userId: 'u-low', name: '次高考生', schoolId: 'source-b', idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
|
||||||
{ userId: 'u-sport', name: '特长考生', schoolId: 'source-b', idNumber: '320101200903031234', phone: '13712345678', specialtyTypes: ['田径'] }
|
{ userId: 'u-sport', name: '特长考生', schoolId: 'source-b', idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] }
|
||||||
],
|
],
|
||||||
schools: [
|
schools: [
|
||||||
{ id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
|
{ id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
|
||||||
@@ -21,7 +22,7 @@ const db = {
|
|||||||
registrations: [
|
registrations: [
|
||||||
{ id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] },
|
{ id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] },
|
||||||
{ id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] },
|
{ id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] },
|
||||||
{ id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'] }
|
{ id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 88.5 }
|
||||||
],
|
],
|
||||||
results: [
|
results: [
|
||||||
{ registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true },
|
{ registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true },
|
||||||
@@ -32,7 +33,7 @@ const db = {
|
|||||||
{ id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } },
|
{ id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } },
|
||||||
{ id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [
|
{ id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [
|
||||||
{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
|
{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
|
||||||
{ code: 'sport', name: '田径特长生', quota: 1, specialtyType: '田径', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
|
{ code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
|
||||||
] } },
|
] } },
|
||||||
{ id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
|
{ id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
|
||||||
{ id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
|
{ id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general' }, { schoolId: 'target-a', categoryCode: 'general' }] } },
|
||||||
@@ -47,6 +48,9 @@ assert.equal(placements.length, 3, '三个符合条件且计划充足的考生
|
|||||||
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
|
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
|
||||||
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
|
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
|
||||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
|
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
|
||||||
|
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送,但不并入文化课总分');
|
||||||
|
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
|
||||||
|
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
|
||||||
|
|
||||||
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
|
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
|
||||||
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
|
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
|
||||||
|
|||||||
+43
-3
@@ -47,7 +47,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My
|
|||||||
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
|
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
|
||||||
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
|
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
|
||||||
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
|
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
|
||||||
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v17 结构并重建更旧或未完成的开发结构');
|
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v19 结构并重建更旧或未完成的开发结构');
|
||||||
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
||||||
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
||||||
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
||||||
@@ -145,6 +145,7 @@ const classAdmin2 = createClient();
|
|||||||
const candidate = createClient();
|
const candidate = createClient();
|
||||||
const selfCandidate = createClient();
|
const selfCandidate = createClient();
|
||||||
const batchCandidate = createClient();
|
const batchCandidate = createClient();
|
||||||
|
const admissionSchoolClient = createClient();
|
||||||
const anonymous = createClient();
|
const anonymous = createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -185,6 +186,8 @@ try {
|
|||||||
const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name);
|
const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name);
|
||||||
const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name);
|
const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name);
|
||||||
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
|
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
|
||||||
|
const schoolColumns = inspector.prepare('PRAGMA table_info(schools)').all().map(row => row.name);
|
||||||
|
const profileColumns = inspector.prepare('PRAGMA table_info(candidate_profiles)').all().map(row => row.name);
|
||||||
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
|
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
|
||||||
const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count;
|
const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count;
|
||||||
const seededExamCount = inspector.prepare('SELECT COUNT(*) AS count FROM exams').get().count;
|
const seededExamCount = inspector.prepare('SELECT COUNT(*) AS count FROM exams').get().count;
|
||||||
@@ -229,7 +232,7 @@ try {
|
|||||||
inspector.close();
|
inspector.close();
|
||||||
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
|
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
|
||||||
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
||||||
assert.equal(schemaVersion, 18, '志愿填报与招生录取应使用 v18 数据结构');
|
assert.equal(schemaVersion, 19, '学校类型、特长资格与特征分应使用 v19 数据结构');
|
||||||
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
|
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
|
||||||
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
|
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
|
||||||
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
|
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
|
||||||
@@ -240,7 +243,10 @@ try {
|
|||||||
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
||||||
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
||||||
assert.ok(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片');
|
assert.ok(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片');
|
||||||
|
assert.ok(['is_source_school', 'is_admission_school'].every(column => schoolColumns.includes(column)), '学校档案应统一保存生源校和招生校类型');
|
||||||
|
assert.ok(['specialty_category', 'specialty_type'].every(column => profileColumns.includes(column)), '考生档案应保存特长生大类和小类');
|
||||||
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
|
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
|
||||||
|
assert.ok(registrationColumns.includes('feature_score'), '每场考试报名应有独立且默认 0 分的特征分');
|
||||||
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
|
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
|
||||||
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
|
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
|
||||||
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'unpaid' && item.count >= 90), '测试数据应包含大量已报名未缴费记录');
|
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'unpaid' && item.count >= 90), '测试数据应包含大量已报名未缴费记录');
|
||||||
@@ -342,11 +348,15 @@ try {
|
|||||||
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务');
|
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务');
|
||||||
const updateProfile = await candidate.request('/api/candidate/profile', {
|
const updateProfile = await candidate.request('/api/candidate/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888', specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyCertificate: 'ART-2026-001' }
|
||||||
});
|
});
|
||||||
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
|
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
|
||||||
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
|
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
|
||||||
assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核');
|
assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核');
|
||||||
|
assert.equal(updateProfile.data.profile.specialtyCategory, 'arts', '考生资料应保存艺术大类资格');
|
||||||
|
assert.equal(updateProfile.data.profile.specialtyType, 'fine_arts', '考生资料应保存对应的美术小类资格');
|
||||||
|
const mismatchedSpecialty = await candidate.request('/api/candidate/profile', { method: 'PUT', body: { ...updateProfile.data.profile, schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', specialtyCategory: 'arts', specialtyType: 'track_field' } });
|
||||||
|
assert.equal(mismatchedSpecialty.response.status, 400, '艺术大类不得选择体育小类');
|
||||||
const refreshedSession = await candidate.request('/api/auth/me');
|
const refreshedSession = await candidate.request('/api/auth/me');
|
||||||
assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步');
|
assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步');
|
||||||
const candidateCannotAdmin = await candidate.request('/api/admin/dashboard');
|
const candidateCannotAdmin = await candidate.request('/api/admin/dashboard');
|
||||||
@@ -367,7 +377,16 @@ try {
|
|||||||
const createSchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市第四中学', code: 'hz04', address: '海州市测试区学校路 4 号', active: true } });
|
const createSchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市第四中学', code: 'hz04', address: '海州市测试区学校路 4 号', active: true } });
|
||||||
assert.equal(createSchool.response.status, 201, '超级管理员应能创建学校');
|
assert.equal(createSchool.response.status, 201, '超级管理员应能创建学校');
|
||||||
assert.equal(createSchool.data.school.code, 'HZ04', '学校代码应规范化为大写');
|
assert.equal(createSchool.data.school.code, 'HZ04', '学校代码应规范化为大写');
|
||||||
|
assert.equal(createSchool.data.school.isSourceSchool, true, '新建学校默认兼容生源校职责');
|
||||||
|
assert.equal(createSchool.data.school.isAdmissionSchool, true, '新建学校默认兼容招生校职责');
|
||||||
const createdSchoolId = createSchool.data.school.id;
|
const createdSchoolId = createSchool.data.school.id;
|
||||||
|
const admissionOnlySchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市招生实验学校', code: 'HZ-ADMISSION', isSourceSchool: false, isAdmissionSchool: true, active: true } });
|
||||||
|
assert.equal(admissionOnlySchool.response.status, 201, '学校管理应支持只设置为招生校');
|
||||||
|
assert.equal(admissionOnlySchool.data.school.isSourceSchool, false);
|
||||||
|
assert.ok(!(await anonymous.request('/api/public/home')).data.schools.some(item => item.id === admissionOnlySchool.data.school.id), '仅招生校不得出现在考生生源学校选择中');
|
||||||
|
const admissionAccount = await admin.request('/api/admin/admission-school-accounts', { method: 'POST', body: { schoolId: admissionOnlySchool.data.school.id, username: 'admission_only_test', password: 'Admission123!', displayName: '招生实验校招办' } });
|
||||||
|
assert.equal(admissionAccount.response.status, 201, '招生校应可创建招生学校账号');
|
||||||
|
assert.equal((await admissionSchoolClient.request('/api/auth/login', { method: 'POST', body: { username: 'admission_only_test', password: 'Admission123!' } })).response.status, 200, '招生学校账号应可登录独立工作台');
|
||||||
assert.equal((await admin.request('/api/admin/schools', { method: 'POST', body: { name: '重复代码学校', code: 'HZ04' } })).response.status, 409, '学校代码必须唯一');
|
assert.equal((await admin.request('/api/admin/schools', { method: 'POST', body: { name: '重复代码学校', code: 'HZ04' } })).response.status, 409, '学校代码必须唯一');
|
||||||
const disableSchool = await admin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { name: '海州市第四实验中学', active: false } });
|
const disableSchool = await admin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { name: '海州市第四实验中学', active: false } });
|
||||||
assert.equal(disableSchool.response.status, 200, '超级管理员应能编辑和停用学校');
|
assert.equal(disableSchool.response.status, 200, '超级管理员应能编辑和停用学校');
|
||||||
@@ -435,6 +454,12 @@ try {
|
|||||||
assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/);
|
assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/);
|
||||||
const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data);
|
const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data);
|
||||||
assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头');
|
assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头');
|
||||||
|
const admittedWorkbook = new ExcelJS.Workbook();
|
||||||
|
await admittedWorkbook.xlsx.load(await buildWorkbook('admitted_candidates', [{ candidateNumber: '20260001', name: '录取考生', featureScore: 87.5, totalScore: 650, admittedSchool: '第一中学', categoryName: '艺术特长生', preferenceOrder: 1 }]));
|
||||||
|
const admittedSheet = admittedWorkbook.getWorksheet('录取考生');
|
||||||
|
assert.equal(admittedSheet.getCell('A3').value, '20260001', '录取考生 Excel 应包含报名号');
|
||||||
|
assert.ok(admittedSheet.getRow(2).values.includes('特征分'), '录取考生 Excel 应单列特征分');
|
||||||
|
assert.ok(admittedSheet.getRow(2).values.includes('录取学校'), '录取考生 Excel 应包含录取学校');
|
||||||
const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }]));
|
const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }]));
|
||||||
const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile });
|
const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile });
|
||||||
assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级');
|
assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级');
|
||||||
@@ -552,6 +577,15 @@ try {
|
|||||||
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
|
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
|
||||||
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
|
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
|
||||||
const exam = createExam.data.exam;
|
const exam = createExam.data.exam;
|
||||||
|
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5 } })).response.status, 200, '超级管理员应能按考试启用志愿功能');
|
||||||
|
const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [
|
||||||
|
{ code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] },
|
||||||
|
{ code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] }
|
||||||
|
] } });
|
||||||
|
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
|
||||||
|
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
|
||||||
|
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
|
||||||
|
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
|
||||||
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
|
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||||
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
|
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
|
||||||
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
|
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
|
||||||
@@ -843,6 +877,11 @@ try {
|
|||||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
||||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
||||||
const adminResults = await admin.request('/api/admin/results');
|
const adminResults = await admin.request('/api/admin/results');
|
||||||
|
assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0');
|
||||||
|
const featureScoreUpdate = await admin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 87.5 } });
|
||||||
|
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能登记与科目无关的特征分');
|
||||||
|
assert.equal(featureScoreUpdate.data.registration.featureScore, 87.5);
|
||||||
|
assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分');
|
||||||
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
|
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
|
||||||
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
|
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
|
||||||
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
|
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
|
||||||
@@ -851,6 +890,7 @@ try {
|
|||||||
const results = await candidate.request('/api/candidate/results');
|
const results = await candidate.request('/api/candidate/results');
|
||||||
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
||||||
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
||||||
|
assert.equal(resultSummary.featureScore, 87.5, '考生端整场成绩应单独显示特征分');
|
||||||
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
||||||
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
|
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
|
||||||
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
|
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
|
||||||
|
|||||||
Reference in New Issue
Block a user