✨ 新增认证审核-司机/车辆页,审核详情按字段级 diff 对比待审值与生效值
审核对象由「司机行 / 车辆行」改为「一条认证记录」,故新增两个待审列表页
(挂在运力管理下,path /transportCapacity/cert-audit-driver|vehicle,与页面
文件名一一对应)与审核详情弹窗:
- api/transportCapacity/certification-audit.js:pending-page / detail / audit 三个接口
- utils/cert-audit.js:快照字段元数据(中文名、控件类型、分组)与两侧快照的
字段级 diff 计算。两侧字段名一致(同一套快照结构),故逐键比较;日期归一化后
判等、长期有效联动展示、未登记字段兜底进「其他字段」
- components/cert-audit-diff:当前生效值与待审提交值并排展示,差异行高亮并标注
新增 / 已修改 / 已清空,支持切换只看变更项;从未认证通过(生效快照为空)时
显示「无对比基准」,不报错
- components/cert-audit-detail:审核详情弹窗(提交信息 + diff + 审核意见),
通过 / 驳回动作。驳回原因前端先拦一道(必填且不超过 200 字),通过时不提交原因;
已终态记录不可再审
- components/cert-audit-list + 两个菜单页:分页与按展示键(司机姓名 / 车牌号)筛选,
审核链接按 cert_certification_audit_{driver,vehicle}_audit 权限点控制
检索过的现有实现:driver.vue / vehicle.vue 的列表与弹窗写法(Avue option + search、
权限点 hasPermission 写法)、image-upload-field、section-card、utils/table-menu.js。
未找到可复用的快照或 diff 实现,故新增 utils/cert-audit.js 纯函数承担比对;
两个列表页除 bizType 与权限点外完全相同,故抽出通用列表组件承接分页/搜索,
避免两页逻辑各自演化。
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 认证审核通用工具
|
||||
*
|
||||
* 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。
|
||||
* 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。
|
||||
* 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。
|
||||
*
|
||||
* 本文件提供三件事:
|
||||
* 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),
|
||||
* 两侧快照共用同一套结构,故同时用于标签与比较规则。
|
||||
*/
|
||||
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,
|
||||
longTermKey: 'idCardLongTerm',
|
||||
},
|
||||
{
|
||||
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,
|
||||
longTermKey: 'driverLongTerm',
|
||||
},
|
||||
{
|
||||
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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
vehicle: [
|
||||
{
|
||||
title: '车辆基本信息',
|
||||
fields: [
|
||||
{ key: 'plateNo', label: '车牌号' },
|
||||
{ key: 'vehicleType', label: '车辆类型' },
|
||||
{ key: 'plateColor', label: '车牌颜色' },
|
||||
{ key: 'vin', label: '车辆识别代号(车架号)' },
|
||||
{ key: 'engineNo', label: '发动机号' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '行驶证信息',
|
||||
fields: [
|
||||
{ key: 'licenseNo', label: '行驶证档案编号' },
|
||||
{ key: 'licenseFrontUrl', label: '行驶证主页照片', type: TYPE_IMAGE },
|
||||
{ key: 'licenseBackUrl', label: '行驶证副页照片', type: TYPE_IMAGE },
|
||||
{
|
||||
key: 'licenseValidStart',
|
||||
label: '行驶证有效期起',
|
||||
type: TYPE_DATE,
|
||||
longTermKey: 'licenseLongTerm',
|
||||
},
|
||||
{
|
||||
key: 'licenseValidEnd',
|
||||
label: '行驶证有效期止',
|
||||
type: TYPE_DATE,
|
||||
longTermKey: 'licenseLongTerm',
|
||||
},
|
||||
{ key: 'licenseLongTerm', label: '行驶证长期有效', type: TYPE_LONG_TERM },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '道路运输证信息',
|
||||
fields: [
|
||||
{ key: 'roadTransportNo', label: '道路运输证号' },
|
||||
{ key: 'roadTransportUrl', label: '道路运输证照片', type: TYPE_IMAGE },
|
||||
{
|
||||
key: 'roadTransportValidStart',
|
||||
label: '道路运输证有效期起',
|
||||
type: TYPE_DATE,
|
||||
longTermKey: 'roadTransportLongTerm',
|
||||
},
|
||||
{
|
||||
key: 'roadTransportValidEnd',
|
||||
label: '道路运输证有效期止',
|
||||
type: TYPE_DATE,
|
||||
longTermKey: 'roadTransportLongTerm',
|
||||
},
|
||||
{ key: 'roadTransportLongTerm', label: '道路运输证长期有效', type: TYPE_LONG_TERM },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 提交元信息与主键不参与对比(两侧必相同,展示出来只是噪音) */
|
||||
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