Files
GERP/src/views/system/AuditLog.vue
T

454 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>