Add indicator qualification and admissions announcements

This commit is contained in:
biss committed 2026-07-21 14:37:06 +08:00
1 parent 5e0bee60e2
commit 6be1ff227a
17 files changed
+332 -70

No files matched your search

+7 -6
View File
@@ -260,18 +260,19 @@ npm run seed-test-data:mysql -- --force
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下: 系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
1. 超级管理员设置填报时间、最多志愿数和当前阶段;考生只有在当次成绩全部发布后才能填报。 1. 超级管理员设置填报时间、普通志愿数、最多提交次数和当前阶段;考生只有在当次成绩全部发布后才能填报,达到提交上限后自动锁定。
2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。 2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
3. 志愿只能由考生本人在开放窗口内保存或修改。班级管理员、校级管理员无权查看;超级管理员只读可见,任何管理员均无代改接口。 3. 生源校学校管理员按考试逐人确认指标分配资格;本校资料已完善的在册考生全部确认后,系统自动公开有无资格及对应特长类型。超级管理员和班级管理员均不能代确认。
4. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,校验特长资格、指标池和类别剩余计划,遵循“分数优先、遵循志愿”。 4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
5. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
6. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并按设置自动发布脱敏公示。 6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。 公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。 学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。
数据结构版本为 v19,`admission_records` 关系表支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
## 手动测试数据账号 ## 手动测试数据账号
+16 -6
View File
@@ -59,7 +59,7 @@ function renderError(error) {
} }
const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState }; const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, emptyState };
const { brand, renderHome, renderAuth } = createPublicViews(baseViewContext); const { brand, renderHome, renderAnnouncements, renderAuth } = createPublicViews(baseViewContext);
const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand });
const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity });
const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand }); const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand });
@@ -75,6 +75,7 @@ async function renderRoute() {
const route = location.hash.slice(1) || 'home'; const route = location.hash.slice(1) || 'home';
const [section, page = 'dashboard'] = route.split('/'); const [section, page = 'dashboard'] = route.split('/');
if (section === 'home') renderHome(); if (section === 'home') renderHome();
else if (section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderAnnouncements(state.publicAnnouncements); }
else if (section === 'login' || section === 'register') renderAuth(section); else if (section === 'login' || section === 'register') renderAuth(section);
else if (section === 'candidate') await renderCandidate(page); else if (section === 'candidate') await renderCandidate(page);
else if (section === 'admin') await renderAdmin(page); else if (section === 'admin') await renderAdmin(page);
@@ -221,6 +222,14 @@ 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 === 'save-indicator-qualification') {
const row = target.closest('tr');
const value = row?.querySelector('[data-indicator-eligible]')?.value;
if (!value) return toast('请选择资格结论', '必须明确选择有资格或无资格');
const data = await api(`/api/admin/indicator-qualifications/${row.dataset.examId}/${row.dataset.userId}`, { method: 'PUT', body: { eligible: value === 'true' } });
toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生');
return renderRoute();
}
if (action === 'add-admission-category') { if (action === 'add-admission-category') {
const sources = state.pageData?.sourceSchools || []; const sources = state.pageData?.sourceSchools || [];
target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources)); target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources));
@@ -510,9 +519,10 @@ document.addEventListener('change', event => {
const categorySelect = row?.querySelector('[name="choiceCategory"]'); const categorySelect = row?.querySelector('[name="choiceCategory"]');
const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId); const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId);
const plan = admission?.plans?.find(item => item.schoolId === event.target.value); const plan = admission?.plans?.find(item => item.schoolId === event.target.value);
const preferenceType = row?.dataset.preferenceType || 'general';
if (categorySelect) { if (categorySelect) {
categorySelect.disabled = !plan; 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('')}`; categorySelect.innerHTML = `<option value="">${plan ? '请选择招生类别' : '请先按代码选择学校'}</option>${(plan?.categories || []).filter(item => item.preferenceTypes?.includes(preferenceType) && Number(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining) > 0).map(item => `<option value="${h(item.code)}">${h(item.name)} · 对应余 ${h(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining)}</option>`).join('')}`;
} }
} }
if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target); if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target);
@@ -617,9 +627,9 @@ 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 = [...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 choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value, preferenceType: row.dataset.preferenceType || 'general' })).filter(item => item.schoolId && item.categoryCode);
await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); const data = await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } });
toast('志愿已保存', '仅你本人可在填报截止前修改'); renderRoute(); toast(data.locked ? '志愿已保存并锁定' : '志愿已保存', data.locked ? '已达到本轮提交次数上限' : `还可提交 ${data.remainingSubmissions} 次`); renderRoute();
} else if (kind === 'exam-registration') { } else if (kind === 'exam-registration') {
const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) };
if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目');
@@ -642,7 +652,7 @@ document.addEventListener('submit', async event => {
await api('/api/admin/admins', { method: 'POST', body }); await api('/api/admin/admins', { method: 'POST', body });
closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute();
} else if (kind === 'admission-setting') { } else if (kind === 'admission-setting') {
const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); body.maxSubmissions = Number(body.maxSubmissions || 3);
await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body }); await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body });
toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute(); toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute();
} else if (kind === 'admission-account') { } else if (kind === 'admission-account') {
+1 -1
View File
@@ -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 = 19, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', 'UPDATE schema_metadata SET schema_version = 20, 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()
); );
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff. Show more