diff --git a/README.md b/README.md index 1a9e8c2..0614054 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ - 查看报名审核、应缴金额、缴费状态及班级负责人确认记录 - 准考证生成状态、开放时间与下载 - 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论 +- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单 +- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书 - 通知公告中心 ### 管理后台 @@ -269,11 +271,14 @@ npm run seed-test-data:mysql -- --force 4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。 5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。 6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。 -7. 未完成计划可开启下一轮补录;已正式录取的考生不会被覆盖。录取结束后系统发送个人通知,并在独立“招生公示”页面自动发布脱敏录取名单及按学校、类别统计的录取分数线。 +7. 超级管理员签发正式录取后,系统按“招生学校代码 + 考试代码 + 校内独立流水号”生成稳定的录取通知书编号,并开启招生学校报到工作台。 +8. 招生学校可逐人暂存 Y/N/P 报到状态,也可导出带下拉校验的 Excel、修改后导入,或扫描录取通知书二维码核验并登记;完整报到情况提交前不会进入审批。 +9. 学校提交报到情况后可选择不补录或申请补录。超级管理员审批所有学校决定后,系统按缺额进入下一轮补录或结束录取;计划录取率和实际报到率在双方工作台实时显示。 +10. 审批通过的报到情况会自动进入公开通知,包含计划数、正式录取数、已报到数、缺额和学校说明;无补录时标题不会出现“补录”。录取结束后继续自动发布脱敏录取名单及按学校、类别统计的录取分数线。 公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。 -学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可在录取结束后下载本校全部正式录取考生信息 Excel。 +学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可设计本校录取通知书的标题、正文、落款和配色,通知书不再添加“录取专用章”。管理后台以一级业务域分组,并把招生录取拆为录取设置、招生账户、招生计划、报到与补录、投档监督等二级菜单。 数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。 diff --git a/app.js b/app.js index f832363..ddebbb3 100644 --- a/app.js +++ b/app.js @@ -345,13 +345,13 @@ document.addEventListener('click', async event => { const results = (state.pageData?.results || []).filter(item => item.examId === examId); const summary = (state.pageData?.summaries || []).find(item => item.examId === examId); if (!results.length || !summary?.verificationCode) return toast('成绩单暂不可下载', '请刷新页面后重试'); - downloadScoreReport({ organization: state.publicData.organization, candidate: state.pageData.candidate || { name: state.user.displayName, candidateNumber: state.user.candidateNumber }, exam: { id: examId, name: results[0].examName, code: results[0].examCode }, results, summary: { ...summary, publishedAt: [...results].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0]?.publishedAt }, verificationCode: summary.verificationCode, verificationUrl: `${location.origin}/#verify/${summary.verificationCode}` }); + await downloadScoreReport({ organization: state.publicData.organization, candidate: state.pageData.candidate || { name: state.user.displayName, candidateNumber: state.user.candidateNumber }, exam: { id: examId, name: results[0].examName, code: results[0].examCode }, results, summary: { ...summary, publishedAt: [...results].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0]?.publishedAt }, verificationCode: summary.verificationCode, verificationQr: summary.verificationQr, verificationUrl: `${location.origin}/#verify/${summary.verificationCode}` }); return toast('PDF 成绩单已生成', '文件包含防伪查询码'); } if (action === 'download-admission-notice') { const item = (state.pageData?.admissions || []).find(entry => entry.examId === target.dataset.examId); if (!item?.placement || item.placement.status !== 'final' || !item.noticeVerificationCode) return toast('录取通知书暂不可下载', '只有正式录取后才能生成'); - downloadAdmissionNotice({ organization: state.publicData.organization, candidate: { name: state.profile?.name || state.user.displayName }, exam: item.exam, placement: item.placement, school: item.placementSchool || { name: item.placement.schoolName || '招生学校' }, template: item.noticeTemplate || {}, verificationCode: item.noticeVerificationCode, verificationUrl: `${location.origin}/#verify/${item.noticeVerificationCode}` }); + await downloadAdmissionNotice({ organization: state.publicData.organization, candidate: { name: state.profile?.name || state.user.displayName }, exam: item.exam, placement: item.placement, school: item.placementSchool || { name: item.placement.schoolName || '招生学校' }, template: item.noticeTemplate || {}, verificationCode: item.noticeVerificationCode, verificationQr: item.noticeVerificationQr, noticeNumber: item.noticeNumber, verificationUrl: `${location.origin}/#verify/${item.noticeVerificationCode}` }); return toast('录取通知书已生成', '请核对学校和录取类别'); } if (action === 'download-admitted-candidates') { @@ -405,6 +405,30 @@ document.addEventListener('click', async event => { updateQualificationSelection(container); return; } + if (action === 'download-admission-reporting') { + window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`; + return; + } + if (action === 'submit-admission-reporting') { + if (!window.confirm('确认提交本轮全部报到情况?提交后需先完成补录决定,才能交由超级管理员审批。')) return; + await api('/api/admission/reporting/submit', { method: 'POST', body: { examId: target.dataset.examId } }); + toast('报到情况已提交', '请根据计划完成率确认是否申请补录'); + return renderRoute(); + } + if (action === 'review-admission-reporting') { + const approved = target.dataset.approved === 'true'; + const supplement = target.dataset.supplement === 'true'; + const approvalNote = window.prompt(approved ? '请输入审批意见(可选)' : '请输入退回原因', '') ?? ''; + if (!approved && approvalNote.trim().length < 2) return toast('需要退回原因', '请说明招生学校应修改的内容'); + let preferenceEnd = ''; + if (approved && supplement) { + preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-30T18:00)', '') ?? ''; + if (!preferenceEnd) return; + } + await api(`/api/admin/admission-reporting/${encodeURIComponent(target.dataset.id)}`, { method: 'PATCH', body: { approved, approvalNote, preferenceEnd } }); + toast(approved ? '审批完成并自动公开' : '已退回招生学校', approved ? (supplement ? '全部学校审批完成后将自动开启补录' : '报到统计已进入公开通知') : '招生学校可修改暂存数据后重新提交'); + return renderRoute(); + } if (action === 'clear-table-filters') { const tableId = target.dataset.target; state.tableFilters[tableId] = { query: '', status: 'all', filters: {} }; @@ -1014,6 +1038,24 @@ document.addEventListener('submit', async event => { } else if (kind === 'admission-notice-template') { await api('/api/admission/notice-template', { method: 'PUT', body: formObject(form) }); toast('录取通知书模板已保存', '正式录取考生将使用该模板生成 PDF'); renderRoute(); + } else if (kind === 'admission-reporting-draft') { + const rows = [...form.querySelectorAll('[data-reporting-status]')].map(select => ({ + placementId: select.dataset.placementId, + status: select.value, + note: form.querySelector(`[data-reporting-note][data-placement-id="${CSS.escape(select.dataset.placementId)}"]`)?.value || '' + })); + if (!rows.length) return toast('当前页没有可暂存记录'); + await api('/api/admission/reporting/draft', { method: 'PUT', body: { examId: form.dataset.examId, rows } }); + toast('当前页已暂存', `${rows.length} 名考生的状态已保存,尚未正式提交`); renderRoute(); + } else if (kind === 'admission-reporting-scan') { + const body = formObject(form); + const result = await api('/api/admission/reporting/scan', { method: 'POST', body }); + toast('防伪二维码核验通过', `${result.row.name}已暂存为“已报到”`); renderRoute(); + } else if (kind === 'admission-reporting-decision') { + const body = formObject(form); + body.supplement = body.supplement === 'true'; + await api('/api/admission/reporting/decision', { method: 'POST', body }); + toast('学校决定已提交', '等待超级管理员审批;审批后报到统计将自动公开'); renderRoute(); } else if (kind === 'admission-plan' || kind === 'school-admission-plan') { const body = formObject(form); body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => { @@ -1422,6 +1464,39 @@ window.addEventListener('keydown', event => { form.requestSubmit(form.querySelector('[data-result-mode="draft"]')); } } + if (event.target.matches('[data-admission-reporting-file]')) { + const input = event.target; + const file = input.files?.[0]; + if (!file) return; + const examId = input.dataset.examId; + input.value = ''; + (async () => { + try { + toast('正在导入报到状态', `${file.name} · 导入结果只会暂存`); + const result = await api(`/api/admission/reporting/import?examId=${encodeURIComponent(examId)}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() }); + toast('Excel 已导入并暂存', `已更新 ${result.count} 名考生,提交前仍可修改`); + renderRoute(); + } catch (error) { toast('Excel 导入失败', error.message); } + })(); + } + if (event.target.matches('[data-reporting-qr-file]')) { + const input = event.target; + const file = input.files?.[0]; + if (!file) return; + input.value = ''; + (async () => { + try { + if (!('BarcodeDetector' in window)) throw new Error('当前浏览器不支持图片二维码识别,请粘贴二维码中的核验链接'); + const detector = new BarcodeDetector({ formats: ['qr_code'] }); + const codes = await detector.detect(file); + const code = codes[0]?.rawValue || ''; + if (!code) throw new Error('图片中没有识别到二维码'); + const result = await api('/api/admission/reporting/scan', { method: 'POST', body: { code } }); + toast('二维码核验通过', `${result.row.name}已暂存为“已报到”`); + renderRoute(); + } catch (error) { toast('二维码识别失败', error.message); } + })(); + } }); try { diff --git a/excel.mjs b/excel.mjs index 585fcf2..1d88396 100644 --- a/excel.mjs +++ b/excel.mjs @@ -124,6 +124,17 @@ const resourceSpecs = { ], numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'], numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' } + }, + admission_reporting: { + title: '录取考生报到状态维护表', sheet: '考生报到', + columns: [ + ['noticeNumber', '录取通知书编号*', 34, 'AD01-EX-2026-ZK-000001'], + ['candidateNumber', '报名号*', 26, '2026-HZ01-F-0001'], ['name', '姓名(只读)', 14, '张同学'], + ['examCode', '考试代码(只读)', 20, 'EX-2026-ZK'], ['schoolCode', '招生学校代码(只读)', 18, 'AD01'], + ['categoryName', '录取类别(只读)', 20, '普通生'], + ['reportingStatusCode', '报到状态码*(Y/N/P)', 22, 'P'], ['reportingNote', '报到备注', 36, ''] + ], + validations: { reportingStatusCode: ['Y', 'N', 'P'] } } }; @@ -200,7 +211,7 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub header.alignment = { vertical: 'middle', horizontal: 'center' }; const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : []; for (const item of outputRows) { - const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, item[key] ?? '']))); + const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, resource === 'admission_reporting' && key === 'reportingNote' && !item[key] ? null : item[key] ?? '']))); row.height = 23; row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } }; row.alignment = { vertical: 'middle' }; @@ -208,6 +219,14 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } }; }); } + if (resource === 'admission_reporting') { + const statusColumn = spec.columns.findIndex(([key]) => key === 'reportingStatusCode') + 1; + const noteColumn = spec.columns.findIndex(([key]) => key === 'reportingNote') + 1; + for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) { + for (const column of [statusColumn, noteColumn]) sheet.getCell(row, column).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF3CD' } }; + } + sheet.getCell('A1').value = `${spec.title}|仅修改黄色列;Y=已报到,N=未报到,P=待确认`; + } sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } }; for (const [key, values] of Object.entries(spec.validations || {})) { const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1; diff --git a/index.html b/index.html index a325095..88ddd8f 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@