6384e80758
- 住址只读态直接显示区划名称串,不再用级联反解(改名后会退化成灰字 placeholder) - 身份证号联动改为失焦触发,且只在出生年月/性别为空时补,不覆盖手填值 - 认证审核页标签改为「住址」「详细地址」 - 详细地址 placeholder 补明采集范围,空住址提示改为「请选择省/市/区」
438 lines
16 KiB
JavaScript
438 lines
16 KiB
JavaScript
/**
|
|
* 认证审核通用工具
|
|
*
|
|
* 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。
|
|
* 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。
|
|
* 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。
|
|
*
|
|
* 本文件提供三件事:
|
|
* 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,
|
|
};
|
|
};
|