feat(audit-ui): 重新应用前端审计详情弹窗渲染逻辑,支持高级对比结构
This commit is contained in:
@ -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>
|
|
||||||
|
<!-- 情况1:UPDATE - 变更对比表 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 情况2:DELETE - 删除快照 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 情况3:CREATE - 新增详情 -->
|
||||||
|
<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>
|
||||||
Reference in New Issue
Block a user