From 7c6ac2e5db334a8bf40e2d5327ea70a789971040 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Fri, 2 Oct 2026 17:15:03 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=AE=A1=E6=A0=B8-=E5=8F=B8=E6=9C=BA/=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E9=A1=B5=EF=BC=8C=E5=AE=A1=E6=A0=B8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=8C=89=E5=AD=97=E6=AE=B5=E7=BA=A7=20diff=20=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=BE=85=E5=AE=A1=E5=80=BC=E4=B8=8E=E7=94=9F=E6=95=88?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核对象由「司机行 / 车辆行」改为「一条认证记录」,故新增两个待审列表页 (挂在运力管理下,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 与权限点外完全相同,故抽出通用列表组件承接分页/搜索, 避免两页逻辑各自演化。 --- .../transportCapacity/certification-audit.js | 48 +++ .../transportCapacity/cert-audit-driver.js | 49 +++ .../transportCapacity/cert-audit-vehicle.js | 49 +++ src/utils/cert-audit.js | 393 ++++++++++++++++++ .../transportCapacity/cert-audit-driver.vue | 30 ++ .../transportCapacity/cert-audit-vehicle.vue | 31 ++ .../components/cert-audit-detail/main.vue | 279 +++++++++++++ .../components/cert-audit-diff/main.vue | 246 +++++++++++ .../components/cert-audit-list/main.vue | 144 +++++++ 9 files changed, 1269 insertions(+) create mode 100644 src/api/transportCapacity/certification-audit.js create mode 100644 src/option/transportCapacity/cert-audit-driver.js create mode 100644 src/option/transportCapacity/cert-audit-vehicle.js create mode 100644 src/utils/cert-audit.js create mode 100644 src/views/transportCapacity/cert-audit-driver.vue create mode 100644 src/views/transportCapacity/cert-audit-vehicle.vue create mode 100644 src/views/transportCapacity/components/cert-audit-detail/main.vue create mode 100644 src/views/transportCapacity/components/cert-audit-diff/main.vue create mode 100644 src/views/transportCapacity/components/cert-audit-list/main.vue diff --git a/src/api/transportCapacity/certification-audit.js b/src/api/transportCapacity/certification-audit.js new file mode 100644 index 0000000..931505b --- /dev/null +++ b/src/api/transportCapacity/certification-audit.js @@ -0,0 +1,48 @@ +import request from '@/axios'; + +/** + * 待审列表:只含「审核中」的认证记录,按提交时间倒序 + * 审核对象是**一条认证记录**,不是一辆车或一名司机,故必须带 bizType 区分 + */ +export const getPendingPage = (bizType, current, size, params) => { + return request({ + url: '/blade-transport/certification-audit/pending-page', + method: 'get', + params: { + ...params, + bizType, + current, + size, + }, + }); +}; + +/** + * 审核详情:同时返回本次待审快照与该对象当前生效快照,供前端做字段级对比 + * 从未认证通过时 effectiveSnapshotJson 为空串 + */ +export const getAuditDetail = id => { + return request({ + url: '/blade-transport/certification-audit/detail', + method: 'get', + params: { + id, + }, + }); +}; + +/** + * 审核一条认证记录 + * 通过时不传驳回原因;驳回时必须传,且原因不超过 200 字 + */ +export const auditCertification = (id, pass, rejectReason) => { + return request({ + url: '/blade-transport/certification-audit/audit', + method: 'post', + params: { + id, + pass, + ...(rejectReason ? { rejectReason } : {}), + }, + }); +}; diff --git a/src/option/transportCapacity/cert-audit-driver.js b/src/option/transportCapacity/cert-audit-driver.js new file mode 100644 index 0000000..ca64df4 --- /dev/null +++ b/src/option/transportCapacity/cert-audit-driver.js @@ -0,0 +1,49 @@ +export const option = { + height: 'auto', + calcHeight: 32, + tip: false, + searchShow: true, + searchMenuSpan: 24, + searchIcon: true, + searchIndex: 4, + searchMenuPosition: 'right', + border: true, + index: true, + indexLabel: '序号', + indexWidth: 70, + selection: false, + addBtn: false, + editBtn: false, + delBtn: false, + viewBtn: false, + columnBtn: false, + menuWidth: 110, + column: [ + { + label: '司机姓名', + prop: 'bizKey', + search: true, + searchOrder: 1, + minWidth: 140, + }, + { + label: '审核状态', + prop: 'statusText', + slot: true, + width: 110, + align: 'center', + }, + { + label: '提交人', + prop: 'submitUserName', + minWidth: 120, + }, + { + label: '提交时间', + prop: 'submitTime', + slot: true, + minWidth: 170, + align: 'center', + }, + ], +}; diff --git a/src/option/transportCapacity/cert-audit-vehicle.js b/src/option/transportCapacity/cert-audit-vehicle.js new file mode 100644 index 0000000..8111393 --- /dev/null +++ b/src/option/transportCapacity/cert-audit-vehicle.js @@ -0,0 +1,49 @@ +export const option = { + height: 'auto', + calcHeight: 32, + tip: false, + searchShow: true, + searchMenuSpan: 24, + searchIcon: true, + searchIndex: 4, + searchMenuPosition: 'right', + border: true, + index: true, + indexLabel: '序号', + indexWidth: 70, + selection: false, + addBtn: false, + editBtn: false, + delBtn: false, + viewBtn: false, + columnBtn: false, + menuWidth: 110, + column: [ + { + label: '车牌号', + prop: 'bizKey', + search: true, + searchOrder: 1, + minWidth: 140, + }, + { + label: '审核状态', + prop: 'statusText', + slot: true, + width: 110, + align: 'center', + }, + { + label: '提交人', + prop: 'submitUserName', + minWidth: 120, + }, + { + label: '提交时间', + prop: 'submitTime', + slot: true, + minWidth: 170, + align: 'center', + }, + ], +}; diff --git a/src/utils/cert-audit.js b/src/utils/cert-audit.js new file mode 100644 index 0000000..f248e1f --- /dev/null +++ b/src/utils/cert-audit.js @@ -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, + }; +}; diff --git a/src/views/transportCapacity/cert-audit-driver.vue b/src/views/transportCapacity/cert-audit-driver.vue new file mode 100644 index 0000000..c746b2b --- /dev/null +++ b/src/views/transportCapacity/cert-audit-driver.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/views/transportCapacity/cert-audit-vehicle.vue b/src/views/transportCapacity/cert-audit-vehicle.vue new file mode 100644 index 0000000..3c3786a --- /dev/null +++ b/src/views/transportCapacity/cert-audit-vehicle.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/views/transportCapacity/components/cert-audit-detail/main.vue b/src/views/transportCapacity/components/cert-audit-detail/main.vue new file mode 100644 index 0000000..825f665 --- /dev/null +++ b/src/views/transportCapacity/components/cert-audit-detail/main.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/src/views/transportCapacity/components/cert-audit-diff/main.vue b/src/views/transportCapacity/components/cert-audit-diff/main.vue new file mode 100644 index 0000000..1137418 --- /dev/null +++ b/src/views/transportCapacity/components/cert-audit-diff/main.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/views/transportCapacity/components/cert-audit-list/main.vue b/src/views/transportCapacity/components/cert-audit-list/main.vue new file mode 100644 index 0000000..3f95103 --- /dev/null +++ b/src/views/transportCapacity/components/cert-audit-list/main.vue @@ -0,0 +1,144 @@ + + +