完成报到确认页完善:
增加勾选、全选本页、批量设置状态和统一备注;应用后点击“暂存当前页”保存。 修复 Excel 文件选择事件被错误放在键盘监听中的问题。 导入后显示读取行数、实际更新人数、未变化人数及状态变化明细;重复导入会明确提示“没有产生变化”。 “拍摄二维码”改为调用 getUserMedia 实时相机,图片选择仅作为备用方式。 扫码后先展示考生确认页,默认选择“确认报到”,点击“暂存”后才修改数据。 后端新增扫码预览接口,预览过程不会提前修改报到状态。
This commit is contained in:
@@ -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); }
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ export function createAdmissionRoutes(context) {
|
|||||||
return { setting, record };
|
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) {
|
async function handleAdmission(request, response, pathname) {
|
||||||
if (!pathname.startsWith('/api/admission/')) return false;
|
if (!pathname.startsWith('/api/admission/')) return false;
|
||||||
const user = await requireUser(request, response, 'admission_school');
|
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 byCandidate = new Map(available.map(item => [item.candidateNumber, item]));
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
const changes = [];
|
||||||
|
let unchangedCount = 0;
|
||||||
|
const importedAt = nowIso();
|
||||||
for (const item of imported) {
|
for (const item of imported) {
|
||||||
const noticeNumber = cleanText(item.noticeNumber, 100);
|
const noticeNumber = cleanText(item.noticeNumber, 100);
|
||||||
const candidateNumber = cleanText(item.candidateNumber, 100);
|
const candidateNumber = cleanText(item.candidateNumber, 100);
|
||||||
@@ -163,13 +178,20 @@ export function createAdmissionRoutes(context) {
|
|||||||
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
|
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
|
||||||
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
|
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
|
||||||
seen.add(target.placementId);
|
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]));
|
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||||
updates.forEach(item => merged.set(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 };
|
if (updates.length) {
|
||||||
await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · ${updates.length} 人`));
|
record.status = 'draft'; record.updatedAt = importedAt; record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName };
|
||||||
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) });
|
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') {
|
if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
@@ -186,20 +208,28 @@ export function createAdmissionRoutes(context) {
|
|||||||
await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length} 人`));
|
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) });
|
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') {
|
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
|
||||||
const body = await readJson(request);
|
const body = await readJson(request);
|
||||||
const match = String(body.code || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
|
const target = reportingScanTarget(db, school, body.code);
|
||||||
if (!match) return sendError(response, 400, '未识别到有效的录取通知书防伪码');
|
if (target.error) return sendError(response, ...target.error);
|
||||||
const code = match[0];
|
const { placement, plan, record } = target;
|
||||||
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 (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||||
if (!placement) return sendError(response, 404, '该二维码不属于本校有效录取通知书');
|
const status = cleanText(body.status, 30);
|
||||||
const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id);
|
if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态');
|
||||||
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 merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
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 };
|
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) };
|
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) });
|
return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) });
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-6
@@ -968,21 +968,65 @@ body.review-subpage-open { overflow:hidden; }
|
|||||||
.reporting-stat-strip span { color:#667085; }
|
.reporting-stat-strip span { color:#667085; }
|
||||||
.reporting-stat-strip b { margin-left:5px; color:#172d55; font-size:17px; }
|
.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-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 { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:stretch; margin:20px 24px 0; }
|
||||||
.reporting-tools > div { display:grid; gap:3px; }
|
.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 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; }
|
.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; }
|
.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 > form,.reporting-ledger-readonly { padding:20px 24px 24px; }
|
||||||
.reporting-workbench table select,.reporting-workbench table input { min-width:150px; }
|
.reporting-workbench table select,.reporting-workbench table input { min-width:150px; }
|
||||||
.reporting-workbench table input { width:100%; }
|
.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-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 { 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 label { display:grid; gap:6px; }
|
||||||
.reporting-decision p { margin:4px 0 0; color:#6b7688; }
|
.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 { padding:20px 24px; border-top:1px solid #e3e9ed; background:#f7f9fb; }
|
||||||
.reporting-readonly-note p { margin:5px 0 0; color:#6f7a8d; }
|
.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; }
|
.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-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; }
|
.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) {
|
@media (max-width: 1200px) {
|
||||||
.notice-template-studio,.verification-hero { grid-template-columns: 1fr; }
|
.notice-template-studio,.verification-hero { grid-template-columns: 1fr; }
|
||||||
.notice-template-preview { position: static; }
|
.notice-template-preview { position: static; }
|
||||||
.reporting-tools { grid-template-columns:1fr auto auto; }
|
.reporting-tools { grid-template-columns:1fr; }
|
||||||
.reporting-tools > div,.reporting-tools form { grid-column:1/-1; }
|
.reporting-bulk-bar { grid-template-columns:auto auto 1fr; }
|
||||||
.reporting-tools form { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; }
|
.reporting-bulk-bar .bulk-note { grid-column:1/3; }
|
||||||
.reporting-decision { grid-template-columns:1fr 1fr; }
|
.reporting-decision { grid-template-columns:1fr 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 850px) {
|
||||||
@@ -1017,5 +1061,11 @@ body.review-subpage-open { overflow:hidden; }
|
|||||||
.reporting-stat-strip em { margin-left:0; }
|
.reporting-stat-strip em { margin-left:0; }
|
||||||
.reporting-tools,.reporting-decision,.reporting-public-stats { grid-template-columns:1fr; }
|
.reporting-tools,.reporting-decision,.reporting-public-stats { grid-template-columns:1fr; }
|
||||||
.reporting-tools form { 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; }
|
.reporting-actions { align-items:stretch; flex-direction:column; }
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -698,15 +698,23 @@ try {
|
|||||||
finalizedInspector.close();
|
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 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 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}` } });
|
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(scannedReporting.response.status, 200, '招生学校应能扫描通知书防伪二维码并暂存报到');
|
assert.equal(reportingPreview.response.status, 200, '扫描通知书二维码后应先返回考生确认信息');
|
||||||
assert.equal(scannedReporting.data.row.status, 'reported', '扫码核验通过后应暂存为已报到而不是直接提交');
|
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 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 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 });
|
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.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/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, '招生学校应能根据实时完成率提交补录决定');
|
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');
|
const reportingApprovalLedger = await admin.request('/api/admin/admissions');
|
||||||
|
|||||||
Reference in New Issue
Block a user