Add structured admission plans and school role management

This commit is contained in:
2026-07-21 14:04:19 +08:00 Unverified
parent 1b6e860013
commit 5e0bee60e2
23 changed files with 483 additions and 82 deletions
+70 -10
View File
@@ -2,10 +2,12 @@ import { api } from './src/client/api.mjs';
import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs';
import { createCandidateViews } from './src/client/candidate-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 { state } from './src/client/state.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 { specialtyCatalog } from './src/data/specialty-types.mjs';
const app = document.querySelector('#app');
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;
}
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') {
const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null;
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 } });
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)) {
event.preventDefault();
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('')}`;
}
}
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-action="admin-level"]')) {
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) });
state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard');
} else if (kind === 'volunteer-preference') {
const choices = new FormData(form).getAll('choices').filter(Boolean).map(value => {
const [schoolId, categoryCode] = String(value).split('|'); return { schoolId, categoryCode };
});
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);
await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute();
} else if (kind === 'exam-registration') {
@@ -595,12 +650,13 @@ document.addEventListener('submit', async event => {
form.reset(); toast('招生学校账号已创建'); renderRoute();
} else if (kind === 'admission-plan' || kind === 'school-admission-plan') {
const body = formObject(form);
body.categories = String(body.categoriesText || '').split(/\r?\n/).map((line, index) => {
const [name, quota, specialtyType = '', indicators = ''] = line.split('|').map(item => item.trim());
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);
return { code: `category_${index + 1}`, name, quota: Number(quota), specialtyType, indicatorAllocations };
body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => {
const specialty = editor.querySelector('[name="categoryKind"]').value === 'specialty';
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: 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);
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 });
toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute();
} else if (kind === 'placement-review') {
@@ -608,7 +664,7 @@ document.addEventListener('submit', async event => {
await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body });
toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute();
} 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 });
closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute();
} else if (kind === 'school-class') {
@@ -713,6 +769,10 @@ document.addEventListener('submit', async event => {
const body = formObject(form); body.published = form.published.checked;
await api('/api/admin/results', { method: 'POST', body });
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') {
const rows = state.resultImportPreview?.rows || [];
if (!rows.length) throw new Error('没有可提交的成绩预览');
@@ -730,7 +790,7 @@ function openAdminForm() {
}
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) {