/** * 认证审核通用工具 * * 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。 * 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。 * 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。 * * 本文件提供三件事: * 1. 审核状态文案与标签(与后端 statusText 同一套词表); * 2. 快照字段的展示元数据(中文名、控件类型、分组); * 3. 两侧快照的字段级 diff 计算,含「从未认证通过、无对比基准」的处理。 * * 两侧快照字段名完全一致(同一套结构),故可逐键比较。 */ const EMPTY_TEXT = '—'; const TYPE_IMAGE = 'image'; const TYPE_DATE = 'date'; const TYPE_LONG_TERM = 'longTerm'; /** 审核状态取值 */ export const CERT_RECORD_STATUS = { PENDING: 0, APPROVED: 1, REJECTED: 2, VOIDED: 3, }; /** 状态文案统一为:审核中 / 已通过 / 已驳回 / 已作废 */ const CERT_STATUS_TAG = { 0: { label: '审核中', type: 'warning' }, 1: { label: '已通过', type: 'success' }, 2: { label: '已驳回', type: 'danger' }, 3: { label: '已作废', type: 'info' }, }; /** 变更类型 → 差异标签 */ export const CERT_CHANGE_TAG = { added: { label: '新增', type: 'success' }, modified: { label: '已修改', type: 'warning' }, cleared: { label: '已清空', type: 'danger' }, }; /** * 「最新提交状态」展示缓存 → 标签 * * 缓存为空表示该对象从未提交过认证(即没有生效快照)。 * 注意它只是最近一次提交的状态,不等于「是否已认证」——后者由生效快照指针决定。 */ export const certStatusTag = status => { if (status === null || status === undefined || status === '') { return { label: '未认证', type: 'info' }; } return CERT_STATUS_TAG[status] || { label: EMPTY_TEXT, type: 'info' }; }; const pad = value => String(value).padStart(2, '0'); /** 后端若改用时间戳毫秒序列化日期,也能落成日期文案 */ const EPOCH_PATTERN = /^\d{13}$/; const isEpochText = text => EPOCH_PATTERN.test(text); const toDate = text => { const date = new Date(Number(text)); return Number.isNaN(date.getTime()) ? null : date; }; /** 提交时间等日期时间展示,兼容 `yyyy-MM-dd HH:mm:ss`、ISO 串与时间戳毫秒 */ export const formatCertDateTime = value => { if (value === null || value === undefined || value === '') return EMPTY_TEXT; const text = String(value).trim(); const matched = text.match( /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/ ); if (matched) { return ( `${matched[1]}-${pad(matched[2])}-${pad(matched[3])} ` + `${pad(matched[4])}:${pad(matched[5])}:${pad(matched[6] || 0)}` ); } const date = isEpochText(text) ? toDate(text) : null; if (!date) return text; return ( `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` ); }; /** * 快照字段元数据 * * 字段名与后端提交 DTO 的 JSON 键一致(司机 PersonCertSubmitRequest / 车辆 VehicleCertSubmitRequest), * 两侧快照共用同一套结构,故同时用于标签与比较规则。 * * `longTermKey` 只挂在**被长期有效作废掉的那个字段**上,也就是各组的「有效期止」与强制报废日期: * 勾了长期有效,它们显示「长期有效」、比较时按同一个值算(避免每次审核都报「已变更」)。 * 「有效期起」不挂:起期是一个独立的日期,长期有效并不会让它失效, * 挂上去等于把它自己的值藏起来、还让审核员看不到起期的改动。 */ const CERT_SNAPSHOT_GROUPS = { driver: [ { title: '身份信息', fields: [ { key: 'realName', label: '真实姓名' }, { key: 'idCardNo', label: '身份证号' }, { key: 'birthday', label: '出生年月', type: TYPE_DATE }, { key: 'addressRegion', label: '住址' }, { key: 'address', label: '详细地址' }, { key: 'idCardFrontUrl', label: '身份证正面照', type: TYPE_IMAGE }, { key: 'idCardBackUrl', label: '身份证反面照', type: TYPE_IMAGE }, { key: 'idCardValidStart', label: '身份证有效期起', type: TYPE_DATE }, { key: 'idCardValidEnd', label: '身份证有效期止', type: TYPE_DATE, longTermKey: 'idCardLongTerm', }, { key: 'idCardLongTerm', label: '身份证长期有效', type: TYPE_LONG_TERM }, { key: 'headPhoto', label: '大头照', type: TYPE_IMAGE }, ], }, { title: '驾驶证信息', fields: [ { key: 'driverFileNo', label: '驾驶证档案编号' }, { key: 'driverClass', label: '准驾车型' }, { key: 'driverLicenseFrontUrl', label: '驾驶证主页照片', type: TYPE_IMAGE }, { key: 'driverLicenseBackUrl', label: '驾驶证副页照片', type: TYPE_IMAGE }, { key: 'driverValidStart', label: '驾驶证有效期起', type: TYPE_DATE }, { key: 'driverValidEnd', label: '驾驶证有效期止', type: TYPE_DATE, longTermKey: 'driverLongTerm', }, { key: 'driverLongTerm', label: '驾驶证长期有效', type: TYPE_LONG_TERM }, ], }, { title: '从业资格证信息', fields: [ { key: 'qualificationType', label: '从业资格认证类型' }, { key: 'qualificationNo', label: '从业资格证号' }, { key: 'qualificationFront', label: '从业资格证封面页', type: TYPE_IMAGE, legacyKeys: ['qualificationUrl'], }, { key: 'qualificationBack', label: '从业资格证内容页', type: TYPE_IMAGE }, { key: 'qualificationValidStart', label: '从业资格证有效期起', type: TYPE_DATE }, { key: 'qualificationValidEnd', label: '从业资格证有效期止', type: TYPE_DATE, longTermKey: 'qualificationLongTerm', }, { key: 'qualificationLongTerm', label: '从业资格证长期有效', type: TYPE_LONG_TERM }, ], }, ], /* * 车辆字段名以 VehicleCertSubmitRequest 为准,与 PersonCertSubmitRequest 不同。 * 键名对不上不报错——每个字段都算「没有值」,两组全显示「—」且 diff 不出变更。 */ vehicle: [ { title: '车辆基本信息', fields: [ { key: 'plateNo', label: '车牌号' }, { key: 'vehicleType', label: '车辆类型' }, { key: 'plateColor', label: '车牌颜色' }, { key: 'ownerName', label: '所有人' }, { key: 'vin', label: '车辆识别代号(车架号)' }, { key: 'outerLength', label: '外廓长度(mm)' }, { key: 'outerWidth', label: '外廓宽度(mm)' }, { key: 'outerHeight', label: '外廓高度(mm)' }, { key: 'approvedLoadKg', label: '核定载质量(KG)' }, { key: 'tractionMassKg', label: '准牵引总质量(KG)' }, { key: 'energyType', label: '能源类型' }, { key: 'businessRelation', label: '业务关系' }, ], }, { title: '行驶证信息', fields: [ { key: 'compulsoryScrapDate', label: '强制报废日期', type: TYPE_DATE, longTermKey: 'compulsoryScrapLongTerm' }, { key: 'compulsoryScrapLongTerm', label: '强制报废长期有效', type: TYPE_LONG_TERM }, { key: 'drivingLicenseNo', label: '行驶证档案编号' }, { key: 'drivingLicenseImage', label: '行驶证主页正面', type: TYPE_IMAGE }, { key: 'drivingLicenseMainBack', label: '行驶证主页反面', type: TYPE_IMAGE }, { key: 'drivingLicenseViceFront', label: '行驶证副页正面', type: TYPE_IMAGE }, { key: 'drivingLicenseViceBack', label: '行驶证副页反面', type: TYPE_IMAGE }, { key: 'drivingLicenseStartDate', label: '行驶证有效期起', type: TYPE_DATE }, { key: 'drivingLicenseEndDate', label: '行驶证有效期止', type: TYPE_DATE, longTermKey: 'drivingLicenseLongTerm', }, { key: 'drivingLicenseLongTerm', label: '行驶证长期有效', type: TYPE_LONG_TERM }, ], }, { title: '道路运输证信息', fields: [ { key: 'roadTransportCertNo', label: '道路运输证号' }, { key: 'roadTransportCertImage', label: '道路运输证照片', type: TYPE_IMAGE }, { key: 'roadTransportCertStartDate', label: '道路运输证有效期起', type: TYPE_DATE }, { key: 'roadTransportCertEndDate', label: '道路运输证有效期止', type: TYPE_DATE, longTermKey: 'roadTransportCertLongTerm', }, { key: 'roadTransportCertLongTerm', label: '道路运输证长期有效', type: TYPE_LONG_TERM }, ], }, { title: '机动车登记本信息', fields: [ { key: 'registrationNo', label: '机动车登记编号' }, { key: 'registrationImage', label: '机动车登记本照片', type: TYPE_IMAGE }, ], }, ], }; /** 提交元信息与主键不参与对比(两侧必相同,展示出来只是噪音) */ const IGNORED_KEYS = [ 'id', 'bizType', 'bizId', 'bizKey', 'certificationStatus', 'snapshotJson', 'pendingSnapshotJson', 'effectiveSnapshotJson', ]; /** * 解析快照 JSON * * 后端把 null 序列化为空串,故空串 / 'null' 都按「没有快照」处理,不算解析失败。 */ const readSnapshot = json => { if (json && typeof json === 'object') { return { value: Object.keys(json).length ? json : null, invalid: false }; } const text = String(json === null || json === undefined ? '' : json).trim(); if (!text || text === 'null') { return { value: null, invalid: false }; } try { const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object') { return { value: parsed, invalid: false }; } return { value: null, invalid: true }; } catch (error) { return { value: null, invalid: true }; } }; /** 解析快照 JSON,无快照或解析失败时返回 null */ export const parseSnapshot = json => readSnapshot(json).value; const isEmptyValue = value => value === null || value === undefined || value === ''; const isLongTermOn = value => value === 1 || value === '1' || value === true; /** 日期归一化到 yyyy-MM-dd,便于 `2026-01-01` 与 `2026-01-01T00:00:00` 判等 */ const normalizeDate = value => { if (isEmptyValue(value)) return ''; const text = String(value).trim(); const matched = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/); if (matched) return `${matched[1]}-${pad(matched[2])}-${pad(matched[3])}`; const date = isEpochText(text) ? toDate(text) : null; if (date) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } return text; }; /** * 取字段值,兼容字段改名前的旧键 * * 后端把某字段改名后,改名之前提交的记录里存的仍是旧键(例:从业资格证封面页 * 由 `qualificationUrl` 改为 `qualificationFront`)。只读新键会得到空, * 审核页就把已填的封面页显示成「—」,误导审核员以为对方没传。 * 新旧键都存在时以新键为准。 */ const readFieldValue = (snapshot, field) => { if (!snapshot) return undefined; const value = snapshot[field.key]; if (!isEmptyValue(value)) return value; const legacyKeys = field.legacyKeys || []; for (let i = 0; i < legacyKeys.length; i += 1) { if (!isEmptyValue(snapshot[legacyKeys[i]])) return snapshot[legacyKeys[i]]; } return value; }; /** 比较用的归一化取值:展示相同即视为未变更 */ const canonicalOf = (snapshot, field) => { const value = readFieldValue(snapshot, field); if (field.type === TYPE_LONG_TERM) { if (isEmptyValue(value)) return ''; return isLongTermOn(value) ? '1' : '0'; } if (field.type === TYPE_DATE) { if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) { return '长期有效'; } return normalizeDate(value); } return isEmptyValue(value) ? '' : String(value).trim(); }; /** 展示文案:日期型受「长期有效」影响,图片型返回 URL 由组件渲染缩略图 */ const textOf = (snapshot, field) => { const value = readFieldValue(snapshot, field); if (field.type === TYPE_IMAGE) { return isEmptyValue(value) ? '' : String(value); } if (field.type === TYPE_LONG_TERM) { if (isEmptyValue(value)) return EMPTY_TEXT; return isLongTermOn(value) ? '是' : '否'; } if (field.type === TYPE_DATE) { if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) { return '长期有效'; } return normalizeDate(value) || EMPTY_TEXT; } return isEmptyValue(value) ? EMPTY_TEXT : String(value); }; const buildRow = (field, pendingSnapshot, effectiveSnapshot, hasBaseline) => { const type = field.type || 'text'; const target = { ...field, type }; const pendingCanonical = canonicalOf(pendingSnapshot, target); const effectiveCanonical = canonicalOf(effectiveSnapshot, target); let changeType = ''; if (hasBaseline && pendingCanonical !== effectiveCanonical) { if (!effectiveCanonical) { changeType = 'added'; } else if (!pendingCanonical) { changeType = 'cleared'; } else { changeType = 'modified'; } } return { key: field.key, label: field.label, type, effective: { raw: readFieldValue(effectiveSnapshot, target), text: textOf(effectiveSnapshot, target), }, pending: { raw: readFieldValue(pendingSnapshot, target), text: textOf(pendingSnapshot, target), }, changed: Boolean(changeType), changeType, }; }; const unionKeys = (pendingSnapshot, effectiveSnapshot) => { const keys = []; [pendingSnapshot, effectiveSnapshot].forEach(snapshot => { Object.keys(snapshot || {}).forEach(key => { if (!keys.includes(key)) keys.push(key); }); }); return keys; }; /** * 构造「待审快照 vs 当前生效值」的字段级 diff * * @param {string} bizType 认证对象类型:driver / vehicle * @param {string} pendingJson 本次待审快照 JSON * @param {string} effectiveJson 该对象当前生效快照 JSON,从未认证通过时为空 * @returns {Object} { hasBaseline, groups, totalCount, changedCount, ... } * hasBaseline 为 false 表示无对比基准(从未认证通过),此时不产出任何变更标记 */ export const buildSnapshotDiff = (bizType, pendingJson, effectiveJson) => { const pending = readSnapshot(pendingJson); const effective = readSnapshot(effectiveJson); const hasBaseline = effective.value !== null; const groupsConfig = CERT_SNAPSHOT_GROUPS[bizType] || []; const knownKeys = []; const groups = groupsConfig.map(group => { const rows = group.fields.map(field => { // 旧键已由所属字段并入展示,不能再作为独立字段出现,否则同一张封面页会出现两行 knownKeys.push(field.key, ...(field.legacyKeys || [])); return buildRow(field, pending.value, effective.value, hasBaseline); }); return { title: group.title, rows, changedCount: rows.filter(row => row.changed).length, }; }); // 元数据未登记的字段照样展示,避免后端新增字段时审核员看不到变化 const extraKeys = unionKeys(pending.value, effective.value).filter( key => !knownKeys.includes(key) && !IGNORED_KEYS.includes(key) ); if (extraKeys.length) { const rows = extraKeys.map(key => buildRow({ key, label: key }, pending.value, effective.value, hasBaseline) ); groups.push({ title: '其他字段', rows, changedCount: rows.filter(row => row.changed).length, }); } const rows = groups.reduce((all, group) => all.concat(group.rows), []); return { hasBaseline, pendingInvalid: pending.invalid, effectiveInvalid: effective.invalid, groups, totalCount: rows.length, changedCount: rows.filter(row => row.changed).length, }; };