224 lines
15 KiB
Vue
224 lines
15 KiB
Vue
<script setup lang="ts">
|
||
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">
|
||
<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 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">{{ 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 admin-density-drawer--detail" 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" 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><p class="density-dialog-note">操作执行前保留的业务数据</p><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><p class="density-dialog-note">操作执行后返回或保存的业务数据</p><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(--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-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>
|