454 lines
14 KiB
Vue
454 lines
14 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 操作审计日志(AuditLog)
|
||
*
|
||
* 迁移自 TempLate/GERP/pages/system/audit-log.html(287 行)
|
||
*
|
||
* 原生实现要点:
|
||
* - 仅管理员可访问(GERP.CheckAdmin),非管理员显示无权访问
|
||
* - 筛选条件:表名 / 操作类型 / 时间范围(CreateTime__gte / CreateTime__lte)
|
||
* - 分页查询接口 GET /API/FormAPI/Get/AuditLog,参数 page / page_size / ordering=-CreateTime / admin_verify=1
|
||
* - 表格列:时间、表名、操作(add/modify/delete 色彩标签)、目标UUID、IP地址、操作人、详情
|
||
* - 详情弹窗:基本信息 + OldData / NewData JSON 展示
|
||
*
|
||
* Vue 版改动:
|
||
* - server / admin_verify 由 request.ts 拦截器统一注入(不再手动拼接 buildApiUrl)
|
||
* - 权限校验改用 userStore.isAdmin 前端预拦截(后端仍会校验);非管理员显示无权访问占位
|
||
* - 表格 / 分页 / 弹窗改用 Element Plus(el-table / el-pagination / el-dialog)
|
||
* - 日期筛选改用 el-date-picker daterange,提交时拆分为 CreateTime__gte / CreateTime__lte
|
||
* - 响应格式兼容:后端可能返回数组或 { records, total },统一适配
|
||
*/
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { get } from '@/api/request'
|
||
import { setPageTitle } from '@/utils/serverScope'
|
||
import { useUserStore } from '@/stores/user'
|
||
import AdminBadge from '@/components/AdminBadge.vue'
|
||
|
||
setPageTitle('操作审计日志')
|
||
|
||
const userStore = useUserStore()
|
||
/** 是否管理员(前端预拦截,后端仍校验) */
|
||
const isAdmin = computed(() => userStore.isAdmin)
|
||
/** 无权访问标记:非管理员进入时置 true */
|
||
const denied = ref(false)
|
||
|
||
/** 审计日志记录(对齐后端字段,PascalCase) */
|
||
interface AuditLogRow {
|
||
AuditLogUUID?: string
|
||
CreateTime?: string
|
||
TableName?: string
|
||
Action?: string
|
||
TargetUUID?: string
|
||
IPAddress?: string
|
||
UserUUID?: string
|
||
UserUUID_label?: string
|
||
OldData?: string | Record<string, unknown> | null
|
||
NewData?: string | Record<string, unknown> | null
|
||
[k: string]: unknown
|
||
}
|
||
|
||
/** 表名筛选选项(对齐原生固定列表) */
|
||
const tableOptions = ['User', 'Role', 'Permission', 'Tenant', 'Company']
|
||
/** 操作类型筛选选项 */
|
||
const actionOptions = [
|
||
{ value: 'add', label: '新增' },
|
||
{ value: 'modify', label: '修改' },
|
||
{ value: 'delete', label: '删除' },
|
||
]
|
||
|
||
/** 筛选条件 */
|
||
const filterTable = ref('')
|
||
const filterAction = ref('')
|
||
/** 日期范围 [开始, 结束],el-date-picker daterange 绑定值 */
|
||
const filterDateRange = ref<[string, string] | null>(null)
|
||
|
||
/** 分页状态 */
|
||
const currentPage = ref(1)
|
||
const pageSize = 20
|
||
const total = ref(0)
|
||
|
||
/** 表格数据与加载态 */
|
||
const logList = ref<AuditLogRow[]>([])
|
||
const loading = ref(false)
|
||
/** 错误信息(非空时展示错误占位) */
|
||
const errorMsg = ref('')
|
||
|
||
/** 详情弹窗 */
|
||
const detailVisible = ref(false)
|
||
const detailRow = ref<AuditLogRow | null>(null)
|
||
|
||
/** 操作类型 → 显示文本 */
|
||
function actionLabel(action?: string): string {
|
||
const map: Record<string, string> = { add: '新增', modify: '修改', delete: '删除' }
|
||
return (action && map[action]) || action || '-'
|
||
}
|
||
|
||
/** 操作类型 → el-tag 类型 */
|
||
function actionTagType(action?: string): 'success' | 'warning' | 'danger' | 'info' {
|
||
const map: Record<string, 'success' | 'warning' | 'danger'> = {
|
||
add: 'success',
|
||
modify: 'warning',
|
||
delete: 'danger',
|
||
}
|
||
return (action && map[action]) || 'info'
|
||
}
|
||
|
||
/** 时间格式化:去掉 T,截取到秒 */
|
||
function formatTime(t?: string): string {
|
||
if (!t) return '-'
|
||
return String(t).replace('T', ' ').substring(0, 19)
|
||
}
|
||
|
||
/** 安全 JSON 解析:字符串解析失败时原样返回 */
|
||
function safeJsonParse(str: unknown): unknown {
|
||
if (typeof str !== 'string') return str
|
||
try {
|
||
return JSON.parse(str)
|
||
} catch {
|
||
return str
|
||
}
|
||
}
|
||
|
||
/** 将 OldData / NewData 规范化为可序列化对象 */
|
||
function normalizeData(data: unknown): Record<string, unknown> | null {
|
||
if (!data) return null
|
||
if (typeof data === 'string') {
|
||
const parsed = safeJsonParse(data)
|
||
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
|
||
}
|
||
return typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||
}
|
||
|
||
/** 详情弹窗:变更前数据 */
|
||
const detailOldData = computed(() => normalizeData(detailRow.value?.OldData))
|
||
/** 详情弹窗:变更后数据 */
|
||
const detailNewData = computed(() => normalizeData(detailRow.value?.NewData))
|
||
|
||
/** 构造查询参数(对齐原生 buildFilters) */
|
||
function buildQuery(): Record<string, unknown> {
|
||
const params: Record<string, unknown> = {
|
||
page: currentPage.value,
|
||
page_size: pageSize,
|
||
ordering: '-CreateTime',
|
||
}
|
||
if (filterTable.value) params.TableName = filterTable.value
|
||
if (filterAction.value) params.Action = filterAction.value
|
||
if (filterDateRange.value && filterDateRange.value.length === 2) {
|
||
params.CreateTime__gte = filterDateRange.value[0] + ' 00:00:00'
|
||
params.CreateTime__lte = filterDateRange.value[1] + ' 23:59:59'
|
||
}
|
||
return params
|
||
}
|
||
|
||
/**
|
||
* 加载审计日志
|
||
* 注:admin_verify 由 request.ts 拦截器按 URL query 自动注入,此处不再手动拼接
|
||
* 响应拦截器返回 result.data,兼容数组或 { records, total } 两种结构
|
||
*/
|
||
async function loadAuditLogs(): Promise<void> {
|
||
loading.value = true
|
||
errorMsg.value = ''
|
||
try {
|
||
// 拦截器返回 result.data;total 可能在 data 内或顶层(此处通过 as any 兼容读取)
|
||
const data = await get<AuditLogRow[] | { records: AuditLogRow[]; total?: number } | null>(
|
||
'/API/FormAPI/Get/AuditLog',
|
||
buildQuery(),
|
||
)
|
||
// 兼容数组 / { records, total } 两种结构
|
||
if (Array.isArray(data)) {
|
||
logList.value = data
|
||
total.value = data.length
|
||
} else if (data && Array.isArray((data as { records?: AuditLogRow[] }).records)) {
|
||
logList.value = (data as { records: AuditLogRow[] }).records
|
||
total.value = (data as { total?: number }).total ?? logList.value.length
|
||
} else {
|
||
logList.value = []
|
||
total.value = 0
|
||
}
|
||
} catch (err) {
|
||
// 业务错误拦截器已弹提示,此处仅展示占位
|
||
errorMsg.value = '加载审计日志失败:' + (err instanceof Error ? err.message : String(err))
|
||
logList.value = []
|
||
total.value = 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 点击查询:重置到第一页 */
|
||
function onFilter(): void {
|
||
currentPage.value = 1
|
||
loadAuditLogs()
|
||
}
|
||
|
||
/** 重置筛选条件 */
|
||
function onResetFilter(): void {
|
||
filterTable.value = ''
|
||
filterAction.value = ''
|
||
filterDateRange.value = null
|
||
currentPage.value = 1
|
||
loadAuditLogs()
|
||
}
|
||
|
||
/** 分页变化 */
|
||
function onPageChange(page: number): void {
|
||
currentPage.value = page
|
||
loadAuditLogs()
|
||
}
|
||
|
||
/** 打开详情弹窗 */
|
||
function showDetail(row: AuditLogRow): void {
|
||
detailRow.value = row
|
||
detailVisible.value = true
|
||
}
|
||
|
||
/** 详情 JSON 文本(美化输出) */
|
||
function prettyJson(data: unknown): string {
|
||
if (!data) return ''
|
||
try {
|
||
return JSON.stringify(data, null, 2)
|
||
} catch {
|
||
return String(data)
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 非管理员直接拦截,展示无权访问
|
||
if (!isAdmin.value) {
|
||
denied.value = true
|
||
return
|
||
}
|
||
loadAuditLogs()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="audit-log-page">
|
||
<!-- 管理员特权横幅(自动判断 server / admin_verify) -->
|
||
<AdminBadge />
|
||
|
||
<!-- 无权访问占位 -->
|
||
<el-empty
|
||
v-if="denied"
|
||
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
|
||
>
|
||
<template #image>
|
||
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
|
||
</template>
|
||
</el-empty>
|
||
|
||
<template v-else>
|
||
<!-- 页头 -->
|
||
<div class="audit-log-header">
|
||
<h1 class="page-title">
|
||
<i class="fas fa-history icon-primary" />
|
||
<span>操作审计日志</span>
|
||
</h1>
|
||
<el-button :icon="'Refresh'" @click="loadAuditLogs">刷新</el-button>
|
||
</div>
|
||
|
||
<!-- 筛选条件 -->
|
||
<div class="audit-log-filters">
|
||
<span class="filter-label">表名:</span>
|
||
<el-select v-model="filterTable" placeholder="全部" clearable style="width: 140px">
|
||
<el-option v-for="t in tableOptions" :key="t" :label="t" :value="t" />
|
||
</el-select>
|
||
<span class="filter-label">操作:</span>
|
||
<el-select v-model="filterAction" placeholder="全部" clearable style="width: 120px">
|
||
<el-option v-for="a in actionOptions" :key="a.value" :label="a.label" :value="a.value" />
|
||
</el-select>
|
||
<span class="filter-label">时间范围:</span>
|
||
<el-date-picker
|
||
v-model="filterDateRange"
|
||
type="daterange"
|
||
range-separator="至"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
value-format="YYYY-MM-DD"
|
||
style="width: 280px"
|
||
/>
|
||
<el-button type="primary" :icon="'Search'" @click="onFilter">查询</el-button>
|
||
<el-button :icon="'RefreshLeft'" @click="onResetFilter">重置</el-button>
|
||
</div>
|
||
|
||
<!-- 表格 -->
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="logList"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
class="audit-log-table"
|
||
>
|
||
<el-table-column label="时间" width="170">
|
||
<template #default="{ row }">{{ formatTime(row.CreateTime) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="TableName" label="表名" width="120" />
|
||
<el-table-column label="操作" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="actionTagType(row.Action)" size="small">{{ actionLabel(row.Action) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="目标UUID" width="190">
|
||
<template #default="{ row }">
|
||
<code class="mono">{{ row.TargetUUID || '-' }}</code>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" width="150">
|
||
<template #default="{ row }">
|
||
<code class="mono">{{ row.IPAddress || '-' }}</code>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作人" min-width="140">
|
||
<template #default="{ row }">{{ row.UserUUID_label || row.UserUUID || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="详情" width="90" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" link type="primary" @click="showDetail(row)">
|
||
<i class="fas fa-eye" /> 详情
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<!-- 空数据 / 错误占位 -->
|
||
<template #empty>
|
||
<div v-if="errorMsg" style="color: var(--el-color-danger); padding: 20px">
|
||
<i class="fas fa-exclamation-circle" /> {{ errorMsg }}
|
||
</div>
|
||
<el-empty v-else description="暂无审计日志" />
|
||
</template>
|
||
</el-table>
|
||
|
||
<!-- 分页 -->
|
||
<div class="audit-log-pagination">
|
||
<el-pagination
|
||
background
|
||
layout="total, prev, pager, next"
|
||
:total="total"
|
||
:page-size="pageSize"
|
||
:current-page="currentPage"
|
||
@current-change="onPageChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 详情弹窗 -->
|
||
<el-dialog v-model="detailVisible" title="审计日志详情" width="800px" top="6vh">
|
||
<template v-if="detailRow">
|
||
<div class="detail-section">
|
||
<h4>基本信息</h4>
|
||
<div class="detail-base">
|
||
<div><strong>时间:</strong> {{ formatTime(detailRow.CreateTime) }}</div>
|
||
<div><strong>表名:</strong> {{ detailRow.TableName || '' }}</div>
|
||
<div>
|
||
<strong>操作:</strong>
|
||
<el-tag :type="actionTagType(detailRow.Action)" size="small">{{ actionLabel(detailRow.Action) }}</el-tag>
|
||
</div>
|
||
<div><strong>目标UUID:</strong> <code class="mono">{{ detailRow.TargetUUID || '-' }}</code></div>
|
||
<div><strong>IP地址:</strong> <code class="mono">{{ detailRow.IPAddress || '-' }}</code></div>
|
||
<div><strong>操作人:</strong> {{ detailRow.UserUUID_label || detailRow.UserUUID || '-' }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="detailOldData" class="detail-section">
|
||
<h4>变更前数据 (OldData)</h4>
|
||
<pre class="detail-json">{{ prettyJson(detailOldData) }}</pre>
|
||
</div>
|
||
<div v-if="detailNewData" class="detail-section">
|
||
<h4>变更后数据 (NewData)</h4>
|
||
<pre class="detail-json">{{ prettyJson(detailNewData) }}</pre>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.audit-log-page {
|
||
padding: 20px;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.audit-log-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
|
||
.page-title {
|
||
margin: 0;
|
||
flex: 1;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.icon-primary {
|
||
color: var(--el-color-primary);
|
||
}
|
||
}
|
||
}
|
||
|
||
.audit-log-filters {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
|
||
.filter-label {
|
||
font-size: 13px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
|
||
.audit-log-table {
|
||
flex: 1;
|
||
min-height: 0;
|
||
|
||
.mono {
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.audit-log-pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.detail-section {
|
||
margin-bottom: 16px;
|
||
|
||
h4 {
|
||
margin: 0 0 8px;
|
||
font-size: 13px;
|
||
color: var(--el-text-color-secondary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.detail-base {
|
||
font-size: 13px;
|
||
line-height: 1.9;
|
||
}
|
||
|
||
.detail-json {
|
||
background: var(--el-fill-color-light);
|
||
border: 1px solid var(--el-border-color);
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 240px;
|
||
overflow: auto;
|
||
margin: 0;
|
||
}
|
||
}
|
||
</style>
|