From 411cf6892c9d205dba1d6565ae37949b50a6fac4 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 10:59:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=8A=A5=E5=88=B0=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E9=A1=B5=E5=AE=8C=E5=96=84=EF=BC=9A=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8B=BE=E9=80=89=E3=80=81=E5=85=A8=E9=80=89=E6=9C=AC?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E6=89=B9=E9=87=8F=E8=AE=BE=E7=BD=AE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=92=8C=E7=BB=9F=E4=B8=80=E5=A4=87=E6=B3=A8=EF=BC=9B?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=90=8E=E7=82=B9=E5=87=BB=E2=80=9C=E6=9A=82?= =?UTF-8?q?=E5=AD=98=E5=BD=93=E5=89=8D=E9=A1=B5=E2=80=9D=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=80=82=20=E4=BF=AE=E5=A4=8D=20Excel=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E4=BA=8B=E4=BB=B6=E8=A2=AB=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=94=BE=E5=9C=A8=E9=94=AE=E7=9B=98=E7=9B=91=E5=90=AC=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=82=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=90=8E=E6=98=BE=E7=A4=BA=E8=AF=BB=E5=8F=96=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E3=80=81=E5=AE=9E=E9=99=85=E6=9B=B4=E6=96=B0=E4=BA=BA=E6=95=B0?= =?UTF-8?q?=E3=80=81=E6=9C=AA=E5=8F=98=E5=8C=96=E4=BA=BA=E6=95=B0=E5=8F=8A?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=98=E5=8C=96=E6=98=8E=E7=BB=86=EF=BC=9B?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=AF=BC=E5=85=A5=E4=BC=9A=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E2=80=9C=E6=B2=A1=E6=9C=89=E4=BA=A7=E7=94=9F?= =?UTF-8?q?=E5=8F=98=E5=8C=96=E2=80=9D=E3=80=82=20=E2=80=9C=E6=8B=8D?= =?UTF-8?q?=E6=91=84=E4=BA=8C=E7=BB=B4=E7=A0=81=E2=80=9D=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=B0=83=E7=94=A8=20getUserMedia=20=E5=AE=9E=E6=97=B6=E7=9B=B8?= =?UTF-8?q?=E6=9C=BA=EF=BC=8C=E5=9B=BE=E7=89=87=E9=80=89=E6=8B=A9=E4=BB=85?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E5=A4=87=E7=94=A8=E6=96=B9=E5=BC=8F=E3=80=82?= =?UTF-8?q?=20=E6=89=AB=E7=A0=81=E5=90=8E=E5=85=88=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=80=83=E7=94=9F=E7=A1=AE=E8=AE=A4=E9=A1=B5=EF=BC=8C=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=80=89=E6=8B=A9=E2=80=9C=E7=A1=AE=E8=AE=A4=E6=8A=A5?= =?UTF-8?q?=E5=88=B0=E2=80=9D=EF=BC=8C=E7=82=B9=E5=87=BB=E2=80=9C=E6=9A=82?= =?UTF-8?q?=E5=AD=98=E2=80=9D=E5=90=8E=E6=89=8D=E4=BF=AE=E6=94=B9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E3=80=82=20=E5=90=8E=E7=AB=AF=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E9=A2=84=E8=A7=88=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E8=BF=87=E7=A8=8B=E4=B8=8D=E4=BC=9A=E6=8F=90?= =?UTF-8?q?=E5=89=8D=E4=BF=AE=E6=94=B9=E6=8A=A5=E5=88=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 126 ++++++++++++++++++++++++++++++-- src/client/admission-views.mjs | 8 +- src/client/state.mjs | 1 + src/routes/admission.routes.mjs | 58 +++++++++++---- styles.css | 62 ++++++++++++++-- tests/system.test.mjs | 16 +++- 6 files changed, 237 insertions(+), 34 deletions(-) diff --git a/app.js b/app.js index ddebbb3..9d2c8fc 100644 --- a/app.js +++ b/app.js @@ -17,6 +17,9 @@ let toastTimer; let noticeEditor; let ckeditorModulePromise; let tableSearchTimer; +let reportingCameraStream; +let reportingCameraFrame; +let reportingCameraToken = 0; function toast(title, message = '') { const element = document.querySelector('#toast'); element.querySelector('strong').textContent = title; @@ -38,7 +41,16 @@ function setReviewSubpage(content) { setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30); } +function stopReportingCamera() { + reportingCameraToken += 1; + if (reportingCameraFrame) cancelAnimationFrame(reportingCameraFrame); + reportingCameraFrame = null; + reportingCameraStream?.getTracks().forEach(track => track.stop()); + reportingCameraStream = null; +} + function closeModal() { + stopReportingCamera(); const editor = noticeEditor; noticeEditor = null; if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error)); @@ -46,6 +58,61 @@ function closeModal() { modalRoot.innerHTML = ''; } +function reportingScanConfirmation(preview, rawCode) { + stopReportingCamera(); + const row = preview.row; + setModal(`
核验通过
考生姓名
${h(row.name)}
报名号
${h(row.candidateNumber)}
通知书编号
${h(row.noticeNumber)}
录取类别
${h(row.categoryName || '—')}
报到确认结果
`); +} + +async function previewReportingScan(code, examId) { + const preview = await api('/api/admission/reporting/scan-preview', { method: 'POST', body: { code, examId } }); + reportingScanConfirmation(preview, code); + return preview; +} + +async function openReportingCamera(examId) { + stopReportingCamera(); + const token = reportingCameraToken; + setModal(`
正在等待二维码进入画面

正在请求相机权限…

`); + const video = modalRoot.querySelector('[data-reporting-camera]'); + const status = modalRoot.querySelector('[data-reporting-camera-status]'); + try { + if (!navigator.mediaDevices?.getUserMedia) throw new Error('当前浏览器未提供相机访问能力'); + reportingCameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); + if (token !== reportingCameraToken) { reportingCameraStream.getTracks().forEach(track => track.stop()); return; } + video.srcObject = reportingCameraStream; + await video.play(); + if (!('BarcodeDetector' in window)) throw new Error('相机已打开,但当前浏览器不支持自动识别二维码,请使用下方防伪码核验'); + const detector = new BarcodeDetector({ formats: ['qr_code'] }); + status.textContent = '相机已开启,请将二维码对准取景框'; + let detecting = false; + const scan = async () => { + if (token !== reportingCameraToken || !reportingCameraStream) return; + if (!detecting && video.readyState >= 2) { + detecting = true; + try { + const codes = await detector.detect(video); + const code = codes[0]?.rawValue; + if (code) { + status.textContent = '已识别二维码,正在核验…'; + await previewReportingScan(code, examId); + return; + } + } catch (error) { + if (token !== reportingCameraToken) return; + status.textContent = error.message || '二维码核验失败,请重新对准取景框'; + } finally { detecting = false; } + } + reportingCameraFrame = requestAnimationFrame(scan); + }; + scan(); + } catch (error) { + if (token !== reportingCameraToken) return; + status.textContent = `无法使用相机:${error.message}。可检查权限,或使用下方备用方式。`; + status.classList.add('error'); + } +} + function loadCKEditor() { if (!document.querySelector('link[data-ckeditor-styles]')) { const stylesheet = document.createElement('link'); @@ -409,6 +476,25 @@ document.addEventListener('click', async event => { window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`; return; } + if (action === 'open-reporting-camera') { + await openReportingCamera(target.dataset.examId); + return; + } + if (action === 'bulk-reporting-apply') { + const toolbar = target.closest('[data-reporting-bulk]'); + const form = toolbar?.closest('form[data-form="admission-reporting-draft"]'); + const table = document.getElementById(toolbar?.dataset.tableId || ''); + const selected = [...(table?.querySelectorAll('[data-reporting-select]:checked') || [])]; + if (!selected.length) return toast('请先选择考生', '可勾选单人或全选当前页'); + const status = toolbar.querySelector('[data-reporting-bulk-status]').value; + const note = toolbar.querySelector('[data-reporting-bulk-note]').value.trim(); + selected.forEach(input => { + form.querySelector(`[data-reporting-status][data-placement-id="${CSS.escape(input.value)}"]`).value = status; + if (note) form.querySelector(`[data-reporting-note][data-placement-id="${CSS.escape(input.value)}"]`).value = note; + }); + toast('批量修改已应用', `已修改 ${selected.length} 名考生,请点击“暂存当前页”保存`); + return; + } if (action === 'submit-admission-reporting') { if (!window.confirm('确认提交本轮全部报到情况?提交后需先完成补录决定,才能交由超级管理员审批。')) return; await api('/api/admission/reporting/submit', { method: 'POST', body: { examId: target.dataset.examId } }); @@ -791,6 +877,25 @@ document.addEventListener('change', event => { }); updatePlacementSelection(); } + if (event.target.matches('[data-reporting-select]')) { + const toolbar = event.target.closest('form')?.querySelector('[data-reporting-bulk]'); + const table = event.target.closest('table'); + const selected = table ? table.querySelectorAll('[data-reporting-select]:checked').length : 0; + const total = table ? table.querySelectorAll('[data-reporting-select]').length : 0; + const count = toolbar?.querySelector('[data-reporting-selected-count]'); + const selectAll = toolbar?.querySelector('[data-reporting-select-all]'); + if (count) count.textContent = `已选 ${selected} 人`; + if (selectAll) { selectAll.checked = total > 0 && selected === total; selectAll.indeterminate = selected > 0 && selected < total; } + } + if (event.target.matches('[data-reporting-select-all]')) { + const table = document.getElementById(event.target.dataset.tableId); + table?.querySelectorAll('[data-reporting-select]').forEach(input => { input.checked = event.target.checked; }); + const count = event.target.closest('[data-reporting-bulk]')?.querySelector('[data-reporting-selected-count]'); + if (count) count.textContent = `已选 ${event.target.checked ? table?.querySelectorAll('[data-reporting-select]').length || 0 : 0} 人`; + } + if (event.target.matches('.reporting-decision-options input[type="radio"]')) { + event.target.closest('fieldset').querySelectorAll('label').forEach(label => label.classList.toggle('selected', label.contains(event.target))); + } if (event.target.matches('[data-action="result-bulk-exam"]')) { state.resultExamFilter = event.target.value; state.resultSubjectFilter = ''; @@ -1047,10 +1152,15 @@ document.addEventListener('submit', async event => { 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') { + } else if (kind === 'admission-reporting-scan-preview') { + const body = formObject(form); + await previewReportingScan(body.code, body.examId); + } else if (kind === 'admission-reporting-scan-confirm') { const body = formObject(form); const result = await api('/api/admission/reporting/scan', { method: 'POST', body }); - toast('防伪二维码核验通过', `${result.row.name}已暂存为“已报到”`); renderRoute(); + closeModal(); + const label = body.status === 'reported' ? '已报到' : body.status === 'not_reported' ? '未报到' : '待确认'; + toast('扫码结果已暂存', `${result.row.name} · ${label},尚未正式提交`); renderRoute(); } else if (kind === 'admission-reporting-decision') { const body = formObject(form); body.supplement = body.supplement === 'true'; @@ -1464,6 +1574,9 @@ window.addEventListener('keydown', event => { form.requestSubmit(form.querySelector('[data-result-mode="draft"]')); } } +}); + +document.addEventListener('change', event => { if (event.target.matches('[data-admission-reporting-file]')) { const input = event.target; const file = input.files?.[0]; @@ -1474,8 +1587,9 @@ window.addEventListener('keydown', event => { 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(); + state.reportingImportSummaries[examId] = result; + toast(result.changedCount ? 'Excel 已导入并暂存' : 'Excel 已读取,状态没有变化', result.changedCount ? `读取 ${result.count} 行,实际更新 ${result.changedCount} 人,${result.unchangedCount} 人未变化` : `读取 ${result.count} 行,内容与当前暂存状态一致`); + await renderRoute(); } catch (error) { toast('Excel 导入失败', error.message); } })(); } @@ -1491,9 +1605,7 @@ window.addEventListener('keydown', event => { 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(); + await previewReportingScan(code, input.dataset.examId || ''); } catch (error) { toast('二维码识别失败', error.message); } })(); } diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs index 6a7e758..e5876b2 100644 --- a/src/client/admission-views.mjs +++ b/src/client/admission-views.mjs @@ -32,11 +32,13 @@ export function createAdmissionViews(context) { const key = `reporting-${batch.exam.id}-${batch.round}`; const page = paged(batch.rows, key, 20); const editable = ['draft', 'rejected'].includes(batch.status); - const rowHtml = page.items.map(item => `${h(item.name)}${h(item.candidateNumber)}${h(item.noticeNumber)}${h(item.categoryName)}`).join(''); + const importSummary = state.reportingImportSummaries?.[batch.exam.id]; + const rowHtml = page.items.map(item => `${editable ? `` : ''}${h(item.name)}${h(item.candidateNumber)}${h(item.noticeNumber)}${h(item.categoryName)}`).join(''); const actions = editable ? `
` : batch.status === 'submitted' ? `
报到情况已提交

请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。

` : `
${h(statusLabels[batch.status] || batch.status)}

${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}

`; - const ledger = `
${rowHtml || ''}
考生通知书 / 类别报到状态码备注
本轮没有正式录取考生
${pagination(page)}`; + const bulkTools = editable ? `
已选 0 人
` : ''; + const ledger = `
${bulkTools}
${editable ? '' : ''}${rowHtml || ``}
选择考生通知书 / 类别报到状态码备注
本轮没有正式录取考生
${pagination(page)}`; const ledgerBlock = editable ? `
${ledger}${actions}
` : `
${ledger}
${actions}`; - return `
${h(batch.exam.code)} · 第 ${h(batch.round)} 轮

${h(batch.exam.name)}

计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。

${h(batch.progress.reportingRate)}%计划报到完成率
正式录取 ${h(batch.progress.finalCount)}已报到 ${h(batch.progress.reportedCount)}未报到 ${h(batch.progress.notReportedCount)}计划缺额 ${h(batch.progress.reportingGap)}${h(statusLabels[batch.status] || batch.status)}
${editable ? `
Excel 批量维护黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。
` : ''}${ledgerBlock}
`; + return `
${h(batch.exam.code)} · 第 ${h(batch.round)} 轮

${h(batch.exam.name)}

计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。

${h(batch.progress.reportingRate)}%计划报到完成率
正式录取 ${h(batch.progress.finalCount)}已报到 ${h(batch.progress.reportedCount)}未报到 ${h(batch.progress.notReportedCount)}计划缺额 ${h(batch.progress.reportingGap)}${h(statusLabels[batch.status] || batch.status)}
${editable ? `
Excel 批量维护黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。
${importSummary ? `
${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)} 人` : '最近导入没有产生变化'}读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行${importSummary.changes?.length ? `${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}:${h(item.fromCode)} → ${h(item.toCode)}`).join(';')}` : 'Excel 内容与当前暂存状态一致。'}
` : ''}
通知书二维码核验打开实时相机扫描;识别后先核对考生,再点击暂存。
` : ''}${ledgerBlock}
`; }).join(''); } diff --git a/src/client/state.mjs b/src/client/state.mjs index 36c54a0..d66f51a 100644 --- a/src/client/state.mjs +++ b/src/client/state.mjs @@ -13,6 +13,7 @@ export const state = { resultSubjectFilter: '', resultExamCatalog: null, resultImportPreview: null, + reportingImportSummaries: {}, tablePages: {}, tableFilters: {}, loading: false diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs index 13e46dd..06e3d6c 100644 --- a/src/routes/admission.routes.mjs +++ b/src/routes/admission.routes.mjs @@ -61,6 +61,18 @@ export function createAdmissionRoutes(context) { return { setting, record }; } + function reportingScanTarget(db, school, rawCode) { + const match = String(rawCode || '').toUpperCase().match(/AN-[A-F0-9]{24}/); + if (!match) return { error: [400, '未识别到有效的录取通知书防伪码'] }; + const code = match[0]; + const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {}))); + if (!placement) return { error: [404, '该二维码不属于本校有效录取通知书'] }; + const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id); + const { record } = editableReportingRecord(db, placement.examId, school.id); + if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return { error: [409, '该考生不在当前可维护的报到批次'] }; + return { code, placement, plan, record }; + } + async function handleAdmission(request, response, pathname) { if (!pathname.startsWith('/api/admission/')) return false; const user = await requireUser(request, response, 'admission_school'); @@ -154,6 +166,9 @@ export function createAdmissionRoutes(context) { const byCandidate = new Map(available.map(item => [item.candidateNumber, item])); const seen = new Set(); const updates = []; + const changes = []; + let unchangedCount = 0; + const importedAt = nowIso(); for (const item of imported) { const noticeNumber = cleanText(item.noticeNumber, 100); const candidateNumber = cleanText(item.candidateNumber, 100); @@ -163,13 +178,20 @@ export function createAdmissionRoutes(context) { const code = String(item.reportingStatusCode || '').trim().toUpperCase(); if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`); seen.add(target.placementId); - updates.push({ placementId: target.placementId, status: reportingStatusByCode[code], note: cleanText(item.reportingNote, 300), updatedAt: nowIso(), source: 'excel' }); + const status = reportingStatusByCode[code]; + const note = cleanText(item.reportingNote, 300); + if (target.status === status && target.note === note) { unchangedCount += 1; continue; } + updates.push({ placementId: target.placementId, status, note, updatedAt: importedAt, source: 'excel' }); + changes.push({ placementId: target.placementId, name: target.name, candidateNumber: target.candidateNumber, noticeNumber: target.noticeNumber, from: target.status, to: status, fromCode: target.statusCode, toCode: code, noteChanged: target.note !== note }); } const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item])); updates.forEach(item => merged.set(item.placementId, item)); - record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName }; - await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · ${updates.length} 人`)); - return sendJson(response, 200, { ok: true, count: updates.length, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) }); + if (updates.length) { + record.status = 'draft'; record.updatedAt = importedAt; record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName }; + await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · 实际更新 ${updates.length} 人`)); + } + const nextDb = updates.length ? { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) } : db; + return sendJson(response, 200, { ok: true, count: imported.length, changedCount: updates.length, unchangedCount, changes, batch: reportingBatch(nextDb, plan, record) }); } if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') { const body = await readJson(request); @@ -186,20 +208,28 @@ export function createAdmissionRoutes(context) { await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length} 人`)); return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) }); } + if (request.method === 'POST' && pathname === '/api/admission/reporting/scan-preview') { + const body = await readJson(request); + const target = reportingScanTarget(db, school, body.code); + if (target.error) return sendError(response, ...target.error); + const { code, placement, plan, record } = target; + if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次'); + const row = reportingRows(db, plan, record).find(item => item.placementId === placement.id); + return sendJson(response, 200, { ok: true, code, examId: placement.examId, row }); + } if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') { const body = await readJson(request); - const match = String(body.code || '').toUpperCase().match(/AN-[A-F0-9]{24}/); - if (!match) return sendError(response, 400, '未识别到有效的录取通知书防伪码'); - const code = match[0]; - const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {}))); - if (!placement) return sendError(response, 404, '该二维码不属于本校有效录取通知书'); - const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id); - const { record } = editableReportingRecord(db, placement.examId, school.id); - if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return sendError(response, 409, '该考生不在当前可维护的报到批次'); + const target = reportingScanTarget(db, school, body.code); + if (target.error) return sendError(response, ...target.error); + const { placement, plan, record } = target; + if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次'); + const status = cleanText(body.status, 30); + if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态'); const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item])); - merged.set(placement.id, { placementId: placement.id, status: 'reported', note: cleanText(body.note, 300) || '扫描录取通知书二维码确认', updatedAt: nowIso(), source: 'qr_scan' }); + const fallbackNote = status === 'reported' ? '扫描录取通知书二维码确认报到' : status === 'not_reported' ? '扫描录取通知书二维码确认未报到' : '扫描录取通知书二维码后暂待确认'; + merged.set(placement.id, { placementId: placement.id, status, note: cleanText(body.note, 300) || fallbackNote, updatedAt: nowIso(), source: 'qr_scan' }); record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName }; - await database.saveAdmissionRecord(record, logAction(db, user, '扫码暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id}`)); + await database.saveAdmissionRecord(record, logAction(db, user, '扫码确认并暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id} · ${reportingCodeByStatus[status]}`)); const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }; return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) }); } diff --git a/styles.css b/styles.css index e859b55..8132cd2 100644 --- a/styles.css +++ b/styles.css @@ -968,21 +968,65 @@ body.review-subpage-open { overflow:hidden; } .reporting-stat-strip span { color:#667085; } .reporting-stat-strip b { margin-left:5px; color:#172d55; font-size:17px; } .reporting-stat-strip em { margin-left:auto; padding:5px 10px; border-radius:99px; color:#1f6f5f; background:#dfeee9; font-style:normal; font-weight:700; } -.reporting-tools { display:grid; grid-template-columns:1fr auto auto minmax(360px,1.3fr); gap:12px; align-items:center; margin:20px 24px 0; padding:17px; border:1px solid #ead9a5; border-radius:10px; background:#fffaf0; } -.reporting-tools > div { display:grid; gap:3px; } +.reporting-tools { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:stretch; margin:20px 24px 0; } +.reporting-excel-tool,.reporting-scan-tool { display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center; padding:17px; border:1px solid #ead9a5; border-radius:10px; background:#fffaf0; } +.reporting-scan-tool { border-color:#bcd8d0; background:#f3faf7; } +.reporting-tools > div > div:first-child { display:grid; gap:3px; } .reporting-tools small { color:#7d735e; } -.reporting-tools form { display:flex; gap:8px; } +.reporting-tools form { grid-column:1/-1; display:flex; gap:8px; } .reporting-tools form input { min-width:220px; } +.tool-buttons { display:flex; gap:8px; } +.reporting-import-summary { grid-column:1/-1; display:grid; grid-template-columns:1fr auto; gap:4px 14px; padding:10px 12px; border-left:3px solid #1f6f5f; border-radius:6px; color:#315d52; background:#e7f3ee; } +.reporting-import-summary.unchanged { border-left-color:#9a7a38; color:#6e5b33; background:#f8efd9; } +.reporting-import-summary small { grid-column:1/-1; } +.camera-button { min-height:42px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:0 16px; border:0; border-radius:8px; color:#fff; background:#1f6f5f; font-weight:700; cursor:pointer; box-shadow:0 8px 18px rgba(31,111,95,.2); } .qr-capture { min-height:38px; display:inline-flex; align-items:center; justify-content:center; padding:0 13px; border:1px dashed #1f6f5f; border-radius:8px; color:#1f6f5f; cursor:pointer; } .reporting-workbench > form,.reporting-ledger-readonly { padding:20px 24px 24px; } .reporting-workbench table select,.reporting-workbench table input { min-width:150px; } .reporting-workbench table input { width:100%; } +.reporting-workbench table .selection-cell { width:56px; min-width:56px; text-align:center; } +.reporting-workbench table .selection-cell input { width:17px; min-width:17px; height:17px; } +.reporting-bulk-bar { display:grid; grid-template-columns:auto auto minmax(180px,.7fr) minmax(260px,1fr) auto; gap:12px; align-items:end; margin:14px 0; padding:14px; border:1px solid #d5e1e5; border-radius:9px; background:#f7f9fb; } +.reporting-bulk-bar label:not(.bulk-check) { display:grid; gap:5px; } +.reporting-bulk-bar label span { color:#667085; font-size:11px; } +.reporting-bulk-bar .bulk-check { display:flex; align-items:center; gap:7px; min-height:40px; } +.reporting-bulk-bar .bulk-check input { width:17px; height:17px; } +.reporting-bulk-bar > strong { min-height:40px; display:flex; align-items:center; color:#1f6f5f; white-space:nowrap; } .reporting-actions { display:flex; justify-content:flex-end; gap:10px; padding-top:18px; } .reporting-decision { display:grid; grid-template-columns:1.2fr .7fr 1fr auto; gap:14px; align-items:end; margin:0; padding:22px 24px; border-top:1px solid #e3e9ed; background:#f7faf9; } .reporting-decision label { display:grid; gap:6px; } .reporting-decision p { margin:4px 0 0; color:#6b7688; } .reporting-readonly-note { padding:20px 24px; border-top:1px solid #e3e9ed; background:#f7f9fb; } .reporting-readonly-note p { margin:5px 0 0; color:#6f7a8d; } +.reporting-camera-head,.reporting-confirm-head { border-bottom:1px solid #dce5e8; } +.reporting-camera-stage { position:relative; margin:22px 24px 10px; overflow:hidden; aspect-ratio:16/10; border-radius:13px; background:#0c1725; } +.reporting-camera-stage video { width:100%; height:100%; display:block; object-fit:cover; } +.scan-frame { position:absolute; inset:15% 25%; display:grid; place-items:end center; padding:14px; color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.48)); } +.scan-frame i { position:absolute; width:36px; height:36px; border-color:#f1d28a; border-style:solid; } +.scan-frame i:nth-child(1) { top:0; left:0; border-width:3px 0 0 3px; } +.scan-frame i:nth-child(2) { top:0; right:0; border-width:3px 3px 0 0; } +.scan-frame i:nth-child(3) { bottom:0; left:0; border-width:0 0 3px 3px; } +.scan-frame i:nth-child(4) { right:0; bottom:0; border-width:0 3px 3px 0; } +.scan-frame span { font-size:12px; } +.reporting-camera-status { margin:0 24px 14px; color:#315d52; } +.reporting-camera-status.error { color:#a0473f; } +.reporting-camera-fallback { display:grid; grid-template-columns:1fr auto; gap:10px; margin:0 24px 24px; padding-top:14px; border-top:1px solid #e1e7ea; } +.reporting-camera-fallback form { display:flex; gap:8px; } +.reporting-camera-fallback form input { flex:1; } +.reporting-confirm-form { padding:22px 24px 24px; } +.reporting-candidate-card { position:relative; padding:20px; border:1px solid #cfe1db; border-radius:11px; background:#f4faf7; } +.reporting-candidate-card dl { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin:0; padding-right:90px; } +.reporting-candidate-card dt { color:#748078; font-size:11px; } +.reporting-candidate-card dd { margin:4px 0 0; color:#173f3a; font-weight:700; } +.candidate-stamp { position:absolute; top:20px; right:18px; padding:7px 10px; border:2px solid #28735e; border-radius:5px; color:#28735e; font-weight:800; transform:rotate(-4deg); } +.reporting-decision-options { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:20px 0; padding:0; border:0; } +.reporting-decision-options legend { margin-bottom:9px; color:#29364b; font-weight:700; } +.reporting-decision-options label { display:flex; gap:10px; padding:14px; border:1px solid #d7e0e4; border-radius:9px; cursor:pointer; } +.reporting-decision-options label.selected { border-color:#1f6f5f; background:#edf7f3; box-shadow:inset 0 0 0 1px #1f6f5f; } +.reporting-decision-options input { margin-top:3px; } +.reporting-decision-options strong,.reporting-decision-options small { display:block; } +.reporting-decision-options small { margin-top:4px; color:#788393; } +.reporting-confirm-note { display:grid; gap:7px; } .workflow-hint { display:block; margin-top:12px; color:#7a8495; line-height:1.6; } .template-notice-number { margin:36px 0 28px; color:#6d655b; font:12px Consolas,monospace; text-align:right; } .template-qr-placeholder { position:absolute; right:52px; bottom:48px; width:88px; height:88px; display:grid; place-items:center; border:1px dashed var(--template-primary); color:var(--template-primary); font-size:10px; text-align:center; } @@ -994,9 +1038,9 @@ body.review-subpage-open { overflow:hidden; } @media (max-width: 1200px) { .notice-template-studio,.verification-hero { grid-template-columns: 1fr; } .notice-template-preview { position: static; } - .reporting-tools { grid-template-columns:1fr auto auto; } - .reporting-tools > div,.reporting-tools form { grid-column:1/-1; } - .reporting-tools form { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; } + .reporting-tools { grid-template-columns:1fr; } + .reporting-bulk-bar { grid-template-columns:auto auto 1fr; } + .reporting-bulk-bar .bulk-note { grid-column:1/3; } .reporting-decision { grid-template-columns:1fr 1fr; } } @media (max-width: 850px) { @@ -1017,5 +1061,11 @@ body.review-subpage-open { overflow:hidden; } .reporting-stat-strip em { margin-left:0; } .reporting-tools,.reporting-decision,.reporting-public-stats { grid-template-columns:1fr; } .reporting-tools form { grid-template-columns:1fr; } + .reporting-excel-tool,.reporting-scan-tool,.reporting-camera-fallback,.reporting-bulk-bar,.reporting-decision-options { grid-template-columns:1fr; } + .tool-buttons { align-items:stretch; flex-direction:column; } + .reporting-bulk-bar .bulk-note { grid-column:auto; } + .reporting-camera-fallback form { display:grid; grid-template-columns:1fr; } + .reporting-candidate-card dl { grid-template-columns:1fr; padding-right:0; } + .candidate-stamp { position:static; display:inline-block; margin-bottom:16px; } .reporting-actions { align-items:stretch; flex-direction:column; } } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 18caef3..fedf03c 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -698,15 +698,23 @@ try { finalizedInspector.close(); const finalizedPlacement = { id: finalizedPlacementRow.id, kind: finalizedPlacementRow.kind, examId: finalizedPlacementRow.exam_id, userId: finalizedPlacementRow.user_id, schoolId: finalizedPlacementRow.school_id, status: finalizedPlacementRow.status, payload: JSON.parse(finalizedPlacementRow.payload_json), createdAt: finalizedPlacementRow.created_at, updatedAt: finalizedPlacementRow.updated_at }; const noticeCode = admissionNoticeCode('development-document-verification-secret', finalizedPlacement, exam); - const scannedReporting = await admissionSchoolClient.request('/api/admission/reporting/scan', { method: 'POST', body: { code: `http://127.0.0.1/#verify/${noticeCode}` } }); - assert.equal(scannedReporting.response.status, 200, '招生学校应能扫描通知书防伪二维码并暂存报到'); - assert.equal(scannedReporting.data.row.status, 'reported', '扫码核验通过后应暂存为已报到而不是直接提交'); + const reportingPreview = await admissionSchoolClient.request('/api/admission/reporting/scan-preview', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}` } }); + assert.equal(reportingPreview.response.status, 200, '扫描通知书二维码后应先返回考生确认信息'); + assert.equal(reportingPreview.data.row.status, 'pending', '扫码预览不得提前修改报到状态'); + const scannedReporting = await admissionSchoolClient.request('/api/admission/reporting/scan', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}`, status: 'reported' } }); + assert.equal(scannedReporting.response.status, 200, '确认页点击暂存后应能保存扫码报到结果'); + assert.equal(scannedReporting.data.row.status, 'reported', '确认页默认选择应能暂存为已报到而不是直接提交'); const secondRow = reportingBatch.rows[1]; const importReportingFile = Buffer.from(await buildWorkbook('admission_reporting', [{ noticeNumber: secondRow.noticeNumber, candidateNumber: secondRow.candidateNumber, name: secondRow.name, examCode: exam.code, schoolCode: admissionOnlySchool.data.school.code, categoryName: secondRow.categoryName, reportingStatusCode: 'N', reportingNote: '逾期未报到' }])); const importedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile }); assert.equal(importedReporting.response.status, 200, '招生学校应能导入修改后的报到 Excel 并暂存'); - assert.equal(importedReporting.data.count, 1, 'Excel 导入应返回实际暂存人数'); + assert.equal(importedReporting.data.count, 1, 'Excel 导入应返回读取行数'); + assert.equal(importedReporting.data.changedCount, 1, 'Excel 导入应明确返回实际变化人数'); + assert.equal(importedReporting.data.changes[0].toCode, 'N', 'Excel 导入变化摘要应说明修改后的状态码'); + const unchangedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile }); + assert.equal(unchangedReporting.data.changedCount, 0, '重复导入相同 Excel 时应明确提示没有变化'); + assert.equal(unchangedReporting.data.unchangedCount, 1, '重复导入相同 Excel 时应返回未变化行数'); assert.equal((await admissionSchoolClient.request('/api/admission/reporting/submit', { method: 'POST', body: { examId: exam.id } })).response.status, 200, '全部状态确认后招生学校应能提交报到情况'); assert.equal((await admissionSchoolClient.request('/api/admission/reporting/decision', { method: 'POST', body: { examId: exam.id, supplement: true, decisionNote: '一名考生未报到,申请补录缺额' } })).response.status, 200, '招生学校应能根据实时完成率提交补录决定'); const reportingApprovalLedger = await admin.request('/api/admin/admissions');