完成报到确认页完善:

增加勾选、全选本页、批量设置状态和统一备注;应用后点击“暂存当前页”保存。
修复 Excel 文件选择事件被错误放在键盘监听中的问题。
导入后显示读取行数、实际更新人数、未变化人数及状态变化明细;重复导入会明确提示“没有产生变化”。
“拍摄二维码”改为调用 getUserMedia 实时相机,图片选择仅作为备用方式。
扫码后先展示考生确认页,默认选择“确认报到”,点击“暂存”后才修改数据。
后端新增扫码预览接口,预览过程不会提前修改报到状态。
This commit is contained in:
biss committed 2026-07-22 10:59:33 +08:00
1 parent 3e1bced990
commit 411cf6892c
6 files changed
+237 -34

No files matched your search

+119 -7
View File
@@ -17,6 +17,9 @@ let toastTimer;
let noticeEditor; let noticeEditor;
let ckeditorModulePromise; let ckeditorModulePromise;
let tableSearchTimer; let tableSearchTimer;
let reportingCameraStream;
let reportingCameraFrame;
let reportingCameraToken = 0;
function toast(title, message = '') { function toast(title, message = '') {
const element = document.querySelector('#toast'); const element = document.querySelector('#toast');
element.querySelector('strong').textContent = title; element.querySelector('strong').textContent = title;
@@ -38,7 +41,16 @@ function setReviewSubpage(content) {
setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30); 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() { function closeModal() {
stopReportingCamera();
const editor = noticeEditor; const editor = noticeEditor;
noticeEditor = null; noticeEditor = null;
if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error)); if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error));
@@ -46,6 +58,61 @@ function closeModal() {
modalRoot.innerHTML = ''; modalRoot.innerHTML = '';
} }
function reportingScanConfirmation(preview, rawCode) {
stopReportingCamera();
const row = preview.row;
setModal(`<div class="modal-head reporting-confirm-head"><div><span>NOTICE VERIFICATION</span><h2>确认考生报到</h2><p>二维码已通过防伪核验。请核对考生信息,选择结论后暂存。</p></div><button type="button" data-action="close-modal">×</button></div><form class="reporting-confirm-form" data-form="admission-reporting-scan-confirm"><input type="hidden" name="examId" value="${h(preview.examId)}"><input type="hidden" name="code" value="${h(rawCode)}"><section class="reporting-candidate-card"><div class="candidate-stamp">核验通过</div><dl><div><dt>考生姓名</dt><dd>${h(row.name)}</dd></div><div><dt>报名号</dt><dd class="mono">${h(row.candidateNumber)}</dd></div><div><dt>通知书编号</dt><dd class="mono">${h(row.noticeNumber)}</dd></div><div><dt>录取类别</dt><dd>${h(row.categoryName || '—')}</dd></div></dl></section><fieldset class="reporting-decision-options"><legend>报到确认结果</legend><label class="selected"><input type="radio" name="status" value="reported" checked><span><strong>确认报到</strong><small>默认选项,暂存为 Y · 已报到</small></span></label><label><input type="radio" name="status" value="not_reported"><span><strong>确认未报到</strong><small>暂存为 N · 未报到</small></span></label><label><input type="radio" name="status" value="pending"><span><strong>暂待确认</strong><small>暂存为 P · 待确认</small></span></label></fieldset><label class="reporting-confirm-note"><span>报到备注</span><input name="note" value="${h(row.note || '')}" placeholder="选填,系统会为扫码操作生成默认备注"></label><div class="modal-foot"><button type="button" class="ghost-button" data-action="open-reporting-camera" data-exam-id="${h(preview.examId)}">重新扫描</button><button type="submit" class="solid-button">暂存</button></div></form>`);
}
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(`<div class="modal-head reporting-camera-head"><div><span>LIVE CAMERA</span><h2>扫描录取通知书二维码</h2><p>请将二维码放入取景框,识别后会进入报到确认页,不会直接保存。</p></div><button type="button" data-action="close-modal">×</button></div><section class="reporting-camera-stage"><video data-reporting-camera autoplay muted playsinline></video><div class="scan-frame"><i></i><i></i><i></i><i></i><span>正在等待二维码进入画面</span></div></section><p class="reporting-camera-status" data-reporting-camera-status>正在请求相机权限…</p><div class="reporting-camera-fallback"><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(examId)}"><input name="code" placeholder="无法使用相机时,可粘贴二维码链接或 AN 防伪码" required><button class="ghost-button" type="submit">核验</button></form><label class="qr-capture">选择二维码图片<input type="file" accept="image/*" data-reporting-qr-file data-exam-id="${h(examId)}" hidden></label></div>`);
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() { function loadCKEditor() {
if (!document.querySelector('link[data-ckeditor-styles]')) { if (!document.querySelector('link[data-ckeditor-styles]')) {
const stylesheet = document.createElement('link'); 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)}`; window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`;
return; 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 (action === 'submit-admission-reporting') {
if (!window.confirm('确认提交本轮全部报到情况?提交后需先完成补录决定,才能交由超级管理员审批。')) return; if (!window.confirm('确认提交本轮全部报到情况?提交后需先完成补录决定,才能交由超级管理员审批。')) return;
await api('/api/admission/reporting/submit', { method: 'POST', body: { examId: target.dataset.examId } }); await api('/api/admission/reporting/submit', { method: 'POST', body: { examId: target.dataset.examId } });
@@ -791,6 +877,25 @@ document.addEventListener('change', event => {
}); });
updatePlacementSelection(); 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"]')) { if (event.target.matches('[data-action="result-bulk-exam"]')) {
state.resultExamFilter = event.target.value; state.resultExamFilter = event.target.value;
state.resultSubjectFilter = ''; state.resultSubjectFilter = '';
@@ -1047,10 +1152,15 @@ document.addEventListener('submit', async event => {
if (!rows.length) return toast('当前页没有可暂存记录'); if (!rows.length) return toast('当前页没有可暂存记录');
await api('/api/admission/reporting/draft', { method: 'PUT', body: { examId: form.dataset.examId, rows } }); await api('/api/admission/reporting/draft', { method: 'PUT', body: { examId: form.dataset.examId, rows } });
toast('当前页已暂存', `${rows.length} 名考生的状态已保存,尚未正式提交`); renderRoute(); 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 body = formObject(form);
const result = await api('/api/admission/reporting/scan', { method: 'POST', body }); 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') { } else if (kind === 'admission-reporting-decision') {
const body = formObject(form); const body = formObject(form);
body.supplement = body.supplement === 'true'; body.supplement = body.supplement === 'true';
@@ -1464,6 +1574,9 @@ window.addEventListener('keydown', event => {
form.requestSubmit(form.querySelector('[data-result-mode="draft"]')); form.requestSubmit(form.querySelector('[data-result-mode="draft"]'));
} }
} }
});
document.addEventListener('change', event => {
if (event.target.matches('[data-admission-reporting-file]')) { if (event.target.matches('[data-admission-reporting-file]')) {
const input = event.target; const input = event.target;
const file = input.files?.[0]; const file = input.files?.[0];
@@ -1474,8 +1587,9 @@ window.addEventListener('keydown', event => {
try { try {
toast('正在导入报到状态', `${file.name} · 导入结果只会暂存`); 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() }); 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} 名考生,提交前仍可修改`); state.reportingImportSummaries[examId] = result;
renderRoute(); toast(result.changedCount ? 'Excel 已导入并暂存' : 'Excel 已读取,状态没有变化', result.changedCount ? `读取 ${result.count} 行,实际更新 ${result.changedCount} 人,${result.unchangedCount} 人未变化` : `读取 ${result.count} 行,内容与当前暂存状态一致`);
await renderRoute();
} catch (error) { toast('Excel 导入失败', error.message); } } catch (error) { toast('Excel 导入失败', error.message); }
})(); })();
} }
@@ -1491,9 +1605,7 @@ window.addEventListener('keydown', event => {
const codes = await detector.detect(file); const codes = await detector.detect(file);
const code = codes[0]?.rawValue || ''; const code = codes[0]?.rawValue || '';
if (!code) throw new Error('图片中没有识别到二维码'); if (!code) throw new Error('图片中没有识别到二维码');
const result = await api('/api/admission/reporting/scan', { method: 'POST', body: { code } }); await previewReportingScan(code, input.dataset.examId || '');
toast('二维码核验通过', `${result.row.name}已暂存为“已报到”`);
renderRoute();
} catch (error) { toast('二维码识别失败', error.message); } } catch (error) { toast('二维码识别失败', error.message); }
})(); })();
} }
+5 -3
View File
@@ -32,11 +32,13 @@ export function createAdmissionViews(context) {
const key = `reporting-${batch.exam.id}-${batch.round}`; const key = `reporting-${batch.exam.id}-${batch.round}`;
const page = paged(batch.rows, key, 20); const page = paged(batch.rows, key, 20);
const editable = ['draft', 'rejected'].includes(batch.status); const editable = ['draft', 'rejected'].includes(batch.status);
const rowHtml = page.items.map(item => `<tr><td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join(''); const importSummary = state.reportingImportSummaries?.[batch.exam.id];
const rowHtml = page.items.map(item => `<tr>${editable ? `<td class="selection-cell"><input type="checkbox" data-reporting-select value="${h(item.placementId)}" aria-label="选择 ${h(item.name)}"></td>` : ''}<td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join('');
const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`; const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`;
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || '<tr><td colspan="4" class="empty-state">本轮没有正式录取考生</td></tr>'}</tbody></table></div>${pagination(page)}`; const bulkTools = editable ? `<div class="reporting-bulk-bar" data-reporting-bulk data-table-id="${h(key)}"><label class="bulk-check"><input type="checkbox" data-reporting-select-all data-table-id="${h(key)}"><span>全选本页</span></label><strong data-reporting-selected-count>已选 0 人</strong><label><span>统一状态</span><select data-reporting-bulk-status><option value="reported">Y · 确认报到</option><option value="not_reported">N · 确认未报到</option><option value="pending">P · 待确认</option></select></label><label class="bulk-note"><span>统一备注(留空则保留原备注)</span><input data-reporting-bulk-note placeholder="例如:现场核验通过"></label><button type="button" class="ghost-button" data-action="bulk-reporting-apply">应用到所选</button></div>` : '';
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div>${bulkTools}<div class="table-scroll"><table id="${h(key)}"><thead><tr>${editable ? '<th class="selection-cell">选择</th>' : ''}<th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || `<tr><td colspan="${editable ? '5' : '4'}" class="empty-state">本轮没有正式录取考生</td></tr>`}</tbody></table></div>${pagination(page)}`;
const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`; const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`;
return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label><form data-form="admission-reporting-scan"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="粘贴 AN 防伪码或二维码核验链接" required><button class="ghost-button" type="submit">扫码结果暂存为已报到</button><label class="qr-capture">拍摄二维码<input type="file" accept="image/*" capture="environment" data-reporting-qr-file hidden></label></form></section>` : ''}${ledgerBlock}</section>`; return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div class="reporting-excel-tool"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><div class="tool-buttons"><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label></div>${importSummary ? `<div class="reporting-import-summary ${importSummary.changedCount ? 'changed' : 'unchanged'}"><strong>${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)} 人` : '最近导入没有产生变化'}</strong><span>读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行</span>${importSummary.changes?.length ? `<small>${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}:${h(item.fromCode)} → ${h(item.toCode)}`).join(';')}</small>` : '<small>Excel 内容与当前暂存状态一致。</small>'}</div>` : ''}</div><div class="reporting-scan-tool"><div><strong>通知书二维码核验</strong><small>打开实时相机扫描;识别后先核对考生,再点击暂存。</small></div><button type="button" class="camera-button" data-action="open-reporting-camera" data-exam-id="${h(batch.exam.id)}">${icons.camera || ''}<span>打开相机扫码</span></button><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="也可粘贴 AN 防伪码或二维码链接" required><button class="ghost-button" type="submit">核验</button></form></div></section>` : ''}${ledgerBlock}</section>`;
}).join(''); }).join('');
} }
+1
View File
@@ -13,6 +13,7 @@ export const state = {
resultSubjectFilter: '', resultSubjectFilter: '',
resultExamCatalog: null, resultExamCatalog: null,
resultImportPreview: null, resultImportPreview: null,
reportingImportSummaries: {},
tablePages: {}, tablePages: {},
tableFilters: {}, tableFilters: {},
loading: false loading: false
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more