style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail } from '../api/platformAudit'
|
||||
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
|
||||
|
||||
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
|
||||
|
||||
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
|
||||
|
||||
const detail: PlatformAuditLogDetail = { ...row, resourceId: '9007199254740993123', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' }
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
@@ -20,7 +25,7 @@ function mountPanel(permissions: string[]) {
|
||||
}
|
||||
|
||||
describe('CustomerAuditLogsPanel', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getPlatformAuditLog).mockResolvedValue(detail); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
|
||||
it('queries logs with the current customer id', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
@@ -91,17 +96,24 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
})
|
||||
|
||||
it('loads detail and formats json snapshots', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const privateId = '9007199254740993123'
|
||||
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, id: privateId }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, id: privateId, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith(privateId)
|
||||
expect(wrapper.text()).toContain('旧名称')
|
||||
expect(wrapper.text()).toContain('新名称')
|
||||
expect(wrapper.text()).toContain('入库测量审核通过')
|
||||
expect(wrapper.text()).toContain('入库测量')
|
||||
expect(wrapper.text()).toContain('头程履约服务')
|
||||
expect(wrapper.text()).toContain('更新')
|
||||
expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '操作日志详情 · 柏悦物流', size: 'min(920px, 96vw)', showFooter: false })
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('req')
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('trace')
|
||||
expect(wrapper.find('.audit-detail-table').text()).not.toContain(privateId)
|
||||
expect(wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').attributes('aria-label')).not.toContain(privateId)
|
||||
})
|
||||
|
||||
it('loads the selected audit page', async () => {
|
||||
@@ -131,4 +143,100 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
|
||||
})
|
||||
it('preserves failure details and complete long or non-json snapshots as text', async () => {
|
||||
const longText = '长内容'.repeat(1200)
|
||||
const raw = '<script>原始内容</script>\n不是JSON'
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: '数据版本冲突', beforeData: JSON.stringify({ note: longText }), afterData: raw })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
const drawer = wrapper.findComponent(AppDrawer)
|
||||
expect(drawer.text()).toContain('操作失败')
|
||||
expect(drawer.get('[role="alert"]').text()).toContain('数据版本冲突')
|
||||
const snapshots = drawer.findAll('.density-code-block')
|
||||
expect(snapshots[0].text()).toBe(JSON.stringify({ note: longText }, null, 2))
|
||||
expect(snapshots[1].text()).toBe(raw)
|
||||
expect(drawer.find('script').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps missing snapshots compact and a zero duration visible', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: null, errorCode: 'OPERATION_CONFLICT', durationMs: 0, beforeData: '', afterData: null, requestId: null, traceId: null })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.density-code-block').map(snapshot => snapshot.text())).toEqual(['无', '无'])
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('0 ms')
|
||||
expect(wrapper.find('.audit-detail-table').text()).not.toContain('链路标识')
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('OPERATION_CONFLICT')
|
||||
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).toContain('操作失败,请查看诊断信息')
|
||||
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).not.toContain('OPERATION_CONFLICT')
|
||||
})
|
||||
|
||||
it('retries the selected log after a detail request failure', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(detail)
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('详情加载失败')
|
||||
await wrapper.findComponent(AppDrawer).get('.load-error-state button').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getPlatformAuditLog).mock.calls).toEqual([['1'], ['1']])
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
|
||||
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('ignores a late %s from the previously selected log', async outcome => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
let fail!: (reason: Error) => void
|
||||
const previous = new Promise<PlatformAuditLogDetail>((resolve, reject) => { finish = resolve; fail = reject })
|
||||
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row, { ...row, id: '2', description: '第二条记录' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(getPlatformAuditLog).mockImplementation(id => id === '1' ? previous : Promise.resolve({ ...detail, id: '2', description: '第二条详情' }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
const actions = wrapper.findAll('.el-table__body button[aria-label^="查看操作日志:"]')
|
||||
await actions[0].trigger('click'); await flushPromises()
|
||||
await actions[1].trigger('click'); await flushPromises()
|
||||
if (outcome === 'resolve') finish({ ...detail, description: '不应重新展示的旧详情' })
|
||||
else fail(new Error('旧请求失败'))
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('loading')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('第二条详情')
|
||||
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('不应重新展示的旧详情')
|
||||
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('closes and clears pending detail when switching customers', async () => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
await wrapper.setProps({ customerId: '9', customerName: '另一客户' }); await flushPromises()
|
||||
finish(detail); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
expect(listPlatformAuditLogs).toHaveBeenLastCalledWith(expect.objectContaining({ accountId: '9' }))
|
||||
})
|
||||
|
||||
it('ignores pending responses after closing and permits a fresh reopen', async () => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent(AppDrawer).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
finish({ ...detail, description: '过期详情' }); await flushPromises()
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
|
||||
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('过期详情')
|
||||
})
|
||||
|
||||
it('clears detail when audit permission is revoked', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
useAuthStore().permissions = []
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('暂无操作日志查看权限')
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref, watch } from 'vue'
|
||||
import { CircleCheck, CircleX, ScrollText } from '@lucide/vue'
|
||||
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'
|
||||
@@ -24,6 +24,9 @@ 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())
|
||||
@@ -80,7 +83,7 @@ async function load() {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
if (!customerId || !access.can('platform:audit:list')) return
|
||||
if (!customerId || !access.can('platform:audit:list')) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformAuditLogs(toQuery(customerId))
|
||||
@@ -104,17 +107,45 @@ function applyFilters(model: QueryModel) {
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformAuditLogListItem) {
|
||||
detailOpen.value = true
|
||||
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 { detail.value = await getPlatformAuditLog(row.id) }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
|
||||
finally { detailLoading.value = false }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.customerId, () => {
|
||||
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()
|
||||
@@ -133,7 +164,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
<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="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<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>
|
||||
@@ -145,29 +176,31 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</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>
|
||||
<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="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 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" 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>
|
||||
<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>
|
||||
@@ -178,8 +211,13 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
|
||||
.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}}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user