<script lang="ts" setup>
|
import type { AuditEventId, AuditEventListDTO, AuditOperation } from './types';
|
|
import { computed, reactive, toRefs } from 'vue';
|
|
import { useMessage, usePermission } from '@jnpf/hooks';
|
import { BasicDrawer, useDrawerInner } from '@jnpf/ui/drawer';
|
import { formatToDateTime } from '@jnpf/utils';
|
|
import { Descriptions, DescriptionsItem, Result } from 'ant-design-vue';
|
|
import AuditDiffList from '#/components/FormExtraPanel/AuditDiffList.vue';
|
|
import { getAuditActionLabel, getAuditEventTypeLabel, getSourceLayerLabel } from './auditOperation';
|
import { auditOperationQuery } from './auditOperationQuery';
|
import { AUDIT_DETAIL_PERMISSION } from './types';
|
|
type DetailError = '' | 'load' | 'not-found';
|
|
interface DrawerState {
|
detail: Awaited<ReturnType<typeof auditOperationQuery.detail>> | null;
|
error: DetailError;
|
operation: AuditOperation | null;
|
selectedEventId: AuditEventId | null;
|
techOpenKeys: string[];
|
}
|
|
const state = reactive<DrawerState>({
|
detail: null,
|
error: '',
|
operation: null,
|
selectedEventId: null,
|
techOpenKeys: ['technical'],
|
});
|
const { detail, error, selectedEventId, techOpenKeys } = toRefs(state);
|
const { createMessage } = useMessage();
|
const { hasBtnP } = usePermission();
|
const [registerDrawer, { changeLoading, closeDrawer }] = useDrawerInner(init);
|
|
const relatedEvents = computed<AuditEventListDTO[]>(() => {
|
return state.operation ? [state.operation, ...state.operation.subEvents] : [];
|
});
|
|
const relatedEventColumns = [
|
{ key: 'action', title: '动作', width: 180 },
|
{ key: 'source', title: '来源', width: 110 },
|
{ key: 'eventTime', title: '操作时间', width: 170 },
|
{ key: 'diffCount', title: '变更数', width: 90 },
|
{ key: 'view', title: '查看', width: 100 },
|
];
|
|
async function init(data: { operation: AuditOperation }) {
|
state.operation = data.operation;
|
state.detail = null;
|
state.error = '';
|
state.techOpenKeys = ['technical'];
|
if (!hasBtnP(AUDIT_DETAIL_PERMISSION, false)) {
|
closeDrawer();
|
createMessage.warning('无权查看审计详情');
|
return;
|
}
|
await loadEvent(data.operation.id);
|
}
|
|
async function loadEvent(eventId: AuditEventId) {
|
state.selectedEventId = eventId;
|
state.error = '';
|
changeLoading(true);
|
try {
|
state.detail = await auditOperationQuery.detail(eventId);
|
} catch (requestError) {
|
state.detail = null;
|
if (isForbiddenError(requestError)) {
|
closeDrawer();
|
createMessage.warning('审计详情权限已失效');
|
return;
|
}
|
state.error = isNotFoundError(requestError) ? 'not-found' : 'load';
|
} finally {
|
changeLoading(false);
|
}
|
}
|
|
function isSelected(eventId: AuditEventId) {
|
return String(eventId) === String(state.selectedEventId);
|
}
|
|
function getRelatedRowClassName(record: AuditEventListDTO) {
|
return isSelected(record.id) ? 'is-selected' : '';
|
}
|
|
function displayText(value: unknown, fallback = '—') {
|
if (value === undefined || value === null || value === '') return fallback;
|
return String(value);
|
}
|
|
function displayTime(value?: string) {
|
return value ? formatToDateTime(value, 'YYYY-MM-DD HH:mm:ss') : '—';
|
}
|
|
function isForbiddenError(errorValue: unknown) {
|
const message = errorValue instanceof Error ? errorValue.message : '';
|
return /403|forbidden|无权|权限|拒绝/i.test(message);
|
}
|
|
function isNotFoundError(errorValue: unknown) {
|
const message = errorValue instanceof Error ? errorValue.message : '';
|
return /404|not found|不存在|不可访问/i.test(message);
|
}
|
</script>
|
|
<template>
|
<BasicDrawer
|
v-bind="$attrs"
|
@register="registerDrawer"
|
title="审计详情"
|
width="min(820px, calc(100vw - 16px))"
|
class="audit-detail-drawer"
|
:keyboard="true"
|
destroy-on-close>
|
<div class="audit-drawer-body">
|
<Result v-if="error === 'not-found'" status="404" title="审计事件不存在或已不可访问">
|
<template #extra>
|
<a-button @click="closeDrawer">关闭</a-button>
|
</template>
|
</Result>
|
<Result v-else-if="error === 'load'" status="error" title="审计详情加载失败" sub-title="请稍后重试,列表筛选条件不会丢失。">
|
<template #extra>
|
<a-button type="primary" @click="selectedEventId !== null && loadEvent(selectedEventId)">重试</a-button>
|
</template>
|
</Result>
|
|
<template v-else-if="detail">
|
<section class="audit-section">
|
<h3>操作概览</h3>
|
<Descriptions class="audit-descriptions" bordered size="small" :column="{ xs: 1, sm: 1, md: 2 }">
|
<DescriptionsItem label="动作">{{ getAuditActionLabel(detail) }}</DescriptionsItem>
|
<DescriptionsItem label="事件类型">{{ getAuditEventTypeLabel(detail.eventType) }}</DescriptionsItem>
|
<DescriptionsItem label="操作人">{{ displayText(detail.operatorName, '未知用户') }}</DescriptionsItem>
|
<DescriptionsItem label="操作时间">{{ displayTime(detail.eventTime) }}</DescriptionsItem>
|
<DescriptionsItem label="业务模块">{{ displayText(detail.bizModule) }}</DescriptionsItem>
|
<DescriptionsItem label="业务类型">{{ displayText(detail.bizType) }}</DescriptionsItem>
|
<DescriptionsItem label="业务单号" :span="2">{{ displayText(detail.bizCode) }}</DescriptionsItem>
|
<DescriptionsItem label="操作原因" :span="2">{{ displayText(detail.reason) }}</DescriptionsItem>
|
</Descriptions>
|
</section>
|
|
<section class="audit-section">
|
<h3>字段变更</h3>
|
<AuditDiffList :diffs="detail.fieldDiffList" hide-technical-field-names layout="table" mask-sensitive :parse-error="detail.fieldDiffParseError" />
|
</section>
|
|
<section class="audit-section">
|
<h3>本次操作事件({{ relatedEvents.length }})</h3>
|
<a-table
|
class="audit-related-table"
|
size="small"
|
:columns="relatedEventColumns"
|
:data-source="relatedEvents"
|
:pagination="false"
|
:row-class-name="getRelatedRowClassName"
|
:scroll="{ x: 650 }"
|
row-key="id"
|
table-layout="fixed">
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'action'">
|
<span class="audit-related-action">{{ getAuditActionLabel(record) }}</span>
|
</template>
|
<template v-else-if="column.key === 'source'">
|
<a-tag :bordered="false">{{ getSourceLayerLabel(record.sourceLayer) }}</a-tag>
|
</template>
|
<template v-else-if="column.key === 'eventTime'">
|
{{ displayTime(record.eventTime) }}
|
</template>
|
<template v-else-if="column.key === 'diffCount'"> {{ record.diffCount || 0 }} 处 </template>
|
<template v-else-if="column.key === 'view'">
|
<a-button type="link" size="small" :disabled="isSelected(record.id)" @click="loadEvent(record.id)">
|
{{ isSelected(record.id) ? '当前事件' : '查看' }}
|
</a-button>
|
</template>
|
</template>
|
</a-table>
|
</section>
|
|
<section class="audit-section audit-section-last">
|
<a-collapse v-model:active-key="techOpenKeys" ghost expand-icon-position="end">
|
<a-collapse-panel key="technical" header="技术信息">
|
<Descriptions class="audit-descriptions audit-technical-descriptions" bordered size="small" :column="1">
|
<DescriptionsItem label="IP 地址">{{ displayText(detail.ip) }}</DescriptionsItem>
|
<DescriptionsItem label="IP 地区">{{ displayText(detail.ipRegion) }}</DescriptionsItem>
|
<DescriptionsItem label="浏览器">{{ displayText(detail.browser) }}</DescriptionsItem>
|
<DescriptionsItem label="操作系统">{{ displayText(detail.os) }}</DescriptionsItem>
|
<DescriptionsItem label="请求路径">{{ displayText(detail.requestUri) }}</DescriptionsItem>
|
<DescriptionsItem label="目标表">{{ displayText(detail.targetTable) }}</DescriptionsItem>
|
<DescriptionsItem label="目标 ID">{{ displayText(detail.targetId) }}</DescriptionsItem>
|
<DescriptionsItem label="技术服务">{{ displayText(detail.appName) }}</DescriptionsItem>
|
<DescriptionsItem label="事件 ID">{{ detail.id }}</DescriptionsItem>
|
<DescriptionsItem label="操作关联 ID">{{ displayText(detail.operationId) }}</DescriptionsItem>
|
<DescriptionsItem label="客户端事件 ID">{{ displayText(detail.clientEventId) }}</DescriptionsItem>
|
</Descriptions>
|
</a-collapse-panel>
|
</a-collapse>
|
</section>
|
</template>
|
</div>
|
</BasicDrawer>
|
</template>
|
|
<style lang="scss" scoped>
|
.audit-drawer-body {
|
min-width: 0;
|
padding: 0 20px 24px;
|
}
|
|
.audit-section {
|
padding: 20px 0;
|
border-bottom: 1px solid var(--border-color-base1);
|
|
h3 {
|
margin: 0 0 14px;
|
font-size: 15px;
|
font-weight: 600;
|
}
|
|
:deep(.ant-descriptions-item-content),
|
:deep(.ant-list-item-meta-description) {
|
min-width: 0;
|
overflow-wrap: anywhere;
|
}
|
}
|
|
.audit-section-last {
|
padding-top: 8px;
|
border-bottom: 0;
|
|
:deep(.ant-collapse-header) {
|
padding-inline: 0;
|
font-weight: 600;
|
}
|
|
:deep(.ant-collapse-content-box) {
|
padding-inline: 0;
|
}
|
}
|
|
.audit-descriptions {
|
:deep(.ant-descriptions-item-label) {
|
width: 104px;
|
white-space: nowrap;
|
}
|
|
:deep(.ant-descriptions-item-content) {
|
word-break: break-word;
|
}
|
}
|
|
.audit-technical-descriptions {
|
:deep(.ant-descriptions-item-label) {
|
width: 120px;
|
}
|
}
|
|
.audit-related-table {
|
:deep(.ant-table-cell) {
|
vertical-align: top;
|
overflow-wrap: anywhere;
|
}
|
|
:deep(.ant-btn-link) {
|
height: auto;
|
padding: 0;
|
}
|
}
|
|
.audit-related-action {
|
font-weight: 500;
|
}
|
|
.audit-related-table :deep(.is-selected > td) {
|
background: rgb(22 119 255 / 6%);
|
}
|
|
@media (max-width: 640px) {
|
.audit-drawer-body {
|
padding: 0 12px 16px;
|
}
|
|
.audit-section {
|
padding: 16px 0;
|
}
|
}
|
</style>
|