完成报到确认页完善:

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