feat(audit-ui): 重新应用前端审计详情弹窗渲染逻辑,支持高级对比结构

This commit is contained in:
DXC
2026-04-20 13:20:23 +08:00
parent 381d1fa675
commit 9a0982e76d

View File

@ -84,12 +84,15 @@
/> />
</el-card> </el-card>
<!-- 详情弹窗 --> <!-- 重写的详情弹窗支持三种高级结构 -->
<el-dialog v-model="detailDialogVisible" title="操作详情" width="700px" destroy-on-close> <el-dialog v-model="detailDialogVisible" title="操作详情" width="750px" destroy-on-close :close-on-click-modal="false">
<el-descriptions :column="2" border> <!-- 基本信息 -->
<el-descriptions :column="2" border class="base-info">
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item> <el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item> <el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>
<el-descriptions-item label="模块">{{ currentLog.module }}</el-descriptions-item> <el-descriptions-item label="模块">
<el-tag>{{ currentLog.module }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作类型"> <el-descriptions-item label="操作类型">
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag> <el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
@ -97,23 +100,92 @@
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item> <el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item> <el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item> <el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item>
<el-descriptions-item label="请求URL" :span="2">
<el-text size="small">{{ currentLog.url }}</el-text>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div v-if="currentLog.details" class="details-box"> <!-- 变更明细区域 -->
<div class="details-title">变更内容 (JSON)</div> <div class="details-section">
<pre class="json-content">{{ formatDetails(currentLog.details) }}</pre>
<!-- 情况1UPDATE - 变更对比表 -->
<div v-if="detailType === 'changes'" class="changes-box">
<div class="section-title">
<el-icon><EditPen /></el-icon>
字段变更详情 {{ changesList.length }} 处变更
</div> </div>
<el-table :data="changesList" border stripe size="small" max-height="350">
<el-table-column prop="field" label="字段名" width="150">
<template #default="{ row }">
<span class="field-name">{{ row.field }}</span>
</template>
</el-table-column>
<el-table-column label="修改前" min-width="200">
<template #default="{ row }">
<span class="old-value">{{ row.old ?? '空' }}</span>
</template>
</el-table-column>
<el-table-column label="修改后" min-width="200">
<template #default="{ row }">
<span class="new-value">{{ row.new ?? '空' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 情况2DELETE - 删除快照 -->
<div v-else-if="detailType === 'deleted_snapshot'" class="snapshot-box">
<div class="section-title">
<el-icon><Delete /></el-icon>
数据删除快照
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="(value, key) in deletedSnapshot"
:key="String(key)"
:label="String(key)"
:span="isLongValue(value) ? 2 : 1"
>
<span class="snapshot-value">{{ formatValue(value) }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 情况3CREATE - 新增详情 -->
<div v-else-if="detailType === 'created'" class="snapshot-box">
<div class="section-title">
<el-icon><Plus /></el-icon>
新增数据详情
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="(value, key) in createdData"
:key="String(key)"
:label="String(key)"
:span="isLongValue(value) ? 2 : 1"
>
<span class="snapshot-value">{{ formatValue(value) }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 兜底原始 JSON -->
<div v-else class="raw-json-box">
<div class="section-title">
<el-icon><Document /></el-icon>
原始数据
</div>
<pre class="raw-json">{{ rawJson }}</pre>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getAuditLogs, getAuditModules } from '@/api/audit' import { getAuditLogs, getAuditModules } from '@/api/audit'
// 表格数据 // 表格数据
@ -144,7 +216,71 @@ const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'im
const detailDialogVisible = ref(false) const detailDialogVisible = ref(false)
const currentLog = ref<any>({}) const currentLog = ref<any>({})
// 获取操作类型对应的标签样式 // ============================================================
// 详情解析逻辑
// ============================================================
// 解析 detail type
const detailType = computed(() => {
const details = currentLog.value.details
if (!details) return 'raw'
if (details.changes && typeof details.changes === 'object') return 'changes'
if (details.deleted_snapshot && typeof details.deleted_snapshot === 'object') return 'deleted_snapshot'
if (details.created && typeof details.created === 'object') return 'created'
return 'raw'
})
// 解析 changes 为表格数据
const changesList = computed(() => {
const details = currentLog.value.details
if (!details?.changes) return []
return Object.entries(details.changes).map(([field, values]: [string, any]) => ({
field,
old: values?.old,
new: values?.new
}))
})
// 解析 deleted_snapshot
const deletedSnapshot = computed(() => {
const details = currentLog.value.details
return details?.deleted_snapshot || {}
})
// 解析 created
const createdData = computed(() => {
const details = currentLog.value.details
return details?.created || {}
})
// 原始 JSON
const rawJson = computed(() => {
const details = currentLog.value.details
if (!details) return ''
return JSON.stringify(details, null, 2)
})
// 辅助函数:格式化值
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '-'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
// 辅助函数:判断是否是长值
const isLongValue = (value: any): boolean => {
if (value === null || value === undefined) return false
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
return str.length > 50
}
// ============================================================
// 其他方法
// ============================================================
const getActionType = (action: string) => { const getActionType = (action: string) => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
'create': 'success', 'create': 'success',
@ -158,11 +294,9 @@ const getActionType = (action: string) => {
return typeMap[action?.toLowerCase()] || 'info' return typeMap[action?.toLowerCase()] || 'info'
} }
// 加载数据
const getList = async () => { const getList = async () => {
tableLoading.value = true tableLoading.value = true
try { try {
// 处理日期范围
if (dateRange.value && dateRange.value.length === 2) { if (dateRange.value && dateRange.value.length === 2) {
queryParams.start_date = dateRange.value[0] queryParams.start_date = dateRange.value[0]
queryParams.end_date = dateRange.value[1] queryParams.end_date = dateRange.value[1]
@ -175,7 +309,6 @@ const getList = async () => {
if (res.code === 200) { if (res.code === 200) {
tableData.value = res.data.list tableData.value = res.data.list
total.value = res.data.total total.value = res.data.total
// 更新选项
if (res.data.modules) { if (res.data.modules) {
moduleOptions.value = res.data.modules moduleOptions.value = res.data.modules
} }
@ -190,13 +323,11 @@ const getList = async () => {
} }
} }
// 搜索
const handleQuery = () => { const handleQuery = () => {
queryParams.page = 1 queryParams.page = 1
getList() getList()
} }
// 重置
const handleReset = () => { const handleReset = () => {
queryParams.page = 1 queryParams.page = 1
queryParams.username = '' queryParams.username = ''
@ -209,29 +340,13 @@ const handleReset = () => {
getList() getList()
} }
// 查看详情
const handleViewDetails = (row: any) => { const handleViewDetails = (row: any) => {
currentLog.value = row currentLog.value = row
detailDialogVisible.value = true detailDialogVisible.value = true
} }
// 格式化详情 JSON
const formatDetails = (details: any) => {
if (!details) return ''
if (typeof details === 'string') {
try {
return JSON.stringify(JSON.parse(details), null, 2)
} catch {
return details
}
}
return JSON.stringify(details, null, 2)
}
// 初始化
onMounted(() => { onMounted(() => {
getList() getList()
// 获取可选模块
getAuditModules().then(res => { getAuditModules().then(res => {
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
moduleOptions.value = res.data moduleOptions.value = res.data
@ -251,26 +366,79 @@ onMounted(() => {
align-items: center; align-items: center;
} }
.text-gray { .base-info {
color: #999; margin-bottom: 16px;
} }
.details-box { .details-section {
margin-top: 20px; margin-top: 10px;
} }
.details-title { .section-title {
font-weight: bold; display: flex;
margin-bottom: 10px; align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
color: #303133;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
} }
.json-content { /* 变更表格样式 */
background-color: #f5f7fa; .changes-box {
padding: 15px; background: #fef0f0;
padding: 12px;
border-radius: 6px;
border: 1px solid #fbc4c4;
}
.field-name {
font-weight: 600;
color: #303133;
}
.old-value {
color: #f56c6c;
text-decoration: line-through;
}
.new-value {
color: #67c23a;
font-weight: 500;
}
/* 快照样式 */
.snapshot-box {
background: #f5f7fa;
padding: 12px;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
.snapshot-value {
word-break: break-all;
}
/* 原始 JSON */
.raw-json-box {
background: #f5f7fa;
padding: 12px;
border-radius: 6px;
}
.raw-json {
background: #2d2d2d;
color: #67c23a;
padding: 12px;
border-radius: 4px; border-radius: 4px;
max-height: 400px;
overflow: auto;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
max-height: 300px;
overflow: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
} }
</style> </style>