This commit is contained in:
162
src/components/business/audit/AuditDistributionChart.vue
Normal file
162
src/components/business/audit/AuditDistributionChart.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="distribution-chart" role="img" :aria-label="ariaLabel"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
|
||||
export interface AuditDistributionItem {
|
||||
code: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: AuditDistributionItem[]
|
||||
type?: 'donut' | 'bar'
|
||||
title: string
|
||||
}>(),
|
||||
{ type: 'donut' }
|
||||
)
|
||||
const emit = defineEmits<{ select: [code: string] }>()
|
||||
const settingStore = useSettingStore()
|
||||
const chartRef = ref<HTMLElement>()
|
||||
const ariaLabel = computed(() =>
|
||||
props.data.length
|
||||
? `${props.title}:${props.data.map((item) => `${item.name} ${item.count}`).join(',')}`
|
||||
: `${props.title}:暂无数据`
|
||||
)
|
||||
let chart: echarts.ECharts | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
const colors = () => [
|
||||
getCssVar('--el-color-primary'),
|
||||
getCssVar('--el-color-success'),
|
||||
getCssVar('--el-color-warning'),
|
||||
getCssVar('--el-color-danger'),
|
||||
getCssVar('--el-color-info'),
|
||||
getCssVar('--el-color-primary-light-3')
|
||||
]
|
||||
const textColor = () => getCssVar('--el-text-color-regular') || '#606266'
|
||||
const splitColor = () => getCssVar('--el-border-color-lighter') || '#ebeef5'
|
||||
const options = (): EChartsOption => {
|
||||
if (props.type === 'bar') {
|
||||
return {
|
||||
color: colors(),
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { top: 8, right: 72, bottom: 8, left: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: textColor(),
|
||||
hideOverlap: true,
|
||||
formatter: (value: number) => {
|
||||
const absolute = Math.abs(value)
|
||||
if (absolute >= 10000) {
|
||||
const formatted = value / 10000
|
||||
return `${Number.isInteger(formatted) ? formatted : formatted.toFixed(1)}万`
|
||||
}
|
||||
if (absolute >= 1000) {
|
||||
const formatted = value / 1000
|
||||
return `${Number.isInteger(formatted) ? formatted : formatted.toFixed(1)}k`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
},
|
||||
splitLine: { lineStyle: { color: splitColor(), type: 'dashed' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.data.map((item) => item.name),
|
||||
inverse: true,
|
||||
axisLabel: { color: textColor(), overflow: 'truncate', width: 90 },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: props.data.map((item) => ({
|
||||
value: item.count,
|
||||
code: item.code,
|
||||
itemStyle: {
|
||||
color: getCssVar('--el-color-primary'),
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
}
|
||||
})),
|
||||
barMaxWidth: 24,
|
||||
label: { show: true, position: 'right', color: textColor() }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
color: colors(),
|
||||
tooltip: { trigger: 'item', formatter: '{b}<br/>{c}({d}%)' },
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
bottom: 0,
|
||||
left: 'center',
|
||||
textStyle: { color: textColor() }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['42%', '68%'],
|
||||
center: ['50%', '43%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderColor: getCssVar('--el-bg-color'), borderWidth: 2 },
|
||||
label: { show: false },
|
||||
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
||||
data: props.data.map((item) => ({
|
||||
value: item.count,
|
||||
name: item.name,
|
||||
code: item.code
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const render = async () => {
|
||||
await nextTick()
|
||||
if (!chartRef.value) return
|
||||
if (!chart) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
chart.on('click', (params) => {
|
||||
const code = (params.data as { code?: string } | undefined)?.code
|
||||
if (code) emit('select', code)
|
||||
})
|
||||
}
|
||||
chart.setOption(options(), true)
|
||||
}
|
||||
|
||||
watch(() => props.data, render, { deep: true })
|
||||
watch(() => settingStore.isDark, render)
|
||||
onMounted(() => {
|
||||
render()
|
||||
if (chartRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => chart?.resize())
|
||||
resizeObserver.observe(chartRef.value)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.distribution-chart {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
53
src/components/business/audit/AuditEventTable.vue
Normal file
53
src/components/business/audit/AuditEventTable.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<ArtTable
|
||||
:data="items"
|
||||
:loading="Boolean(loading)"
|
||||
:pagination="false"
|
||||
row-key="event_id"
|
||||
stripe
|
||||
:margin-top="0"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="发生时间" prop="occurred_at" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="动作" min-width="180">
|
||||
<template #default="{ row }">{{ row.action_name || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="摘要" prop="summary" min-width="240" show-overflow-tooltip />
|
||||
<ElTableColumn label="操作者" min-width="150">
|
||||
<template #default="{ row }">{{ row.actor_name || row.actor_id || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="结果" width="100" align="center">
|
||||
<template #default="{ row }"
|
||||
><ElTag :type="resultMeta(row).type">{{ resultMeta(row).label }}</ElTag></template
|
||||
>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="风险" width="90" align="center">
|
||||
<template #default="{ row }"
|
||||
><ElTag effect="plain" :type="riskMeta(row).type">{{
|
||||
riskMeta(row).label
|
||||
}}</ElTag></template
|
||||
>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }"
|
||||
><ElButton link type="primary" @click="$emit('detail', row)">查看详情</ElButton></template
|
||||
>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditEventView } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
|
||||
|
||||
defineProps<{ items: AuditEventView[]; loading?: boolean }>()
|
||||
defineEmits<{ detail: [row: AuditEventView] }>()
|
||||
const resultMeta = (row: AuditEventView) =>
|
||||
auditResultMeta[row.result] || { label: row.result, type: 'info' as const }
|
||||
const riskMeta = (row: AuditEventView) =>
|
||||
auditRiskMeta[row.risk_level] || { label: row.risk_level, type: 'info' as const }
|
||||
</script>
|
||||
462
src/components/business/audit/AuditInvestigationDrawer.vue
Normal file
462
src/components/business/audit/AuditInvestigationDrawer.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
size="min(760px, 92vw)"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
lock-scroll
|
||||
modal
|
||||
modal-class="audit-investigation-overlay"
|
||||
>
|
||||
<div v-loading="loading" class="investigation-drawer">
|
||||
<ElTimeline v-if="eventItems.length">
|
||||
<ElTimelineItem
|
||||
v-for="item in eventItems"
|
||||
:key="item.event_id"
|
||||
:timestamp="formatDateTime(item.occurred_at)"
|
||||
placement="top"
|
||||
:type="auditResultMeta[item.result]?.type"
|
||||
>
|
||||
<article class="timeline-node">
|
||||
<div class="node-head">
|
||||
<strong>{{ item.action_name }}</strong>
|
||||
<ElTag size="small" :type="auditResultMeta[item.result]?.type">{{
|
||||
auditResultMeta[item.result]?.label || item.result
|
||||
}}</ElTag>
|
||||
</div>
|
||||
<p>{{ item.summary || '-' }}</p>
|
||||
<div class="node-meta">
|
||||
<span>{{ item.actor_name || item.actor_id || '-' }}</span>
|
||||
<span>{{ auditSourceLabels[item.source] || item.source }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
|
||||
<ElTimeline v-else-if="linkNodes.length">
|
||||
<ElTimelineItem
|
||||
v-for="node in linkNodes"
|
||||
:key="node.node_id"
|
||||
:timestamp="formatDateTime(node.occurred_at)"
|
||||
placement="top"
|
||||
:type="node.result === 'failed' ? 'danger' : node.reference_only ? 'info' : 'primary'"
|
||||
>
|
||||
<article class="timeline-node">
|
||||
<div class="node-head">
|
||||
<strong>{{ node.title }}</strong>
|
||||
<ElTag size="small" effect="plain">{{
|
||||
auditResultDisplay(node.result, node.result_name)
|
||||
}}</ElTag>
|
||||
</div>
|
||||
<p>{{ node.summary || '-' }}</p>
|
||||
<div class="node-meta">
|
||||
<span>{{ recordSourceLabels[node.record_source] || node.record_source }}</span>
|
||||
<ElTag v-if="node.reference_only" size="small" type="info" effect="plain">
|
||||
只读引用
|
||||
</ElTag>
|
||||
</div>
|
||||
</article>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
|
||||
<ElTimeline v-else-if="financeItems.length">
|
||||
<ElTimelineItem
|
||||
v-for="item in financeItems"
|
||||
:key="item.node_id"
|
||||
:timestamp="formatDateTime(item.occurred_at)"
|
||||
placement="top"
|
||||
:type="item.result === 'failed' ? 'danger' : 'primary'"
|
||||
>
|
||||
<article class="timeline-node">
|
||||
<div class="node-head">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<ElTag size="small" effect="plain">
|
||||
{{ auditResultDisplay(item.result, item.result_name) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<p v-if="item.amount !== null && item.amount !== undefined">
|
||||
金额:{{ formatMoney(item.amount, item.currency) }}
|
||||
</p>
|
||||
<div class="node-meta">
|
||||
<span>{{ item.code || '-' }}</span>
|
||||
<span>{{ item.record_source || '-' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
|
||||
<ElTimeline v-else-if="subjectItems.length">
|
||||
<ElTimelineItem
|
||||
v-for="(item, index) in subjectItems"
|
||||
:key="`${item.occurred_at}-${item.action_code}-${index}`"
|
||||
:timestamp="formatDateTime(item.occurred_at)"
|
||||
placement="top"
|
||||
:type="auditResultMeta[item.result]?.type"
|
||||
>
|
||||
<article class="timeline-node">
|
||||
<div class="node-head">
|
||||
<strong>{{ item.action_name || item.action_code }}</strong>
|
||||
<ElTag size="small" :type="auditResultMeta[item.result]?.type">
|
||||
{{ auditResultMeta[item.result]?.label || item.result }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<p>{{ item.subject_summary || '-' }}</p>
|
||||
</article>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
|
||||
<template v-else-if="eventDetail">
|
||||
<ElDescriptions :column="2" border>
|
||||
<ElDescriptionsItem label="动作">{{ eventDetail.action_name }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="发生时间">
|
||||
{{ formatDateTime(eventDetail.occurred_at) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作者">
|
||||
{{ eventDetail.actor_name || eventDetail.actor_id || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="来源">
|
||||
{{ auditSourceLabels[eventDetail.source] || eventDetail.source }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="结果">
|
||||
<ElTag :type="auditResultMeta[eventDetail.result]?.type">
|
||||
{{ auditResultMeta[eventDetail.result]?.label || eventDetail.result }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="风险">
|
||||
<ElTag :type="auditRiskMeta[eventDetail.risk_level]?.type">
|
||||
{{ auditRiskMeta[eventDetail.risk_level]?.label || eventDetail.risk_level }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="摘要" :span="2">
|
||||
{{ eventDetail.summary || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="eventDetail.error_summary" label="错误摘要" :span="2">
|
||||
{{ eventDetail.error_summary }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
|
||||
<template v-else-if="integrationDetail">
|
||||
<ElDescriptions :column="2" border>
|
||||
<ElDescriptionsItem label="提供方">
|
||||
{{ integrationDetail.identity.provider_name || integrationDetail.identity.provider }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="方向">
|
||||
{{ integrationDetail.identity.direction_name || integrationDetail.identity.direction }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作">
|
||||
{{ integrationDetail.identity.operation_name || integrationDetail.identity.operation }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="结果">
|
||||
<ElTag
|
||||
size="small"
|
||||
:type="integrationCategoryMeta[integrationDetail.result.category]?.type || 'info'"
|
||||
>
|
||||
{{ auditResultDisplay(integrationDetail.result.code, integrationDetail.result.name) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="耗时">
|
||||
{{ integrationDetail.result.duration_ms }} ms
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="HTTP 状态">
|
||||
{{ integrationDetail.result.http_status ?? '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="integrationDetail.identity.external_id" label="外部业务标识">
|
||||
{{ integrationDetail.identity.external_id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="integrationDetail.resource?.key" label="资源业务标识">
|
||||
{{ integrationDetail.resource.key }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="integrationDetail.result.provider_message"
|
||||
label="外部结果摘要"
|
||||
:span="2"
|
||||
>
|
||||
{{ integrationDetail.result.provider_message }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<section
|
||||
v-if="
|
||||
hasJsonContent(integrationDetail.content.request_summary) ||
|
||||
hasJsonContent(integrationDetail.content.response_summary) ||
|
||||
hasJsonContent(integrationDetail.content.metadata)
|
||||
"
|
||||
class="json-section"
|
||||
>
|
||||
<h4>交互内容摘要</h4>
|
||||
<pre v-if="hasJsonContent(integrationDetail.content.request_summary)">{{
|
||||
auditJson(integrationDetail.content.request_summary)
|
||||
}}</pre>
|
||||
<pre v-if="hasJsonContent(integrationDetail.content.response_summary)">{{
|
||||
auditJson(integrationDetail.content.response_summary)
|
||||
}}</pre>
|
||||
<pre v-if="hasJsonContent(integrationDetail.content.metadata)">{{
|
||||
auditJson(integrationDetail.content.metadata)
|
||||
}}</pre>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else-if="!loading" description="当前在线窗口内没有记录" />
|
||||
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<ElPagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type {
|
||||
AuditEventDetail,
|
||||
AuditEventView,
|
||||
AuditFinanceTimelineNode,
|
||||
AuditLinkTimelineNode,
|
||||
AuditSubjectActivity,
|
||||
IntegrationDetailResponse
|
||||
} from '@/types/api'
|
||||
import {
|
||||
auditJson,
|
||||
auditResultDisplay,
|
||||
auditResultMeta,
|
||||
auditRiskMeta,
|
||||
auditSourceLabels,
|
||||
integrationCategoryMeta
|
||||
} from '@/utils/business/audit'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import type { AuditInvestigationTarget } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
target?: AuditInvestigationTarget | null
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const titleMap: Record<AuditInvestigationTarget['mode'], string> = {
|
||||
event: '审计事件详情',
|
||||
actor: '操作者行为时间线',
|
||||
resource: '资源审计时间线',
|
||||
request: '请求链路',
|
||||
correlation: '业务关联链路',
|
||||
integration: '外部交互详情',
|
||||
finance: '财务审计时间线',
|
||||
agent: '资源审计时间线',
|
||||
enterprise: '资源审计时间线'
|
||||
}
|
||||
const title = computed(() => (props.target ? titleMap[props.target.mode] : '审计调查'))
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const eventItems = ref<AuditEventView[]>([])
|
||||
const linkNodes = ref<AuditLinkTimelineNode[]>([])
|
||||
const financeItems = ref<AuditFinanceTimelineNode[]>([])
|
||||
const subjectItems = ref<AuditSubjectActivity[]>([])
|
||||
const eventDetail = ref<AuditEventDetail>()
|
||||
const integrationDetail = ref<IntegrationDetailResponse>()
|
||||
let loadSequence = 0
|
||||
|
||||
const recordSourceLabels: Record<string, string> = {
|
||||
audit_event: '审计事件',
|
||||
integration_log: '外部交互',
|
||||
outbox_event: '可靠投递',
|
||||
asynq_task: '异步任务引用',
|
||||
domain_ledger_ref: '业务台账引用'
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
eventItems.value = []
|
||||
linkNodes.value = []
|
||||
financeItems.value = []
|
||||
subjectItems.value = []
|
||||
eventDetail.value = undefined
|
||||
integrationDetail.value = undefined
|
||||
total.value = 0
|
||||
}
|
||||
const hasJsonContent = (value?: Record<string, unknown> | null) =>
|
||||
Boolean(value && Object.keys(value).length)
|
||||
const formatMoney = (amount: number, currency?: string | null) => {
|
||||
const value = (amount / 100).toFixed(2)
|
||||
return currency === 'CNY' || !currency ? `¥${value}` : `${value} ${currency}`
|
||||
}
|
||||
const load = async () => {
|
||||
if (!props.target) return
|
||||
const sequence = ++loadSequence
|
||||
clear()
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.target.mode === 'event') {
|
||||
const data = (await AuditService.getEventDetail(props.target.id)).data
|
||||
if (sequence === loadSequence) eventDetail.value = data
|
||||
} else if (props.target.mode === 'actor') {
|
||||
const data = (
|
||||
await AuditService.getActorEvents(props.target.actorKind, props.target.id, {
|
||||
page: page.value,
|
||||
page_size: pageSize
|
||||
})
|
||||
).data
|
||||
if (sequence === loadSequence) {
|
||||
eventItems.value = data.items || []
|
||||
total.value = data.total
|
||||
}
|
||||
} else if (props.target.mode === 'resource') {
|
||||
const data = (
|
||||
await AuditService.getResourceTimeline(props.target.resourceType, props.target.id, {
|
||||
page: page.value,
|
||||
page_size: pageSize
|
||||
})
|
||||
).data
|
||||
if (sequence === loadSequence) {
|
||||
eventItems.value = data.items || []
|
||||
total.value = data.total
|
||||
}
|
||||
} else if (props.target.mode === 'request') {
|
||||
const nodes = (await AuditService.getRequestTimeline(props.target.id)).data.nodes || []
|
||||
if (sequence === loadSequence) linkNodes.value = nodes
|
||||
} else if (props.target.mode === 'correlation') {
|
||||
const nodes = (await AuditService.getCorrelationTimeline(props.target.id)).data.nodes || []
|
||||
if (sequence === loadSequence) linkNodes.value = nodes
|
||||
} else if (props.target.mode === 'finance') {
|
||||
const data = (
|
||||
await AuditService.getFinanceTimeline({
|
||||
[props.target.field]: props.target.value,
|
||||
page: page.value,
|
||||
page_size: pageSize
|
||||
})
|
||||
).data
|
||||
if (sequence === loadSequence) {
|
||||
financeItems.value = data.items || []
|
||||
total.value = data.total
|
||||
}
|
||||
} else if (props.target.mode === 'agent') {
|
||||
const data = (
|
||||
await AuditService.getAgentResourceActivities(
|
||||
props.target.resourceType,
|
||||
props.target.id,
|
||||
{
|
||||
page: page.value,
|
||||
page_size: pageSize
|
||||
}
|
||||
)
|
||||
).data
|
||||
if (sequence === loadSequence) {
|
||||
subjectItems.value = data.items || []
|
||||
total.value = data.total
|
||||
}
|
||||
} else if (props.target.mode === 'enterprise') {
|
||||
const data = (
|
||||
await AuditService.getEnterpriseResourceActivities(
|
||||
props.target.resourceType as 'iot_card' | 'device',
|
||||
props.target.id,
|
||||
{ page: page.value, page_size: pageSize }
|
||||
)
|
||||
).data
|
||||
if (sequence === loadSequence) {
|
||||
subjectItems.value = data.items || []
|
||||
total.value = data.total
|
||||
}
|
||||
} else {
|
||||
const data = (await AuditService.getIntegrationDetail(props.target.id)).data
|
||||
if (sequence === loadSequence) integrationDetail.value = data
|
||||
}
|
||||
} catch {
|
||||
if (sequence === loadSequence) {
|
||||
ElMessage.error(
|
||||
props.target.mode === 'agent' || props.target.mode === 'enterprise'
|
||||
? '活动不可用'
|
||||
: '审计调查数据加载失败'
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.target] as const,
|
||||
([open]) => {
|
||||
if (!open || !props.target) {
|
||||
loadSequence++
|
||||
clear()
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
page.value = 1
|
||||
load()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.investigation-drawer {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
padding: 14px 16px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-node p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.node-head,
|
||||
.node-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.json-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.json-section h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 360px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.audit-investigation-overlay .el-drawer__body {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
</style>
|
||||
23
src/components/business/audit/AuditInvestigationHost.vue
Normal file
23
src/components/business/audit/AuditInvestigationHost.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<AuditInvestigationDrawer
|
||||
v-model="auditInvestigationVisible"
|
||||
:target="auditInvestigationTarget"
|
||||
/>
|
||||
<AuditResourceSearchDialog
|
||||
v-model="auditResourceSearchVisible"
|
||||
:resource-type="auditResourceSearchType"
|
||||
:initial-keyword="auditResourceSearchKeyword"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AuditInvestigationDrawer from './AuditInvestigationDrawer.vue'
|
||||
import AuditResourceSearchDialog from './AuditResourceSearchDialog.vue'
|
||||
import {
|
||||
auditInvestigationTarget,
|
||||
auditInvestigationVisible,
|
||||
auditResourceSearchKeyword,
|
||||
auditResourceSearchType,
|
||||
auditResourceSearchVisible
|
||||
} from './investigationController'
|
||||
</script>
|
||||
154
src/components/business/audit/AuditInvestigationLinks.vue
Normal file
154
src/components/business/audit/AuditInvestigationLinks.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div v-if="hasLinks" class="investigation-links" aria-label="调查入口">
|
||||
<ElButton v-if="eventId" link type="primary" @click="openEvent">事件详情</ElButton>
|
||||
<ElButton v-if="actorRef" link type="primary" @click="openActor"> 操作者行为时间线 </ElButton>
|
||||
<ElButton
|
||||
v-for="resource in stableResourceRefs"
|
||||
:key="`${resource.resource_type}:${resource.resource_id}`"
|
||||
link
|
||||
type="primary"
|
||||
@click="openResource(resource)"
|
||||
>
|
||||
资源审计时间线
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-for="resource in searchableResourceRefs"
|
||||
:key="`${resource.resource_type}:${resource.resource_key}`"
|
||||
link
|
||||
type="primary"
|
||||
@click="searchResource(resource)"
|
||||
>
|
||||
精确注册资源
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="refs?.request_id"
|
||||
link
|
||||
type="primary"
|
||||
@click="openTimeline('request', refs.request_id)"
|
||||
>
|
||||
请求链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="refs?.correlation_id"
|
||||
link
|
||||
type="primary"
|
||||
@click="openTimeline('correlation', refs.correlation_id)"
|
||||
>
|
||||
业务关联链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-for="item in stableIntegrationRefs"
|
||||
:key="item.integration_id"
|
||||
link
|
||||
type="primary"
|
||||
@click="openIntegration(item.integration_id)"
|
||||
>
|
||||
外部交互
|
||||
</ElButton>
|
||||
</div>
|
||||
<span v-else-if="showEmpty" class="muted">无可靠调查引用</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type {
|
||||
AuditInvestigationRefs,
|
||||
AuditInvestigationResourceRef,
|
||||
AuditSearchResourceType
|
||||
} from '@/types/api'
|
||||
import { openAuditInvestigation, openAuditResourceSearch } from './investigationController'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
refs?: AuditInvestigationRefs | null
|
||||
currentEventId?: string
|
||||
showEmpty?: boolean
|
||||
}>(),
|
||||
{ currentEventId: '', showEmpty: true }
|
||||
)
|
||||
const searchableTypes = new Set<AuditSearchResourceType>([
|
||||
'iot_card',
|
||||
'device',
|
||||
'shop',
|
||||
'order',
|
||||
'refund'
|
||||
])
|
||||
const eventId = computed(() => {
|
||||
const id = props.refs?.event_id?.trim()
|
||||
return id && id !== props.currentEventId ? id : ''
|
||||
})
|
||||
const actorRef = computed(() => {
|
||||
const actor = props.refs?.actor_ref
|
||||
return actor?.kind && actor.id ? actor : null
|
||||
})
|
||||
const stableResourceRefs = computed(() =>
|
||||
(props.refs?.resource_refs || []).filter(
|
||||
(resource): resource is AuditInvestigationResourceRef & { resource_id: string } =>
|
||||
Boolean(resource.resource_type && resource.resource_id)
|
||||
)
|
||||
)
|
||||
const searchableResourceRefs = computed(() =>
|
||||
(props.refs?.resource_refs || []).filter(
|
||||
(
|
||||
resource
|
||||
): resource is AuditInvestigationResourceRef & {
|
||||
resource_type: AuditSearchResourceType
|
||||
resource_key: string
|
||||
} =>
|
||||
!resource.resource_id &&
|
||||
Boolean(resource.resource_key) &&
|
||||
searchableTypes.has(resource.resource_type as AuditSearchResourceType)
|
||||
)
|
||||
)
|
||||
const stableIntegrationRefs = computed(() =>
|
||||
(props.refs?.integration_refs || []).filter((item) => Boolean(item.integration_id))
|
||||
)
|
||||
const hasLinks = computed(() =>
|
||||
Boolean(
|
||||
eventId.value ||
|
||||
actorRef.value ||
|
||||
props.refs?.request_id ||
|
||||
props.refs?.correlation_id ||
|
||||
stableResourceRefs.value.length ||
|
||||
searchableResourceRefs.value.length ||
|
||||
stableIntegrationRefs.value.length
|
||||
)
|
||||
)
|
||||
|
||||
const openEvent = () => {
|
||||
if (eventId.value) openAuditInvestigation({ mode: 'event', id: eventId.value })
|
||||
}
|
||||
const openIntegration = (id: string) => openAuditInvestigation({ mode: 'integration', id })
|
||||
const openTimeline = (mode: 'request' | 'correlation', id: string) =>
|
||||
openAuditInvestigation({ mode, id })
|
||||
const openActor = () => {
|
||||
const actor = actorRef.value
|
||||
if (actor) {
|
||||
openAuditInvestigation({ mode: 'actor', actorKind: actor.kind, id: actor.id })
|
||||
}
|
||||
}
|
||||
const openResource = (resource: AuditInvestigationResourceRef & { resource_id: string }) =>
|
||||
openAuditInvestigation({
|
||||
mode: 'resource',
|
||||
resourceType: resource.resource_type,
|
||||
id: resource.resource_id
|
||||
})
|
||||
const searchResource = (
|
||||
resource: AuditInvestigationResourceRef & {
|
||||
resource_type: AuditSearchResourceType
|
||||
resource_key: string
|
||||
}
|
||||
) => openAuditResourceSearch(resource.resource_type, resource.resource_key)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.investigation-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
269
src/components/business/audit/AuditResourceSearchDialog.vue
Normal file
269
src/components/business/audit/AuditResourceSearchDialog.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="精确注册资源"
|
||||
width="min(880px, 92vw)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
lock-scroll
|
||||
>
|
||||
<div v-loading="loading" class="result-list">
|
||||
<ElCard
|
||||
v-for="row in items"
|
||||
:key="`${row.resource_type}:${row.resource_id || row.resource_key}`"
|
||||
shadow="never"
|
||||
class="resource-card"
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<strong>业务信息</strong>
|
||||
<ElButton link type="primary" :disabled="!row.resource_id" @click="openTimeline(row)">
|
||||
<template #icon>
|
||||
<ElIcon><Clock /></ElIcon>
|
||||
</template>
|
||||
审计时间线
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="snapshotDetails(row).length" class="snapshot-grid">
|
||||
<div v-for="item in snapshotDetails(row)" :key="item.label" class="snapshot-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="snapshot-item">
|
||||
<span>业务标识</span>
|
||||
<strong>{{ row.resource_key || '-' }}</strong>
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElEmpty v-if="!loading && !items.length" description="未找到匹配的注册资源" />
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<ElPagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="search(false)"
|
||||
/>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Clock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type { AuditResourceCandidate, AuditSearchResourceType } from '@/types/api'
|
||||
import { openAuditInvestigation } from './investigationController'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
resourceType: AuditSearchResourceType
|
||||
initialKeyword?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = ref(0)
|
||||
const items = ref<AuditResourceCandidate[]>([])
|
||||
let searchSequence = 0
|
||||
const resourceTypeLabels: Record<AuditSearchResourceType, string> = {
|
||||
iot_card: 'IoT卡',
|
||||
device: '设备',
|
||||
shop: '店铺',
|
||||
order: '订单',
|
||||
refund: '退款单'
|
||||
}
|
||||
|
||||
const resourceTypeLabel = computed(() => resourceTypeLabels[props.resourceType])
|
||||
|
||||
interface SnapshotField {
|
||||
label: string
|
||||
keys: string[]
|
||||
format?: (value: unknown) => string
|
||||
}
|
||||
|
||||
const textValue = (value: unknown) => String(value ?? '-')
|
||||
const refundStatusValue = (value: unknown) =>
|
||||
({ 1: '待审批', 2: '已通过', 3: '已拒绝', 4: '已退回' })[Number(value)] || textValue(value)
|
||||
const centAmountValue = (value: unknown) => {
|
||||
const amount = Number(value)
|
||||
return Number.isFinite(amount) ? `¥${(amount / 100).toFixed(2)}` : textValue(value)
|
||||
}
|
||||
const snapshotFields: Record<AuditSearchResourceType, SnapshotField[]> = {
|
||||
iot_card: [
|
||||
{ label: 'ICCID', keys: ['iccid', 'iccid_19', 'iccid_20'] },
|
||||
{ label: '虚拟号', keys: ['virtual_no'] },
|
||||
{ label: 'MSISDN', keys: ['msisdn'] },
|
||||
{ label: '运营商', keys: ['carrier_name', 'carrier_type'] }
|
||||
],
|
||||
device: [
|
||||
{ label: '虚拟号', keys: ['virtual_no'] },
|
||||
{ label: 'IMEI', keys: ['imei'] },
|
||||
{ label: 'SN', keys: ['sn', 'serial_no'] },
|
||||
{ label: '设备编号', keys: ['device_no'] }
|
||||
],
|
||||
shop: [
|
||||
{ label: '店铺编号', keys: ['shop_code'] },
|
||||
{ label: '店铺名称', keys: ['shop_name', 'name'] },
|
||||
{ label: '联系人', keys: ['contact_name'] },
|
||||
{ label: '联系电话', keys: ['contact_phone', 'phone'] }
|
||||
],
|
||||
order: [
|
||||
{ label: '订单号', keys: ['order_no'] },
|
||||
{ label: '资产编号', keys: ['asset_identifier'] },
|
||||
{ label: '订单状态', keys: ['status_name'] },
|
||||
{ label: '订单金额', keys: ['total_amount', 'order_amount'], format: centAmountValue }
|
||||
],
|
||||
refund: [
|
||||
{ label: '退款单号', keys: ['refund_no'] },
|
||||
{ label: '订单号', keys: ['order_no'] },
|
||||
{ label: '资产编号', keys: ['asset_identifier'] },
|
||||
{
|
||||
label: '申请退款金额',
|
||||
keys: ['requested_refund_amount'],
|
||||
format: centAmountValue
|
||||
},
|
||||
{ label: '状态', keys: ['status_name', 'status'], format: refundStatusValue }
|
||||
]
|
||||
}
|
||||
|
||||
const snapshotDetails = (row: AuditResourceCandidate) => {
|
||||
const snapshot = row.identity_snapshot || {}
|
||||
return snapshotFields[row.resource_type].flatMap((field) => {
|
||||
const key = field.keys.find((candidate) => {
|
||||
const value = snapshot[candidate]
|
||||
return value !== undefined && value !== null && value !== ''
|
||||
})
|
||||
if (!key) return []
|
||||
const value = snapshot[key]
|
||||
return [{ label: field.label, value: field.format?.(value) || textValue(value) }]
|
||||
})
|
||||
}
|
||||
const search = async (resetPage: boolean) => {
|
||||
const exactKeyword = keyword.value.trim()
|
||||
if (!exactKeyword) {
|
||||
ElMessage.warning(`当前记录缺少可用于搜索${resourceTypeLabel.value}的业务标识`)
|
||||
return
|
||||
}
|
||||
if (resetPage) page.value = 1
|
||||
const sequence = ++searchSequence
|
||||
loading.value = true
|
||||
try {
|
||||
const data = (
|
||||
await AuditService.searchResources({
|
||||
resource_type: props.resourceType,
|
||||
keyword: exactKeyword,
|
||||
page: page.value,
|
||||
page_size: pageSize
|
||||
})
|
||||
).data
|
||||
if (sequence === searchSequence) {
|
||||
items.value = data.items || []
|
||||
total.value = data.total
|
||||
}
|
||||
} catch {
|
||||
if (sequence === searchSequence) ElMessage.error('注册资源搜索失败')
|
||||
} finally {
|
||||
if (sequence === searchSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openTimeline = (row: AuditResourceCandidate) => {
|
||||
if (!row.resource_id) return
|
||||
visible.value = false
|
||||
openAuditInvestigation({
|
||||
mode: 'resource',
|
||||
resourceType: row.resource_type,
|
||||
id: row.resource_id
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
searchSequence++
|
||||
loading.value = false
|
||||
items.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
keyword.value = props.initialKeyword?.trim() || ''
|
||||
page.value = 1
|
||||
items.value = []
|
||||
total.value = 0
|
||||
if (keyword.value) void search(true)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-list {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.resource-card + .resource-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-item {
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
|
||||
span,
|
||||
strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.snapshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
src/components/business/audit/investigationController.ts
Normal file
25
src/components/business/audit/investigationController.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
import type { AuditInvestigationTarget } from './types'
|
||||
import type { AuditSearchResourceType } from '@/types/api'
|
||||
|
||||
export const auditInvestigationVisible = ref(false)
|
||||
export const auditInvestigationTarget = ref<AuditInvestigationTarget>()
|
||||
export const auditResourceSearchVisible = ref(false)
|
||||
export const auditResourceSearchType = ref<AuditSearchResourceType>('iot_card')
|
||||
export const auditResourceSearchKeyword = ref('')
|
||||
|
||||
export const openAuditInvestigation = (target: AuditInvestigationTarget) => {
|
||||
auditResourceSearchVisible.value = false
|
||||
auditInvestigationTarget.value = target
|
||||
auditInvestigationVisible.value = true
|
||||
}
|
||||
|
||||
export const openAuditResourceSearch = (
|
||||
resourceType: AuditSearchResourceType,
|
||||
keyword?: string | null
|
||||
) => {
|
||||
auditInvestigationVisible.value = false
|
||||
auditResourceSearchType.value = resourceType
|
||||
auditResourceSearchKeyword.value = keyword?.trim() || ''
|
||||
auditResourceSearchVisible.value = true
|
||||
}
|
||||
24
src/components/business/audit/types.ts
Normal file
24
src/components/business/audit/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AuditActorKind, AuditSubjectResourceType } from '@/types/api'
|
||||
|
||||
export type AuditFinanceField =
|
||||
| 'shop_id'
|
||||
| 'wallet_id'
|
||||
| 'order_id'
|
||||
| 'order_no'
|
||||
| 'payment_id'
|
||||
| 'payment_no'
|
||||
| 'refund_id'
|
||||
| 'refund_no'
|
||||
| 'recharge_id'
|
||||
| 'recharge_no'
|
||||
| 'approval_instance_id'
|
||||
| 'third_party_trade_no'
|
||||
| 'correlation_id'
|
||||
|
||||
export type AuditInvestigationTarget =
|
||||
| { mode: 'event'; id: string }
|
||||
| { mode: 'actor'; id: string; actorKind: AuditActorKind }
|
||||
| { mode: 'resource'; id: string; resourceType: string }
|
||||
| { mode: 'agent' | 'enterprise'; id: string; resourceType: AuditSubjectResourceType }
|
||||
| { mode: 'finance'; field: AuditFinanceField; value: string | number }
|
||||
| { mode: 'request' | 'correlation' | 'integration'; id: string }
|
||||
Reference in New Issue
Block a user