Files
admin-web/src/modules/audit/components/CustomerAuditLogsPanel.vue
T
2026-10-20 23:34:11 +08:00

225 lines
15 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">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ScrollText } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail, type PlatformAuditLogListItem, type PlatformAuditLogQuery } from '../api/platformAudit'
import { auditDescriptionLabel, auditEventLabel, auditHttpMethodLabel, auditOperatorLabel, auditResourceLabel, auditServiceLabel } from '../presentation'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
const rows = ref<PlatformAuditLogListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformAuditLogDetail | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
const selectedLogId = ref<string | null>(null)
const detailTitle = computed(() => props.customerName.trim() ? `操作日志详情 · ${props.customerName.trim()}` : '操作日志详情')
let detailRequestSerial = 0
const emptyFilters = (): QueryModel => ({ keyword: '', success: '', occurredRange: [] })
const filters = ref<QueryModel>(emptyFilters())
const appliedFilters = ref<QueryModel>(emptyFilters())
let requestSerial = 0
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '操作内容/操作人/资源或请求标识' },
{ key: 'success', type: 'select', label: '结果', width: 150, options: [{ label: '成功', value: true }, { label: '失败', value: false }] },
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'description', label: '操作内容', minWidth: 220, slotName: 'description' },
{ key: 'clientIp', label: 'IP 地址', width: 140, slotName: 'clientIp' },
{ key: 'eventType', label: '事件类型', minWidth: 180, slotName: 'eventType' },
{ key: 'username', label: '操作人', width: 130, slotName: 'username' },
{ key: 'serviceName', label: '来源服务', width: 145, slotName: 'serviceName' },
{ key: 'success', label: '结果', width: 90, slotName: 'success' },
{ key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' },
]
const display = (value?: string | null) => value?.trim() || '—'
const formatDate = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
function formatSnapshot(value?: string | null) {
if (!value?.trim()) return '无'
try { return JSON.stringify(JSON.parse(value), null, 2) }
catch { return value }
}
function localDayIso(value: string, end: boolean) {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return undefined
return new Date(year, month - 1, day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0, end ? 999 : 0).toISOString()
}
function toQuery(customerId: string): PlatformAuditLogQuery {
const range = Array.isArray(appliedFilters.value.occurredRange) ? appliedFilters.value.occurredRange.map(String) : []
const success = appliedFilters.value.success
return {
accountId: customerId,
page: page.value,
size: pageSize.value,
keyword: String(appliedFilters.value.keyword || '').trim() || undefined,
success: typeof success === 'boolean' ? success : undefined,
occurredFrom: range[0] ? localDayIso(range[0], false) : undefined,
occurredTo: range[1] ? localDayIso(range[1], true) : undefined,
}
}
async function load() {
const customerId = props.customerId.trim()
const serial = ++requestSerial
rows.value = []
total.value = 0
loadError.value = null
if (!customerId || !access.can('platform:audit:list')) { loading.value = false; return }
loading.value = true
try {
const result = await listPlatformAuditLogs(toQuery(customerId))
if (serial !== requestSerial || customerId !== props.customerId.trim()) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (serial === requestSerial) loadError.value = getApiErrorDetails(error, '客户操作日志加载失败,请稍后重试')
} finally {
if (serial === requestSerial) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || '').trim(),
success: typeof model.success === 'boolean' ? model.success : '',
occurredRange: Array.isArray(model.occurredRange) ? model.occurredRange.map(String) : [],
}
if (page.value === 1) void load()
else page.value = 1
}
function clearDetail() {
detailRequestSerial += 1
selectedLogId.value = null
detailOpen.value = false
detailLoading.value = false
detail.value = null
detailError.value = null
}
async function loadDetail() {
const logId = selectedLogId.value
const customerId = props.customerId.trim()
if (!logId || !detailOpen.value || !access.can('platform:audit:list')) return
const serial = ++detailRequestSerial
const isCurrent = () => serial === detailRequestSerial && customerId === props.customerId.trim() && detailOpen.value
detailLoading.value = true
detail.value = null
detailError.value = null
try {
const result = await getPlatformAuditLog(logId)
if (isCurrent()) detail.value = result
} catch (error) {
if (isCurrent()) detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试')
} finally {
if (isCurrent()) detailLoading.value = false
}
}
async function openDetail(row: PlatformAuditLogListItem) {
selectedLogId.value = row.id
detailOpen.value = true
await loadDetail()
}
watch(detailOpen, open => { if (!open) clearDetail() })
onBeforeUnmount(() => { requestSerial += 1; detailRequestSerial += 1 })
watch([() => props.customerId, () => access.can('platform:audit:list')], () => {
clearDetail()
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
if (page.value === 1) void load()
else page.value = 1
}, { immediate: true })
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void load()
})
</script>
<template>
<section class="customer-audit-panel admin-density admin-workbench">
<div class="panel-title"><div><h2>操作日志</h2></div><ScrollText :size="19" /></div>
<div v-if="!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" /><strong>暂无操作日志查看权限</strong><p>请联系管理员开通平台操作日志查看权限。</p></div>
<template v-else>
<QueryToolbar v-model="filters" class="audit-query workbench-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div class="workbench-table" v-else><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志:${auditEventLabel((row as PlatformAuditLogListItem).eventType)},${formatDate((row as PlatformAuditLogListItem).occurredAt)}`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
<template #description="{ row }"><el-tooltip :content="auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement="top" :show-after="400"><span class="cell-clamp">{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}</span></el-tooltip></template>
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label workbench-badge is-toned">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
<template #username="{ row }">{{ auditOperatorLabel((row as PlatformAuditLogListItem).username) }}</template>
<template #serviceName="{ row }"><el-tooltip :content="display((row as PlatformAuditLogListItem).serviceName)" placement="top" :show-after="500"><span class="service-label">{{ auditServiceLabel((row as PlatformAuditLogListItem).serviceName) }}</span></el-tooltip></template>
<template #success="{ row }"><el-tag :type="(row as PlatformAuditLogListItem).success ? 'success' : 'danger'">{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}</el-tag></template>
<template #occurredAt="{ row }">{{ formatDate((row as PlatformAuditLogListItem).occurredAt) }}</template>
<template #pagination-left>共 {{ total }} 条客户操作记录</template>
</DataTable></div>
</template>
<AppDrawer class="customer-audit-drawer admin-density-drawer admin-density-drawer--detail admin-workbench is-drawer" v-model="detailOpen" :title="detailTitle" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">{{ auditEventLabel(detail.eventType) }} <el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag></h3>
<p class="audit-event-description">{{ auditDescriptionLabel(detail.description) }}</p>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="source">
<h3 class="density-dialog-section__title">操作信息</h3>
<table class="density-table audit-detail-table" aria-label="操作日志基础信息"><tbody>
<tr><th scope="row">操作人</th><td>{{ auditOperatorLabel(detail.username) }}</td><th scope="row">发生时间</th><td>{{ formatDate(detail.occurredAt) }}</td></tr>
<tr><th scope="row">来源服务</th><td>{{ auditServiceLabel(detail.serviceName) }}<small class="audit-service-code density-dialog-note">{{ display(detail.serviceName) }}</small></td><th scope="row">客户端 IP</th><td>{{ display(detail.clientIp) }}</td></tr>
<tr><th scope="row">业务资源</th><td>{{ auditResourceLabel(detail.resourceType) }}</td><th scope="row">处理耗时</th><td>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</td></tr>
<tr><th scope="row">请求地址</th><td colspan="3"><span class="audit-request"><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></span></td></tr>
<tr><th scope="row">请求标识</th><td colspan="3"><code>{{ display(detail.requestId) }}</code></td></tr>
<tr v-if="!detail.success && detail.errorCode"><th scope="row">错误码(诊断)</th><td colspan="3"><code>{{ detail.errorCode }}</code></td></tr>
<tr v-if="detail.traceId"><th scope="row">链路标识</th><td colspan="3"><code>{{ detail.traceId }}</code></td></tr>
</tbody></table>
</section>
<el-alert v-if="!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
<div class="audit-snapshots">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
</div>
</div>
</AppDrawer>
</section>
</template>
<style scoped>
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--workbench-border); }.panel-title h2 { margin:0; color:var(--workbench-text); font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--workbench-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:var(--workbench-muted); text-align:center; }.module-empty strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; overflow:hidden; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:var(--workbench-text); text-overflow:ellipsis; white-space:nowrap; }
.audit-event-description { margin:0; overflow-wrap:anywhere; }
.audit-detail-table { table-layout:fixed; }
.audit-detail-table th { width:92px; }
.audit-service-code { display:block; }
.audit-request { display:flex; align-items:center; gap:var(--admin-space-3); }
.audit-request code { min-width:0; }
.audit-snapshots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
.audit-snapshots > .density-dialog-section { margin-bottom:0; }
@media(max-width:720px) { .audit-snapshots { grid-template-columns:1fr; } }
</style>