🔀 合并认证审核页面分支到 master
新增认证审核-司机/车辆页(按字段级 diff 对比待审值与生效值)、 审核列表待审核/已通过/已驳回页签、司机审核补手机号、 车辆表单补所有人与人车辆识别代号,并移除旧认证审核入口。
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 认证审核通用工具
|
||||
*
|
||||
* 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。
|
||||
* 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。
|
||||
* 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。
|
||||
*
|
||||
* 本文件提供三件事:
|
||||
* 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: '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: 'qualificationNo', label: '从业资格证号' },
|
||||
{ key: 'qualificationUrl', label: '从业资格证照片', type: TYPE_IMAGE },
|
||||
{ key: 'qualificationValidEnd', label: '从业资格证有效期止', type: TYPE_DATE },
|
||||
],
|
||||
},
|
||||
],
|
||||
/*
|
||||
* ⚠️ 车辆字段名与 PersonCertSubmitRequest 不同:这一组曾经整组用着改造前的旧键名
|
||||
* (licenseNo / licenseValidEnd / roadTransportNo …),而快照早已改名成 drivingLicense* /
|
||||
* roadTransportCert*。键名对不上**不报错**——每个字段都算「没有值」,
|
||||
* 于是车辆审核详情里行驶证与道路运输证两组全显示「—」,真值全落到未翻译的「其他字段」组,
|
||||
* 审核员既看不到内容、也 diff 不出变更。字段名一律以 VehicleCertSubmitRequest 为准。
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/** 比较用的归一化取值:展示相同即视为未变更 */
|
||||
const canonicalOf = (snapshot, field) => {
|
||||
const value = snapshot ? snapshot[field.key] : undefined;
|
||||
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 = snapshot ? snapshot[field.key] : undefined;
|
||||
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: effectiveSnapshot ? effectiveSnapshot[field.key] : undefined,
|
||||
text: textOf(effectiveSnapshot, target),
|
||||
},
|
||||
pending: {
|
||||
raw: pendingSnapshot ? pendingSnapshot[field.key] : undefined,
|
||||
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);
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user