Files
admin-web/src/modules/audit/components/CustomerAuditLogsPanel.vue
T
2026-09-14 17:54:21 +08:00

185 lines
16 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 { ref, watch } from 'vue'
import { CircleCheck, CircleX, 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 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')) 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
}
async function openDetail(row: PlatformAuditLogListItem) {
detailOpen.value = true
detailLoading.value = true
detail.value = null
detailError.value = null
try { detail.value = await getPlatformAuditLog(row.id) }
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
finally { detailLoading.value = false }
}
watch(() => props.customerId, () => {
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">
<div class="panel-title"><div><h2>操作日志</h2><p>{{ customerName }}已归属当前客户的档案与业务操作记录</p></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" 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>
<DataTable v-else 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 link type="primary" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @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">{{ 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>
</template>
<AppDrawer class="customer-audit-drawer admin-density-drawer" v-model="detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="audit-detail-hero" :class="detail.success ? 'is-success' : 'is-failed'">
<span class="result-icon" aria-hidden="true"><CircleCheck v-if="detail.success" :size="22" /><CircleX v-else :size="22" /></span>
<div class="hero-copy"><span>事件类型</span><h3>{{ auditEventLabel(detail.eventType) }}</h3><p>{{ auditDescriptionLabel(detail.description) }}</p></div>
<el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag>
</section>
<section class="audit-detail-grid" aria-label="操作日志基础信息">
<div><span>操作人</span><strong>{{ auditOperatorLabel(detail.username) }}</strong></div>
<div><span>发生时间</span><strong>{{ formatDate(detail.occurredAt) }}</strong></div>
<div><span>来源服务</span><strong>{{ auditServiceLabel(detail.serviceName) }}</strong><small>{{ display(detail.serviceName) }}</small></div>
<div><span>客户端 IP</span><strong>{{ display(detail.clientIp) }}</strong></div>
<div><span>业务资源</span><strong>{{ auditResourceLabel(detail.resourceType) }}</strong><small>{{ display(detail.resourceId) }}</small></div>
<div><span>处理耗时</span><strong>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</strong></div>
<div class="wide request-cell"><span>请求地址</span><div><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></div></div>
<div class="wide identity-cell"><span>请求标识</span><strong>{{ display(detail.requestId) }}</strong><small v-if="detail.traceId">链路标识:{{ detail.traceId }}</small></div>
</section>
<el-alert v-if="!detail.success" class="failure-alert" type="error" :closable="false" show-icon :title="display(detail.errorMessage || detail.errorCode)" />
<div class="snapshot-grid"><section><h3>操作前快照</h3><p>操作执行前保留的业务数据</p><pre>{{ formatSnapshot(detail.beforeData) }}</pre></section><section><h3>操作结果快照</h3><p>操作执行后返回或保存的业务数据</p><pre>{{ formatSnapshot(detail.afterData) }}</pre></section></div>
</template>
</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(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--cc-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:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--cc-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; max-width:100%; padding:3px 9px; overflow:hidden; border:1px solid #dbeafe; border-radius:999px; background:#eff6ff; color:#1d4ed8; font-size:var(--admin-font-caption, 12px); line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:#475467; text-overflow:ellipsis; white-space:nowrap; }
.audit-detail-hero { display:flex; align-items:flex-start; gap:13px; padding:var(--admin-space-3, 8px) var(--admin-space-4, 10px); border:1px solid #d1fadf; border-radius:0; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; height:var(--admin-tab-height, 38px); flex:0 0 auto; place-items:center; border-radius:50%; background:#dcfae6; color:#079455; }.is-failed .result-icon { background:#fee4e2; color:#d92d20; }.hero-copy { min-width:0; flex:1; }.hero-copy > span { color:#667085; font-size:var(--admin-font-caption, 12px); }.hero-copy h3 { margin:3px 0 var(--admin-space-1, 4px); color:#101828; font-size:var(--admin-font-section, 14px); }.hero-copy p { margin:0; color:#475467; font-size:var(--admin-font-body, 13px); line-height:1.55; }
.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:var(--admin-space-6, 16px); overflow:hidden; border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.audit-detail-grid > div { display:flex; min-width:0; min-height:var(--admin-row-height, 36px); flex-direction:column; justify-content:center; gap:5px; padding:var(--admin-space-2, 6px) var(--admin-space-4, 10px); border-right:1px solid #eaecf0; border-bottom:1px solid #eaecf0; }.audit-detail-grid > div:nth-child(3n) { border-right:0; }.audit-detail-grid > div.wide { grid-column:1/-1; border-right:0; }.audit-detail-grid > div:last-child { border-bottom:0; }.audit-detail-grid span { color:#667085; font-size:var(--admin-font-caption, 12px); }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:var(--admin-font-section, 14px); font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.request-cell > div { display:flex; min-width:0; align-items:center; gap:9px; }.request-cell code { min-width:0; overflow-wrap:anywhere; color:#344054; font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }.failure-alert { margin-top:var(--admin-space-6, 16px); }
.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-6, 16px); margin-top:18px; }.snapshot-grid section { min-width:0; padding:var(--admin-space-4, 10px); border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.snapshot-grid h3 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.snapshot-grid p { margin:var(--admin-space-1, 4px) 0 var(--admin-space-4, 10px); color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:var(--admin-label-bg, #f8fafc); color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
@media(max-width:720px){.audit-detail-grid{grid-template-columns:1fr}.audit-detail-grid>div,.audit-detail-grid>div:nth-child(3n){border-right:0}.audit-detail-grid>div.wide{grid-column:auto}.snapshot-grid{grid-template-columns:1fr}.audit-detail-hero{align-items:flex-start}.audit-detail-hero>.el-tag{flex:0 0 auto}}
</style>