✨ 新增认证审核-司机/车辆页,审核详情按字段级 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:
2026-10-02 17:15:03 +08:00
parent 0b81001ce7
commit 7c6ac2e5db
9 changed files with 1269 additions and 0 deletions
@@ -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 } : {}),
},
});
};
@@ -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',
},
],
};
@@ -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',
},
],
};
+393
View File
@@ -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,
};
};
@@ -0,0 +1,30 @@
<template>
<cert-audit-list
:option="option"
biz-type="driver"
audit-permission-code="cert_certification_audit_driver_audit"
/>
</template>
<script>
import { option } from '@/option/transportCapacity/cert-audit-driver';
import CertAuditList from './components/cert-audit-list/main.vue';
/**
* 认证审核-司机
*
* 菜单挂在运力管理下(path: /transportCapacity/cert-audit-driver),
* 列表与审核详情由通用组件承接,本页只固定认证对象类型与审核权限点。
*/
export default {
name: 'CertAuditDriver',
components: {
CertAuditList,
},
data() {
return {
option,
};
},
};
</script>
@@ -0,0 +1,31 @@
<template>
<cert-audit-list
:option="option"
biz-type="vehicle"
audit-permission-code="cert_certification_audit_vehicle_audit"
/>
</template>
<script>
import { option } from '@/option/transportCapacity/cert-audit-vehicle';
import CertAuditList from './components/cert-audit-list/main.vue';
/**
* 认证审核-车辆
*
* 菜单挂在运力管理下(path: /transportCapacity/cert-audit-vehicle),
* 车辆认证审核已从「车辆管理」页迁到本页:审核对象由「车辆行」改为「认证记录」,
* 生效值与待审值分离后,原「看当前值直接通过/驳回」的模型不再成立。
*/
export default {
name: 'CertAuditVehicle',
components: {
CertAuditList,
},
data() {
return {
option,
};
},
};
</script>
@@ -0,0 +1,279 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="92%"
top="4vh"
append-to-body
class="cert-audit-detail-dialog"
@closed="handleClosed"
>
<div v-loading="loading" class="cert-audit-detail">
<el-alert
v-if="loadFailed"
type="error"
:closable="false"
show-icon
title="认证记录详情加载失败,请刷新后重试"
class="cert-audit-detail__notice"
/>
<section-card title="提交信息">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="认证对象">
{{ detail.bizKey || '-' }}
</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="statusTag.type" size="small">
{{ detail.statusText || statusTag.label }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="该对象当前是否已认证">
<el-tag :type="detail.certified ? 'success' : 'info'" size="small">
{{ detail.certified ? '已认证' : '未认证' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="提交人">
{{ detail.submitUserName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="提交时间">
{{ formatCertDateTime(detail.submitTime) }}
</el-descriptions-item>
<el-descriptions-item label="认证类型">
{{ bizTypeLabel }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card v-if="!loading && !loadFailed" title="待审内容 vs 当前生效值">
<cert-audit-diff
:key="diffKey"
:biz-type="bizType"
:pending-json="detail.pendingSnapshotJson"
:effective-json="detail.effectiveSnapshotJson"
/>
</section-card>
<section-card title="审核意见">
<el-form label-position="right" label-width="90px" class="cert-audit-detail__form">
<el-form-item label="驳回原因">
<el-input
v-model="rejectReason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
:disabled="!auditable"
placeholder="驳回时必填,不超过 200 字;通过时无需填写"
/>
</el-form-item>
</el-form>
</section-card>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="danger"
:disabled="!auditable || loadFailed"
:loading="rejectLoading"
@click="handleReject"
>
驳回
</el-button>
<el-button
type="primary"
:disabled="!auditable || loadFailed"
:loading="approveLoading"
@click="handleApprove"
>
通过
</el-button>
</template>
</el-dialog>
</template>
<script>
import { getAuditDetail, auditCertification } from '@/api/transportCapacity/certification-audit';
import { CERT_RECORD_STATUS, certStatusTag, formatCertDateTime } from '@/utils/cert-audit';
import CertAuditDiff from '../cert-audit-diff/main.vue';
export default {
name: 'CertAuditDetail',
components: {
CertAuditDiff,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
// 认证记录主键
recordId: {
type: [String, Number],
default: '',
},
// 认证对象类型:driver / vehicle
bizType: {
type: String,
default: 'driver',
},
},
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
loadFailed: false,
detail: {},
rejectReason: '',
approveLoading: false,
rejectLoading: false,
};
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
bizTypeLabel() {
return this.bizType === 'vehicle' ? '车辆认证' : '司机认证';
},
dialogTitle() {
const bizKey = this.detail.bizKey ? `:${this.detail.bizKey}` : '';
return `${this.bizTypeLabel}审核${bizKey}`;
},
statusTag() {
return certStatusTag(this.detail.status);
},
// 只有「审核中」的记录可审;已通过/已驳回/已作废都是终态
auditable() {
return String(this.detail.status) === String(CERT_RECORD_STATUS.PENDING);
},
diffKey() {
return `${this.detail.id || this.recordId || ''}-${this.detail.submitTime || ''}`;
},
},
watch: {
modelValue(value) {
if (value) {
this.resetState();
this.loadDetail();
}
},
},
methods: {
formatCertDateTime,
resetState() {
this.detail = {};
this.rejectReason = '';
this.loadFailed = false;
this.approveLoading = false;
this.rejectLoading = false;
},
loadDetail() {
if (!this.recordId) {
this.loadFailed = true;
return;
}
this.loading = true;
getAuditDetail(this.recordId)
.then(res => {
this.detail = res.data.data || {};
this.loadFailed = false;
})
.catch(() => {
this.detail = {};
this.loadFailed = true;
})
.finally(() => {
this.loading = false;
});
},
handleClosed() {
this.resetState();
},
// 通过:只提交是否通过,不带驳回原因
handleApprove() {
const bizKey = this.detail.bizKey || '该认证对象';
this.$confirm(`通过后本次提交将立即成为「${bizKey}」的当前生效值,是否确认通过?`, {
confirmButtonText: '确认通过',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
this.approveLoading = true;
return auditCertification(this.detail.id, true);
})
.then(() => {
this.$message.success('认证已通过');
this.visible = false;
this.$emit('success');
})
.catch(() => {})
.finally(() => {
this.approveLoading = false;
});
},
// 驳回:原因必填且不超过 200 字,前端先拦一道
handleReject() {
const rejectReason = String(this.rejectReason || '').trim();
if (!rejectReason) {
this.$message.warning('请输入驳回原因');
return;
}
if (rejectReason.length > 200) {
this.$message.warning('驳回原因不能超过 200 字');
return;
}
const bizKey = this.detail.bizKey || '该认证对象';
this.$confirm(
`驳回后本次提交的内容不会生效,「${bizKey}」的当前生效值保持不变,是否确认驳回?`,
{
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(() => {
this.rejectLoading = true;
return auditCertification(this.detail.id, false, rejectReason);
})
.then(() => {
this.$message.success('认证已驳回');
this.visible = false;
this.$emit('success');
})
.catch(() => {})
.finally(() => {
this.rejectLoading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.cert-audit-detail {
&__notice {
margin-bottom: 8px;
}
:deep(.section-card) {
margin-bottom: 8px;
}
:deep(.el-descriptions__label) {
width: 150px;
}
&__form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
}
</style>
@@ -0,0 +1,246 @@
<template>
<div class="cert-audit-diff">
<el-alert
v-if="diff.effectiveInvalid"
type="error"
:closable="false"
show-icon
title="当前生效快照内容无法解析,本次不展示对比结果"
class="cert-audit-diff__notice"
/>
<el-alert
v-else-if="!diff.hasBaseline"
type="warning"
:closable="false"
show-icon
title="该认证对象从未审核通过,没有生效快照可对比(无对比基准)"
class="cert-audit-diff__notice"
>
下方为本次提交的全部内容,审核通过后即成为该对象的当前生效值。
</el-alert>
<el-alert
v-else-if="diff.changedCount === 0"
type="success"
:closable="false"
show-icon
title="本次提交内容与当前生效值完全一致,没有任何字段发生变化"
class="cert-audit-diff__notice"
/>
<el-alert
v-else
type="info"
:closable="false"
show-icon
:title="`共 ${diff.changedCount} / ${diff.totalCount} 项内容与当前生效值不同,已在下方高亮标记`"
class="cert-audit-diff__notice"
/>
<div class="cert-audit-diff__toolbar">
<el-switch
v-model="onlyChanged"
:disabled="!diff.hasBaseline"
active-text="仅看变更项"
inactive-text="展示全部"
/>
</div>
<section-card
v-for="group in visibleGroups"
:key="group.title"
:title="group.title"
class="cert-audit-diff__group"
>
<template #extra>
<span v-if="diff.hasBaseline" class="cert-audit-diff__extra">
变更 {{ group.changedCount }} 项
</span>
</template>
<el-table
:data="group.rows"
border
size="small"
:row-class-name="rowClassName"
class="cert-audit-diff__table"
>
<el-table-column label="字段" prop="label" width="190" />
<el-table-column :label="effectiveColumnLabel" min-width="240">
<template #default="{ row }">
<el-image
v-if="diff.hasBaseline && row.type === 'image' && row.effective.raw"
:src="row.effective.raw"
:preview-src-list="[row.effective.raw]"
preview-teleported
:z-index="3000"
fit="contain"
class="cert-audit-diff__image"
/>
<span
v-else-if="diff.hasBaseline && row.type !== 'image'"
class="cert-audit-diff__value"
>
{{ row.effective.text }}
</span>
<span v-else class="cert-audit-diff__absent">—</span>
</template>
</el-table-column>
<el-table-column label="待审提交值" min-width="240">
<template #default="{ row }">
<el-image
v-if="row.type === 'image' && row.pending.raw"
:src="row.pending.raw"
:preview-src-list="[row.pending.raw]"
preview-teleported
:z-index="3000"
fit="contain"
class="cert-audit-diff__image"
/>
<span v-else-if="row.type === 'image'" class="cert-audit-diff__absent">—</span>
<span
v-else
class="cert-audit-diff__value"
:class="{ 'cert-audit-diff__value--changed': row.changed }"
>
{{ row.pending.text }}
</span>
</template>
</el-table-column>
<el-table-column label="差异" width="96" align="center">
<template #default="{ row }">
<el-tag v-if="row.changeType" :type="changeTag(row.changeType).type" size="small">
{{ changeTag(row.changeType).label }}
</el-tag>
<span v-else class="cert-audit-diff__absent">—</span>
</template>
</el-table-column>
</el-table>
</section-card>
<el-empty v-if="!visibleGroups.length" :description="emptyText" />
</div>
</template>
<script>
import { buildSnapshotDiff, CERT_CHANGE_TAG } from '@/utils/cert-audit';
export default {
name: 'CertAuditDiff',
props: {
// 认证对象类型:driver / vehicle
bizType: {
type: String,
default: 'driver',
},
// 本次待审快照 JSON
pendingJson: {
type: [String, Object],
default: '',
},
// 该对象当前生效快照 JSON,从未认证通过时为空
effectiveJson: {
type: [String, Object],
default: '',
},
},
data() {
return {
// 默认并排展示两侧全部字段(差异行高亮),审核员可切换为只看变更项
onlyChanged: false,
};
},
computed: {
diff() {
return buildSnapshotDiff(this.bizType, this.pendingJson, this.effectiveJson);
},
visibleGroups() {
// 无对比基准时不存在「变更项」,强制展示全部内容
const onlyChanged = this.onlyChanged && this.diff.hasBaseline;
return this.diff.groups
.map(group => ({
...group,
rows: onlyChanged ? group.rows.filter(row => row.changed) : group.rows,
}))
.filter(group => group.rows.length);
},
emptyText() {
if (this.diff.hasBaseline && this.onlyChanged && this.diff.changedCount === 0) {
return '本次提交与当前生效值完全一致,切换为「展示全部」可查看明细';
}
return '本次提交没有可对比的内容';
},
// 无对比基准时在列头直接写明,避免逐行重复「无对比基准」
effectiveColumnLabel() {
return this.diff.hasBaseline ? '当前生效值' : '当前生效值(无对比基准)';
},
},
watch: {
pendingJson() {
this.onlyChanged = false;
},
effectiveJson() {
this.onlyChanged = false;
},
},
methods: {
changeTag(changeType) {
return CERT_CHANGE_TAG[changeType] || { label: changeType, type: 'info' };
},
rowClassName({ row }) {
return row.changed ? 'cert-audit-diff__row--changed' : '';
},
},
};
</script>
<style lang="scss" scoped>
.cert-audit-diff {
&__notice {
margin-bottom: 8px;
}
&__toolbar {
display: flex;
justify-content: flex-end;
padding: 0 0 8px;
}
&__group {
margin-bottom: 8px;
}
&__extra {
color: #909399;
font-size: 12px;
}
&__value {
word-break: break-all;
&--changed {
color: #e6a23c;
font-weight: 600;
}
}
&__absent {
color: #c0c4cc;
}
&__image {
width: 56px;
height: 56px;
border: 1px solid #eff1f7;
border-radius: 4px;
cursor: pointer;
}
&__table {
:deep(.el-table__row.cert-audit-diff__row--changed) {
background-color: #fdf6ec;
}
:deep(.el-table__row.cert-audit-diff__row--changed td.el-table__cell) {
background-color: #fdf6ec;
}
}
}
</style>
@@ -0,0 +1,144 @@
<template>
<basic-container class="cert-audit-list-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
ref="crud"
@search-change="searchChange"
@search-reset="searchReset"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #statusText="{ row }">
<el-tag :type="certStatusTag(row.status).type" class="status-text">
{{ row.statusText || certStatusTag(row.status).label }}
</el-tag>
</template>
<template #submitTime="{ row }">
{{ formatCertDateTime(row.submitTime) }}
</template>
<template #menu="{ row }">
<el-link type="primary" v-if="canAudit" @click="openDetail(row)">审核</el-link>
</template>
</avue-crud>
<cert-audit-detail
v-model="detailVisible"
:record-id="detailRecordId"
:biz-type="bizType"
@success="handleAuditSuccess"
/>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import { getPendingPage } from '@/api/transportCapacity/certification-audit';
import { certStatusTag, formatCertDateTime } from '@/utils/cert-audit';
import { isAdministrator } from '@/utils/auth';
import CertAuditDetail from '../cert-audit-detail/main.vue';
/**
* 认证待审列表(司机 / 车辆共用)
*
* 审核对象是「一条认证记录」,只有审核中的记录会出现在这里;
* 列表只负责挑选记录,具体改了什么由审核详情弹窗做字段级对比。
*/
export default {
name: 'CertAuditList',
components: {
CertAuditDetail,
},
props: {
// Avue 列表配置,按认证对象类型分别放在 src/option/transportCapacity/ 下
option: {
type: Object,
required: true,
},
// 认证对象类型:driver / vehicle,由列表页固定传入
bizType: {
type: String,
required: true,
},
// 审核动作权限点,如 cert_certification_audit_driver_audit
auditPermissionCode: {
type: String,
required: true,
},
},
data() {
return {
loading: true,
data: [],
query: {},
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
detailVisible: false,
detailRecordId: '',
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
isAdmin() {
return isAdministrator(this.userInfo);
},
canAudit() {
return this.isAdmin || this.permission?.[this.auditPermissionCode] === true;
},
},
methods: {
certStatusTag,
formatCertDateTime,
onLoad(page, params = {}) {
this.loading = true;
getPendingPage(this.bizType, page.currentPage, page.pageSize, {
...this.query,
...params,
})
.then(res => {
const data = res.data.data || {};
this.page.total = data.total || 0;
this.data = data.records || [];
})
.finally(() => {
this.loading = false;
});
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page);
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
openDetail(row) {
this.detailRecordId = row.id;
this.detailVisible = true;
},
// 审核后该记录已是终态,重新拉取待审列表
handleAuditSuccess() {
this.onLoad(this.page, this.query);
},
},
};
</script>