✨ 新增认证审核-司机/车辆页,审核详情按字段级 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,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>