This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<ChunkErrorBoundary>
|
||||
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
||||
<RouterView></RouterView>
|
||||
<AuditInvestigationHost />
|
||||
</ElConfigProvider>
|
||||
</ChunkErrorBoundary>
|
||||
</template>
|
||||
@@ -17,6 +18,7 @@
|
||||
import { checkStorageCompatibility } from '@/utils'
|
||||
import ChunkErrorBoundary from '@/components/core/others/ChunkErrorBoundary.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import AuditInvestigationHost from '@/components/business/audit/AuditInvestigationHost.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { language } = storeToRefs(userStore)
|
||||
|
||||
124
src/api/modules/audit.ts
Normal file
124
src/api/modules/audit.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
import type {
|
||||
AuditActorEventQuery,
|
||||
AuditActorKind,
|
||||
AuditEventDetail,
|
||||
AuditEventPage,
|
||||
AuditEventQuery,
|
||||
AuditFinanceQuery,
|
||||
AuditFinanceTimelinePage,
|
||||
AuditLinkTimeline,
|
||||
AuditResourceSearchPage,
|
||||
AuditResourceSearchQuery,
|
||||
AuditResourceTimelineQuery,
|
||||
AuditRiskEventPage,
|
||||
AuditRiskEventQuery,
|
||||
AuditRiskOverview,
|
||||
AuditRiskQuery,
|
||||
AuditSubjectActivityPage,
|
||||
AuditSubjectActivityQuery,
|
||||
AuditSubjectResourceType,
|
||||
BaseResponse,
|
||||
IntegrationDetailResponse,
|
||||
IntegrationListPage,
|
||||
IntegrationOverview,
|
||||
IntegrationQuery
|
||||
} from '@/types/api'
|
||||
|
||||
export class AuditService extends BaseService {
|
||||
static getEvents(params?: AuditEventQuery): Promise<BaseResponse<AuditEventPage>> {
|
||||
return this.get('/api/admin/audit/events', params)
|
||||
}
|
||||
|
||||
static getEventDetail(eventId: string): Promise<BaseResponse<AuditEventDetail>> {
|
||||
return this.get(`/api/admin/audit/events/${encodeURIComponent(eventId)}`)
|
||||
}
|
||||
|
||||
static getActorEvents(
|
||||
kind: AuditActorKind,
|
||||
id: string,
|
||||
params?: AuditActorEventQuery
|
||||
): Promise<BaseResponse<AuditEventPage>> {
|
||||
return this.get(
|
||||
`/api/admin/audit/actors/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/events`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static searchResources(
|
||||
params: AuditResourceSearchQuery
|
||||
): Promise<BaseResponse<AuditResourceSearchPage>> {
|
||||
return this.get('/api/admin/audit/resources/search', params)
|
||||
}
|
||||
|
||||
static getResourceTimeline(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
params?: AuditResourceTimelineQuery
|
||||
): Promise<BaseResponse<AuditEventPage>> {
|
||||
return this.get(
|
||||
`/api/admin/audit/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/timeline`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static getRequestTimeline(requestId: string): Promise<BaseResponse<AuditLinkTimeline>> {
|
||||
return this.get(`/api/admin/audit/requests/${encodeURIComponent(requestId)}/timeline`)
|
||||
}
|
||||
|
||||
static getCorrelationTimeline(correlationId: string): Promise<BaseResponse<AuditLinkTimeline>> {
|
||||
return this.get(`/api/admin/audit/correlations/${encodeURIComponent(correlationId)}/timeline`)
|
||||
}
|
||||
|
||||
static getFinanceTimeline(
|
||||
params: AuditFinanceQuery
|
||||
): Promise<BaseResponse<AuditFinanceTimelinePage>> {
|
||||
return this.get('/api/admin/audit/finance/timeline', params)
|
||||
}
|
||||
|
||||
static getRiskOverview(params?: AuditRiskQuery): Promise<BaseResponse<AuditRiskOverview>> {
|
||||
return this.get('/api/admin/audit/risks/overview', params)
|
||||
}
|
||||
|
||||
static getRiskEvents(params?: AuditRiskEventQuery): Promise<BaseResponse<AuditRiskEventPage>> {
|
||||
return this.get('/api/admin/audit/risks/events', params)
|
||||
}
|
||||
|
||||
static getIntegrationOverview(
|
||||
params?: IntegrationQuery & { bucket?: 'hour' | 'day' }
|
||||
): Promise<BaseResponse<IntegrationOverview>> {
|
||||
return this.get('/api/admin/audit/integrations/overview', params)
|
||||
}
|
||||
|
||||
static getIntegrations(params?: IntegrationQuery): Promise<BaseResponse<IntegrationListPage>> {
|
||||
return this.get('/api/admin/audit/integrations', params)
|
||||
}
|
||||
|
||||
static getIntegrationDetail(
|
||||
integrationId: string
|
||||
): Promise<BaseResponse<IntegrationDetailResponse>> {
|
||||
return this.get(`/api/admin/audit/integrations/${encodeURIComponent(integrationId)}`)
|
||||
}
|
||||
|
||||
static getAgentResourceActivities(
|
||||
resourceType: AuditSubjectResourceType,
|
||||
identifier: string,
|
||||
params?: AuditSubjectActivityQuery
|
||||
): Promise<BaseResponse<AuditSubjectActivityPage>> {
|
||||
return this.get(
|
||||
`/api/admin/agent/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static getEnterpriseResourceActivities(
|
||||
resourceType: Extract<AuditSubjectResourceType, 'iot_card' | 'device'>,
|
||||
identifier: string,
|
||||
params?: AuditSubjectActivityQuery
|
||||
): Promise<BaseResponse<AuditSubjectActivityPage>> {
|
||||
return this.get(
|
||||
`/api/admin/enterprise/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export { ExportTaskService } from './exportTask'
|
||||
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
|
||||
export { BulkPurchaseService } from './bulkPurchase'
|
||||
export { NotificationService } from './notification'
|
||||
export { AuditService } from './audit'
|
||||
export { WecomService } from './wecom'
|
||||
|
||||
// TODO: 按需添加其他业务模块
|
||||
|
||||
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 }
|
||||
27
src/config/constants/audit.ts
Normal file
27
src/config/constants/audit.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** 八月审计链路页面与按钮权限。 */
|
||||
export const AUDIT_PERMISSIONS = {
|
||||
centerPage: 'audit:center_view',
|
||||
eventList: 'audit:event_list',
|
||||
eventDetail: 'audit:event_detail',
|
||||
actorTimeline: 'audit:actor_timeline',
|
||||
resourceSearch: 'audit:resource_search',
|
||||
resourceTimeline: 'audit:resource_timeline',
|
||||
requestTimeline: 'audit:request_timeline',
|
||||
correlationTimeline: 'audit:correlation_timeline',
|
||||
financeTimeline: 'audit:finance_timeline',
|
||||
riskPage: 'audit:risk_view',
|
||||
riskEvents: 'audit:risk_events',
|
||||
integrationPage: 'audit:integration_view',
|
||||
integrationDetail: 'audit:integration_detail',
|
||||
agentActivity: 'audit:agent_resource_activity',
|
||||
enterpriseActivity: 'audit:enterprise_resource_activity',
|
||||
cardEntry: 'audit:card_entry',
|
||||
deviceEntry: 'audit:device_entry',
|
||||
shopEntry: 'audit:shop_entry',
|
||||
enterpriseEntry: 'audit:enterprise_entry',
|
||||
orderEntry: 'audit:order_entry',
|
||||
refundEntry: 'audit:refund_entry',
|
||||
walletEntry: 'audit:wallet_entry'
|
||||
} as const
|
||||
|
||||
export type AuditPermission = (typeof AUDIT_PERMISSIONS)[keyof typeof AUDIT_PERMISSIONS]
|
||||
@@ -2,6 +2,7 @@ import { RoutesAlias } from '../routesAlias'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
|
||||
/**
|
||||
* 菜单列表、异步路由
|
||||
@@ -692,6 +693,72 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
]
|
||||
},
|
||||
|
||||
// 审计中心
|
||||
{
|
||||
path: '/audit',
|
||||
name: 'AuditCenter',
|
||||
component: RoutesAlias.Home,
|
||||
meta: {
|
||||
title: '审计中心',
|
||||
icon: '',
|
||||
permissions: [AUDIT_PERMISSIONS.centerPage]
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'events',
|
||||
name: 'AuditEvents',
|
||||
component: RoutesAlias.AuditEvents,
|
||||
meta: {
|
||||
title: '审计事件',
|
||||
permissions: [AUDIT_PERMISSIONS.eventList],
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'events/:eventId',
|
||||
name: 'AuditEventDetail',
|
||||
component: RoutesAlias.AuditEventDetail,
|
||||
meta: {
|
||||
title: '审计事件详情',
|
||||
permissions: [AUDIT_PERMISSIONS.eventDetail],
|
||||
isHide: true,
|
||||
keepAlive: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'risks',
|
||||
name: 'AuditRisks',
|
||||
component: RoutesAlias.AuditRisks,
|
||||
meta: {
|
||||
title: '风险中心',
|
||||
permissions: [AUDIT_PERMISSIONS.riskPage],
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
name: 'AuditIntegrations',
|
||||
component: RoutesAlias.AuditIntegrations,
|
||||
meta: {
|
||||
title: '外部交互',
|
||||
permissions: [AUDIT_PERMISSIONS.integrationPage],
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'integrations/:integrationId',
|
||||
name: 'AuditIntegrationDetail',
|
||||
component: RoutesAlias.AuditIntegrationDetail,
|
||||
meta: {
|
||||
title: '外部交互详情',
|
||||
permissions: [AUDIT_PERMISSIONS.integrationDetail],
|
||||
isHide: true,
|
||||
keepAlive: false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 佣金管理
|
||||
{
|
||||
path: '/commission',
|
||||
|
||||
@@ -23,6 +23,13 @@ export enum RoutesAlias {
|
||||
CarrierManagement = '/system/carrier-management', // 运营商管理
|
||||
UserCenter = '/system/user-center', // 用户中心
|
||||
|
||||
// 审计中心
|
||||
AuditEvents = '/audit/events',
|
||||
AuditEventDetail = '/audit/events/detail',
|
||||
AuditRisks = '/audit/risks',
|
||||
AuditIntegrations = '/audit/integrations',
|
||||
AuditIntegrationDetail = '/audit/integrations/detail',
|
||||
|
||||
// 套餐管理
|
||||
PackageList = '/package-management/package-list', // 套餐列表
|
||||
PackageDetail = '/package-management/package-list/detail', // 套餐列表详情
|
||||
|
||||
449
src/types/api/audit.ts
Normal file
449
src/types/api/audit.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import type { PaginationParams } from './common'
|
||||
|
||||
export type AuditActorKind =
|
||||
| 'account'
|
||||
| 'personal_customer'
|
||||
| 'openapi'
|
||||
| 'system_task'
|
||||
| 'scheduled_job'
|
||||
| 'external_system'
|
||||
export type AuditResult = 'success' | 'failed' | 'denied' | 'partial' | 'unknown'
|
||||
export type AuditRiskLevel = 'low' | 'normal' | 'high' | 'critical'
|
||||
export type AuditCategory =
|
||||
| 'configuration'
|
||||
| 'reliability'
|
||||
| 'asset'
|
||||
| 'security'
|
||||
| 'identity'
|
||||
| 'business'
|
||||
export type AuditSource =
|
||||
| 'admin_api'
|
||||
| 'personal_api'
|
||||
| 'openapi'
|
||||
| 'worker'
|
||||
| 'scheduler'
|
||||
| 'callback'
|
||||
export type AuditScopeType = 'platform' | 'shop' | 'personal_customer'
|
||||
export type IntegrationProvider =
|
||||
| 'ctcc'
|
||||
| 'cmcc'
|
||||
| 'cucc'
|
||||
| 'wechat_pay'
|
||||
| 'alipay'
|
||||
| 'fuiou'
|
||||
| 'wecom'
|
||||
| 'gateway'
|
||||
export type IntegrationDirection = 'inbound' | 'outbound'
|
||||
export type IntegrationResult =
|
||||
| 'pending'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'unknown'
|
||||
| 'not_found'
|
||||
| 'invalid_payload'
|
||||
| 'conflict'
|
||||
| 'ignored'
|
||||
| 'merged'
|
||||
| 'rate_limited'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
export type IntegrationResultCategory =
|
||||
| 'processing'
|
||||
| 'succeeded'
|
||||
| 'indeterminate'
|
||||
| 'failed'
|
||||
| 'not_sent'
|
||||
export type AuditSubjectResourceType =
|
||||
| 'iot_card'
|
||||
| 'device'
|
||||
| 'asset_allocation_record'
|
||||
| 'exchange_order'
|
||||
| 'shop'
|
||||
| 'enterprise'
|
||||
|
||||
export interface AuditRetentionInfo {
|
||||
online_from: string
|
||||
archived_before: string
|
||||
timezone: string
|
||||
}
|
||||
export interface AuditActorRef {
|
||||
kind: AuditActorKind
|
||||
id: string
|
||||
}
|
||||
export interface AuditInvestigationResourceRef {
|
||||
resource_type: string
|
||||
resource_id?: string | null
|
||||
resource_key?: string | null
|
||||
display_name?: string | null
|
||||
}
|
||||
export interface AuditInvestigationRefs {
|
||||
event_id?: string | null
|
||||
actor_ref?: AuditActorRef | null
|
||||
resource_refs?: AuditInvestigationResourceRef[]
|
||||
request_id?: string | null
|
||||
correlation_id?: string | null
|
||||
integration_refs?: Array<{ integration_id: string }>
|
||||
}
|
||||
export interface AuditResourceView extends AuditInvestigationResourceRef {
|
||||
relation: 'primary' | 'affected' | 'reference'
|
||||
role: string
|
||||
display_name: string
|
||||
subject_summary?: string | null
|
||||
subject_visibility?: 'internal_only' | 'subject_result' | 'subject_detail'
|
||||
identity_snapshot?: Record<string, unknown> | null
|
||||
before_data?: Record<string, unknown> | null
|
||||
after_data?: Record<string, unknown> | null
|
||||
subject_data?: Record<string, unknown> | null
|
||||
sort_order?: number | null
|
||||
created_at?: string | null
|
||||
}
|
||||
export interface AuditEventView {
|
||||
event_id: string
|
||||
parent_event_id?: string | null
|
||||
action_code: string
|
||||
action_name: string
|
||||
category: AuditCategory
|
||||
summary: string
|
||||
result: AuditResult
|
||||
risk_level: AuditRiskLevel
|
||||
source: AuditSource
|
||||
actor_kind: AuditActorKind
|
||||
actor_id: string
|
||||
actor_name: string
|
||||
actor_shop_id?: number | null
|
||||
actor_shop_name?: string | null
|
||||
actor_enterprise_id?: number | null
|
||||
actor_enterprise_name?: string | null
|
||||
scope_type: AuditScopeType
|
||||
scope_id?: string | null
|
||||
scope_name?: string | null
|
||||
request_id?: string | null
|
||||
correlation_id?: string | null
|
||||
request_method?: string | null
|
||||
request_path?: string | null
|
||||
ip_address?: string | null
|
||||
user_agent?: string | null
|
||||
success_count?: number | null
|
||||
fail_count?: number | null
|
||||
batch_total?: number | null
|
||||
error_code?: string | null
|
||||
error_summary?: string | null
|
||||
content_hash?: string | null
|
||||
occurred_at: string
|
||||
created_at: string
|
||||
resources: AuditResourceView[]
|
||||
investigation_refs?: AuditInvestigationRefs | null
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
export interface AuditEventDetail extends AuditEventView {
|
||||
retention: AuditRetentionInfo
|
||||
}
|
||||
export interface AuditPage<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
retention: AuditRetentionInfo
|
||||
}
|
||||
export type AuditEventPage = AuditPage<AuditEventView>
|
||||
export interface AuditEventQuery extends PaginationParams {
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
action?: string
|
||||
category?: AuditCategory
|
||||
actor_kind?: AuditActorKind
|
||||
actor_id?: string
|
||||
source?: AuditSource
|
||||
result?: AuditResult
|
||||
risk?: AuditRiskLevel
|
||||
scope_type?: AuditScopeType
|
||||
scope_id?: string
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
resource_key?: string
|
||||
request_id?: string
|
||||
correlation_id?: string
|
||||
}
|
||||
export interface AuditActorEventQuery extends PaginationParams {
|
||||
action?: string
|
||||
result?: AuditResult
|
||||
risk?: AuditRiskLevel
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
}
|
||||
export interface AuditResourceTimelineQuery extends PaginationParams {
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
action?: string
|
||||
result?: AuditResult
|
||||
}
|
||||
export type AuditSearchResourceType = 'iot_card' | 'device' | 'shop' | 'order' | 'refund'
|
||||
export interface AuditResourceSearchQuery extends PaginationParams {
|
||||
resource_type: AuditSearchResourceType
|
||||
keyword: string
|
||||
}
|
||||
export interface AuditResourceCandidate extends AuditInvestigationResourceRef {
|
||||
resource_type: AuditSearchResourceType
|
||||
display_name: string
|
||||
historical: boolean
|
||||
identity_snapshot?: Record<string, unknown> | null
|
||||
}
|
||||
export type AuditResourceSearchPage = AuditPage<AuditResourceCandidate>
|
||||
|
||||
export interface AuditLinkTimelineNode {
|
||||
node_id: string
|
||||
record_source: 'audit_event' | 'integration_log' | 'outbox_event'
|
||||
code: string
|
||||
title: string
|
||||
summary: string
|
||||
result: string
|
||||
result_name: string
|
||||
occurred_at: string
|
||||
request_id?: string | null
|
||||
correlation_id?: string | null
|
||||
parent_event_id?: string | null
|
||||
reference_only?: boolean
|
||||
resources?: AuditInvestigationResourceRef[]
|
||||
investigation_refs?: AuditInvestigationRefs | null
|
||||
fidelity?: Record<string, boolean | null> | null
|
||||
}
|
||||
export interface AuditLinkTimeline {
|
||||
request_id?: string | null
|
||||
correlation_id?: string | null
|
||||
access_log_lookup_request_id?: string | null
|
||||
nodes: AuditLinkTimelineNode[]
|
||||
retention: AuditRetentionInfo
|
||||
}
|
||||
|
||||
export interface AuditFinanceQuery extends PaginationParams {
|
||||
shop_id?: number
|
||||
wallet_id?: number
|
||||
order_id?: number
|
||||
order_no?: string
|
||||
payment_id?: number
|
||||
payment_no?: string
|
||||
refund_id?: number
|
||||
refund_no?: string
|
||||
recharge_id?: number
|
||||
recharge_no?: string
|
||||
approval_instance_id?: number
|
||||
third_party_trade_no?: string
|
||||
actor_kind?: AuditActorKind
|
||||
actor_id?: string
|
||||
correlation_id?: string
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
}
|
||||
export interface AuditFinanceTimelineNode {
|
||||
node_id: string
|
||||
record_source: string
|
||||
code: string
|
||||
title: string
|
||||
result: string
|
||||
result_name: string
|
||||
occurred_at: string
|
||||
shop_id?: number | null
|
||||
amount?: number | null
|
||||
balance_before?: number | null
|
||||
balance_after?: number | null
|
||||
currency?: string | null
|
||||
wallet?: { resource_type: 'agent_wallet' | 'asset_wallet'; wallet_id: number } | null
|
||||
amount_authority?: {
|
||||
authoritative: boolean
|
||||
table?: string
|
||||
field?: string
|
||||
conflict_rule?: string
|
||||
} | null
|
||||
facts?: Record<string, unknown> | null
|
||||
investigation_refs?: AuditInvestigationRefs | null
|
||||
}
|
||||
export type AuditFinanceTimelinePage = AuditPage<AuditFinanceTimelineNode>
|
||||
|
||||
export interface AuditRiskQuery {
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
risk?: AuditRiskLevel
|
||||
result?: AuditResult
|
||||
action?: string
|
||||
source?: AuditSource
|
||||
}
|
||||
export interface AuditRiskEventQuery extends AuditRiskQuery, PaginationParams {}
|
||||
export interface AuditNamedCount {
|
||||
code: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
export interface AuditRiskOverview {
|
||||
total: number
|
||||
bucket: 'hour' | 'day'
|
||||
risks: AuditNamedCount[]
|
||||
results: AuditNamedCount[]
|
||||
actions: AuditNamedCount[]
|
||||
sources: AuditNamedCount[]
|
||||
signals: AuditNamedCount[]
|
||||
trend: Array<Record<string, string | number>>
|
||||
retention: AuditRetentionInfo
|
||||
}
|
||||
export type AuditRiskEventPage = AuditPage<AuditEventView>
|
||||
|
||||
export interface IntegrationQuery extends PaginationParams {
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
integration_id?: string
|
||||
provider?: IntegrationProvider
|
||||
direction?: IntegrationDirection
|
||||
operation?: string
|
||||
result?: IntegrationResult
|
||||
result_category?: IntegrationResultCategory
|
||||
external_id?: string
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
resource_key?: string
|
||||
trigger_source?: string
|
||||
trigger_scene?: string
|
||||
trigger_series?: string
|
||||
state_changed?: boolean
|
||||
http_status?: number
|
||||
provider_code?: string
|
||||
request_id?: string
|
||||
correlation_id?: string
|
||||
}
|
||||
export interface IntegrationResourceView {
|
||||
type?: string | null
|
||||
id?: string | null
|
||||
key?: string | null
|
||||
}
|
||||
export interface IntegrationIdentityView {
|
||||
integration_id: string
|
||||
provider: IntegrationProvider
|
||||
provider_name: string
|
||||
direction: IntegrationDirection
|
||||
direction_name: string
|
||||
operation: string
|
||||
operation_name: string
|
||||
external_id?: string | null
|
||||
}
|
||||
export interface IntegrationResultView {
|
||||
category: IntegrationResultCategory
|
||||
code: IntegrationResult
|
||||
name: string
|
||||
duration_ms: number
|
||||
http_status?: number | null
|
||||
provider_code?: string | null
|
||||
provider_message?: string | null
|
||||
recovery_strategy?: string | null
|
||||
state_changed: boolean
|
||||
}
|
||||
export interface IntegrationLinkageView {
|
||||
audit_event_id?: number | null
|
||||
request_id?: string | null
|
||||
correlation_id?: string | null
|
||||
}
|
||||
export interface IntegrationTriggerView {
|
||||
attempt: number
|
||||
scene?: string | null
|
||||
series?: string | null
|
||||
source?: string | null
|
||||
}
|
||||
export interface IntegrationTimestampView {
|
||||
created_at: string
|
||||
scheduled_at?: string | null
|
||||
started_at?: string | null
|
||||
updated_at: string
|
||||
}
|
||||
export interface IntegrationContentView {
|
||||
content_hash: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
request_summary?: Record<string, unknown> | null
|
||||
response_summary?: Record<string, unknown> | null
|
||||
}
|
||||
export interface IntegrationFidelityView {
|
||||
attempt_sequence_reliable: boolean
|
||||
correlation_available: boolean
|
||||
provider_message_fidelity: string
|
||||
resource_id_available: boolean
|
||||
trigger_series_available: boolean
|
||||
}
|
||||
export interface IntegrationAttemptView {
|
||||
attempt: number
|
||||
created_at: string
|
||||
duration_ms: number
|
||||
integration_id: string
|
||||
operation: string
|
||||
operation_name: string
|
||||
result: IntegrationResult
|
||||
result_category: IntegrationResultCategory
|
||||
result_name: string
|
||||
sent: boolean
|
||||
state_changed: boolean
|
||||
}
|
||||
export interface IntegrationListItem {
|
||||
integration_id: string
|
||||
provider: IntegrationProvider
|
||||
provider_name: string
|
||||
direction: IntegrationDirection
|
||||
direction_name: string
|
||||
operation: string
|
||||
operation_name: string
|
||||
result: IntegrationResult
|
||||
result_name: string
|
||||
result_category: IntegrationResultCategory
|
||||
state_changed: boolean
|
||||
duration_ms?: number | null
|
||||
request_id?: string | null
|
||||
correlation_id?: string | null
|
||||
created_at: string
|
||||
resource?: IntegrationResourceView | null
|
||||
}
|
||||
export type IntegrationListPage = AuditPage<IntegrationListItem>
|
||||
export interface IntegrationOverview {
|
||||
total: number
|
||||
anomaly_count: number
|
||||
stale_pending_count: number
|
||||
state_changed_count: number
|
||||
unknown_count: number
|
||||
average_duration_ms: number
|
||||
p95_duration_ms: number
|
||||
providers: AuditNamedCount[]
|
||||
directions: AuditNamedCount[]
|
||||
results: Array<AuditNamedCount & { category: IntegrationResultCategory }>
|
||||
trend: Array<Record<string, string | number>>
|
||||
retention: AuditRetentionInfo
|
||||
}
|
||||
export interface IntegrationDetailResponse {
|
||||
identity: IntegrationIdentityView
|
||||
result: IntegrationResultView
|
||||
resource?: IntegrationResourceView | null
|
||||
linkage: IntegrationLinkageView
|
||||
trigger: IntegrationTriggerView
|
||||
timestamps: IntegrationTimestampView
|
||||
content: IntegrationContentView
|
||||
fidelity: IntegrationFidelityView
|
||||
attempts: IntegrationAttemptView[]
|
||||
retention: AuditRetentionInfo
|
||||
}
|
||||
|
||||
export interface AuditSubjectActivityQuery extends PaginationParams {
|
||||
created_from?: string
|
||||
created_to?: string
|
||||
}
|
||||
export interface AuditSubjectResourceSummary {
|
||||
resource_type?: string
|
||||
identifier?: string
|
||||
display_name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
export interface AuditSubjectActivity {
|
||||
action_code: string
|
||||
action_name: string
|
||||
occurred_at: string
|
||||
result: AuditResult
|
||||
subject_summary: string
|
||||
subject_data?: Record<string, unknown> | null
|
||||
related_resources?: AuditSubjectResourceSummary[]
|
||||
}
|
||||
export interface AuditSubjectActivityPage extends AuditPage<AuditSubjectActivity> {
|
||||
resource: AuditSubjectResourceSummary
|
||||
}
|
||||
@@ -126,5 +126,8 @@ export * from './bulkPurchase'
|
||||
// 站内通知相关
|
||||
export * from './notification'
|
||||
|
||||
// 审计链路与调查中心
|
||||
export * from './audit'
|
||||
|
||||
// 企业微信审批配置相关
|
||||
export * from './wecom'
|
||||
|
||||
6
src/types/components.d.ts
vendored
6
src/types/components.d.ts
vendored
@@ -68,6 +68,12 @@ declare module 'vue' {
|
||||
ArtWangEditor: typeof import('./../components/core/forms/ArtWangEditor.vue')['default']
|
||||
ArtWatermark: typeof import('./../components/core/others/ArtWatermark.vue')['default']
|
||||
ArtWorkTab: typeof import('./../components/core/layouts/art-work-tab/index.vue')['default']
|
||||
AuditDistributionChart: typeof import('./../components/business/audit/AuditDistributionChart.vue')['default']
|
||||
AuditEventTable: typeof import('./../components/business/audit/AuditEventTable.vue')['default']
|
||||
AuditInvestigationDrawer: typeof import('./../components/business/audit/AuditInvestigationDrawer.vue')['default']
|
||||
AuditInvestigationHost: typeof import('./../components/business/audit/AuditInvestigationHost.vue')['default']
|
||||
AuditInvestigationLinks: typeof import('./../components/business/audit/AuditInvestigationLinks.vue')['default']
|
||||
AuditResourceSearchDialog: typeof import('./../components/business/audit/AuditResourceSearchDialog.vue')['default']
|
||||
BasicSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default']
|
||||
BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default']
|
||||
BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default']
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
||||
isFirstLevel?: boolean
|
||||
/** 角色权限 */
|
||||
roles?: string[]
|
||||
/** 页面访问权限,满足任意一个即可 */
|
||||
permissions?: string[]
|
||||
/** 导出任务固定场景 */
|
||||
exportTaskScene?: ExportTaskScene
|
||||
/** 是否固定标签页 */
|
||||
|
||||
137
src/utils/business/audit.ts
Normal file
137
src/utils/business/audit.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
AuditActorKind,
|
||||
AuditCategory,
|
||||
AuditResult,
|
||||
AuditRiskLevel,
|
||||
AuditScopeType,
|
||||
AuditSource,
|
||||
IntegrationResultCategory
|
||||
} from '@/types/api'
|
||||
|
||||
export const auditCategoryLabels: Record<AuditCategory, string> = {
|
||||
configuration: '配置',
|
||||
reliability: '可靠性',
|
||||
asset: '资产',
|
||||
security: '安全',
|
||||
identity: '身份',
|
||||
business: '业务'
|
||||
}
|
||||
|
||||
export const auditActorKindLabels: Record<AuditActorKind, string> = {
|
||||
account: '人工账号',
|
||||
personal_customer: '个人客户',
|
||||
openapi: '开放接口账号',
|
||||
system_task: '系统任务',
|
||||
scheduled_job: '计划任务',
|
||||
external_system: '外部系统'
|
||||
}
|
||||
|
||||
export const auditScopeTypeLabels: Record<AuditScopeType, string> = {
|
||||
platform: '平台',
|
||||
shop: '店铺',
|
||||
personal_customer: '个人客户'
|
||||
}
|
||||
|
||||
export const auditSourceLabels: Record<AuditSource, string> = {
|
||||
admin_api: '后台管理 API',
|
||||
personal_api: '个人客户 API',
|
||||
openapi: '代理 OpenAPI',
|
||||
worker: '异步 Worker',
|
||||
scheduler: '计划任务',
|
||||
callback: '外部系统回调'
|
||||
}
|
||||
|
||||
export const auditResourceRelationLabels: Record<string, string> = {
|
||||
primary: '主要资源',
|
||||
affected: '受影响资源',
|
||||
reference: '引用资源'
|
||||
}
|
||||
|
||||
export const auditSubjectVisibilityLabels: Record<string, string> = {
|
||||
internal_only: '仅平台可见',
|
||||
subject_result: '主体可见结论',
|
||||
subject_detail: '主体可见安全详情'
|
||||
}
|
||||
|
||||
export const auditResourceTypeLabels: Record<string, string> = {
|
||||
iot_card: 'IoT 卡',
|
||||
device: '设备',
|
||||
asset_allocation_record: '资产分配记录',
|
||||
exchange_order: '换卡订单',
|
||||
shop: '店铺',
|
||||
enterprise: '企业',
|
||||
order: '订单',
|
||||
refund: '退款',
|
||||
integration_log: '外部集成记录'
|
||||
}
|
||||
|
||||
export const auditResultMeta: Record<
|
||||
AuditResult,
|
||||
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
|
||||
> = {
|
||||
success: { label: '成功', type: 'success' },
|
||||
failed: { label: '失败', type: 'danger' },
|
||||
denied: { label: '拒绝', type: 'danger' },
|
||||
partial: { label: '部分成功', type: 'warning' },
|
||||
unknown: { label: '未知', type: 'info' }
|
||||
}
|
||||
|
||||
const auditResultLabels: Record<string, string> = {
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
denied: '拒绝',
|
||||
partial: '部分成功',
|
||||
unknown: '未知',
|
||||
pending: '处理中',
|
||||
not_found: '未找到',
|
||||
invalid_payload: '无效载荷',
|
||||
conflict: '冲突',
|
||||
ignored: '已忽略',
|
||||
merged: '已合并',
|
||||
rate_limited: '已限流',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
|
||||
export function auditResultDisplay(result?: string | null, resultName?: string | null): string {
|
||||
const name = resultName?.trim()
|
||||
if (name) return name
|
||||
return result ? auditResultLabels[result] || result : '-'
|
||||
}
|
||||
|
||||
export const auditRiskMeta: Record<
|
||||
AuditRiskLevel,
|
||||
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
|
||||
> = {
|
||||
low: { label: '低', type: 'success' },
|
||||
normal: { label: '普通', type: 'info' },
|
||||
high: { label: '高', type: 'warning' },
|
||||
critical: { label: '严重', type: 'danger' }
|
||||
}
|
||||
|
||||
export const integrationCategoryMeta: Record<
|
||||
IntegrationResultCategory,
|
||||
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
|
||||
> = {
|
||||
processing: { label: '处理中', type: 'warning' },
|
||||
succeeded: { label: '成功', type: 'success' },
|
||||
indeterminate: { label: '结果不确定', type: 'warning' },
|
||||
failed: { label: '失败', type: 'danger' },
|
||||
not_sent: { label: '未发送', type: 'info' }
|
||||
}
|
||||
|
||||
export function toRfc3339(value?: string | Date | null): string | undefined {
|
||||
if (!value) return undefined
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||
}
|
||||
|
||||
export function fenDisplay(value?: number | null): string {
|
||||
return typeof value === 'number' ? `¥${(value / 100).toFixed(2)}` : '-'
|
||||
}
|
||||
|
||||
export function auditJson(value: unknown): string {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
69
src/utils/business/auditNavigation.ts
Normal file
69
src/utils/business/auditNavigation.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { AuditSubjectResourceType } from '@/types/api'
|
||||
import type { AuditFinanceField, AuditInvestigationTarget } from '@/components/business/audit/types'
|
||||
|
||||
interface AuditResourceRouteOptions {
|
||||
userType?: number
|
||||
resourceType: AuditSubjectResourceType | string
|
||||
internalId?: string | number | null
|
||||
businessIdentifier?: string | null
|
||||
}
|
||||
|
||||
/** 根据当前主体选择平台内部 ID 或主体安全业务标识。 */
|
||||
export function resolveAuditResourceTarget(
|
||||
options: AuditResourceRouteOptions
|
||||
): AuditInvestigationTarget | null {
|
||||
const { userType, resourceType, internalId, businessIdentifier } = options
|
||||
const normalizedBusinessIdentifier = businessIdentifier?.trim()
|
||||
if (userType === 3) {
|
||||
if (
|
||||
!normalizedBusinessIdentifier ||
|
||||
![
|
||||
'iot_card',
|
||||
'device',
|
||||
'asset_allocation_record',
|
||||
'exchange_order',
|
||||
'shop',
|
||||
'enterprise'
|
||||
].includes(resourceType)
|
||||
)
|
||||
return null
|
||||
return {
|
||||
mode: 'agent',
|
||||
resourceType: resourceType as AuditSubjectResourceType,
|
||||
id: normalizedBusinessIdentifier
|
||||
}
|
||||
}
|
||||
if (userType === 4) {
|
||||
if (!normalizedBusinessIdentifier || !['iot_card', 'device'].includes(resourceType)) return null
|
||||
return {
|
||||
mode: 'enterprise',
|
||||
resourceType: resourceType as AuditSubjectResourceType,
|
||||
id: normalizedBusinessIdentifier
|
||||
}
|
||||
}
|
||||
if (
|
||||
internalId === undefined ||
|
||||
internalId === null ||
|
||||
internalId === '' ||
|
||||
(typeof internalId === 'string' && !internalId.trim()) ||
|
||||
(typeof internalId === 'number' && (!Number.isFinite(internalId) || internalId <= 0))
|
||||
)
|
||||
return null
|
||||
return { mode: 'resource', resourceType, id: String(internalId).trim() }
|
||||
}
|
||||
|
||||
export function resolveFinanceAuditTarget(
|
||||
field: AuditFinanceField,
|
||||
value?: string | number | null
|
||||
): AuditInvestigationTarget | null {
|
||||
const normalizedValue = typeof value === 'string' ? value.trim() : value
|
||||
if (
|
||||
normalizedValue === undefined ||
|
||||
normalizedValue === null ||
|
||||
normalizedValue === '' ||
|
||||
(typeof normalizedValue === 'number' &&
|
||||
(!Number.isFinite(normalizedValue) || normalizedValue <= 0))
|
||||
)
|
||||
return null
|
||||
return { mode: 'finance', field, value: normalizedValue }
|
||||
}
|
||||
@@ -9,8 +9,16 @@ const resolveTargetRoute = (
|
||||
target: NotificationTarget
|
||||
): TargetRoute | null => {
|
||||
const targetId = target.target_id === null ? '' : String(target.target_id)
|
||||
const targetType = target.target_type || refType
|
||||
|
||||
switch (refType) {
|
||||
if (targetType === 'integration_log') {
|
||||
const integrationId = target.target_key?.trim()
|
||||
return integrationId
|
||||
? { path: `/audit/integrations/${encodeURIComponent(integrationId)}` }
|
||||
: null
|
||||
}
|
||||
|
||||
switch (targetType) {
|
||||
case 'system_config':
|
||||
return { path: RoutesAlias.SystemConfigs }
|
||||
case 'refund':
|
||||
@@ -27,7 +35,6 @@ const resolveTargetRoute = (
|
||||
return { path: RoutesAlias.ExpiringAssets }
|
||||
case 'shop_fund':
|
||||
return { path: RoutesAlias.AgentFundOverview }
|
||||
case 'integration_log':
|
||||
case 'package':
|
||||
case 'asset':
|
||||
case 'wecom_approval':
|
||||
|
||||
@@ -335,6 +335,9 @@
|
||||
JULY_PERMISSIONS,
|
||||
STATUS_SELECT_OPTIONS
|
||||
} from '@/config/constants'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
|
||||
|
||||
@@ -670,6 +673,19 @@
|
||||
const getActions = (row: any) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
resourceType: 'account',
|
||||
internalId: row.id
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAuth('account:patch_role')) {
|
||||
actions.push({
|
||||
label: '分配角色',
|
||||
|
||||
@@ -204,6 +204,9 @@
|
||||
import { h, onActivated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import { EnterpriseService, ShopService } from '@/api/modules'
|
||||
import { ElMessage, ElSwitch, ElCascader } from 'element-plus'
|
||||
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
|
||||
@@ -502,6 +505,21 @@
|
||||
const getActions = (row: EnterpriseItem) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth(AUDIT_PERMISSIONS.enterpriseEntry)) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.getUserInfo.user_type,
|
||||
resourceType: 'enterprise',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.enterprise_code
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAuth('enterprise_customer:look_customer')) {
|
||||
actions.push({
|
||||
label: '账号列表',
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<ElButton v-if="auditTarget" type="primary" link @click="openAssetAudit">
|
||||
{{ isSubjectActivityView ? '活动记录' : '审计记录' }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="
|
||||
cardInfo?.asset_type === 'card' &&
|
||||
@@ -311,7 +314,7 @@
|
||||
{{ scope.row.gateway_extend || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="180" fixed="right">
|
||||
<ElTableColumn label="操作" width="280" fixed="right">
|
||||
<template #default="scope">
|
||||
<!-- 根据网络状态显示启用或停用按钮 -->
|
||||
<ElButton
|
||||
@@ -345,6 +348,24 @@
|
||||
>
|
||||
更新实名状态
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="getBindingCardAuditTarget(scope.row)"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openBindingCardAudit(scope.row)"
|
||||
>
|
||||
卡审计
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="getBindingAuditTarget(scope.row)"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openBindingAudit(scope.row)"
|
||||
>
|
||||
绑定审计
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
@@ -589,10 +610,14 @@
|
||||
getExpiryEstimateTooltip,
|
||||
getExpiryEstimateClass
|
||||
} from '@/utils/business/expiryEstimate'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
// Props
|
||||
interface BindingCard {
|
||||
id: number
|
||||
card_id?: number
|
||||
iccid: string
|
||||
msisdn?: string
|
||||
carrier_name?: string
|
||||
@@ -609,7 +634,9 @@
|
||||
}
|
||||
|
||||
interface AssetInfo {
|
||||
asset_id: number
|
||||
asset_type: 'card' | 'device'
|
||||
identifier: string
|
||||
iccid?: string
|
||||
imei?: string
|
||||
msisdn?: string
|
||||
@@ -717,6 +744,64 @@
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const isSubjectActivityView = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
|
||||
const auditTarget = computed(() => {
|
||||
const userType = Number(userStore.info.user_type)
|
||||
const permission =
|
||||
userType === 3
|
||||
? AUDIT_PERMISSIONS.agentActivity
|
||||
: userType === 4
|
||||
? AUDIT_PERMISSIONS.enterpriseActivity
|
||||
: props.cardInfo.asset_type === 'card'
|
||||
? AUDIT_PERMISSIONS.cardEntry
|
||||
: AUDIT_PERMISSIONS.deviceEntry
|
||||
if (!hasAuth(permission)) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: props.cardInfo.asset_type === 'card' ? 'iot_card' : 'device',
|
||||
internalId: props.cardInfo.asset_id,
|
||||
businessIdentifier:
|
||||
props.cardInfo.asset_type === 'card' ? props.cardInfo.iccid : props.cardInfo.virtual_no
|
||||
})
|
||||
})
|
||||
const openAssetAudit = () => {
|
||||
if (auditTarget.value) openAuditInvestigation(auditTarget.value)
|
||||
}
|
||||
const getBindingCardAuditTarget = (card: BindingCard) => {
|
||||
const userType = Number(userStore.info.user_type)
|
||||
const permission =
|
||||
userType === 3
|
||||
? AUDIT_PERMISSIONS.agentActivity
|
||||
: userType === 4
|
||||
? AUDIT_PERMISSIONS.enterpriseActivity
|
||||
: AUDIT_PERMISSIONS.cardEntry
|
||||
if (!hasAuth(permission)) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: 'iot_card',
|
||||
internalId: card.card_id,
|
||||
businessIdentifier: card.iccid
|
||||
})
|
||||
}
|
||||
const getBindingAuditTarget = (card: BindingCard) => {
|
||||
if (
|
||||
![1, 2].includes(Number(userStore.info.user_type)) ||
|
||||
!hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
|
||||
)
|
||||
return null
|
||||
return resolveAuditResourceTarget({
|
||||
resourceType: 'device_sim_binding',
|
||||
internalId: card.id
|
||||
})
|
||||
}
|
||||
const openBindingCardAudit = (card: BindingCard) => {
|
||||
const target = getBindingCardAuditTarget(card)
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openBindingAudit = (card: BindingCard) => {
|
||||
const target = getBindingAuditTarget(card)
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const CARD_MONTH_USAGE_PERMISSION = 'asset_info:view_card_month_usage'
|
||||
|
||||
@@ -44,6 +44,20 @@
|
||||
/>
|
||||
<ElButton type="primary" @click="handleQuery">查询</ElButton>
|
||||
<ElButton @click="handleReset()">重置</ElButton>
|
||||
<ElButton
|
||||
v-if="walletInfo?.wallet_id && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
|
||||
@click="openWalletAudit"
|
||||
>资金链路</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="
|
||||
walletInfo?.resource_type &&
|
||||
walletInfo?.resource_id &&
|
||||
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
|
||||
"
|
||||
@click="openAssetAudit"
|
||||
>资产审计</ElButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -167,6 +181,13 @@
|
||||
AssetWalletTransactionParams,
|
||||
TransactionType
|
||||
} from '@/types/api'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
interface Props {
|
||||
assetIdentifier: string
|
||||
@@ -182,6 +203,19 @@
|
||||
boundDeviceId: undefined,
|
||||
boundDeviceNo: ''
|
||||
})
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const openWalletAudit = () => {
|
||||
const target = resolveFinanceAuditTarget('wallet_id', props.walletInfo?.wallet_id)
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openAssetAudit = () => {
|
||||
const target = resolveAuditResourceTarget({
|
||||
resourceType: props.walletInfo?.resource_type || '',
|
||||
internalId: props.walletInfo?.resource_id
|
||||
})
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'navigateToDevice', deviceNo: string): void
|
||||
|
||||
@@ -969,6 +969,10 @@
|
||||
import { computed, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
DeviceService,
|
||||
ShopService,
|
||||
@@ -1027,6 +1031,7 @@
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const allocateLoading = ref(false)
|
||||
@@ -3173,6 +3178,27 @@
|
||||
})
|
||||
}
|
||||
|
||||
const userType = Number(userStore.getUserInfo.user_type)
|
||||
const auditPermission =
|
||||
userType === 3
|
||||
? AUDIT_PERMISSIONS.agentActivity
|
||||
: userType === 4
|
||||
? AUDIT_PERMISSIONS.enterpriseActivity
|
||||
: AUDIT_PERMISSIONS.deviceEntry
|
||||
if (hasAuth(auditPermission)) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: 'device',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.virtual_no
|
||||
})
|
||||
if (auditTarget)
|
||||
moreActions.push({
|
||||
label: userType >= 3 ? '活动记录' : '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
// 如果有更多操作,添加到 actions 中
|
||||
if (moreActions.length > 0) {
|
||||
actions.push(...moreActions)
|
||||
|
||||
@@ -10,6 +10,30 @@
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">换货单详情</h2>
|
||||
<ElButton
|
||||
v-if="exchangeAuditTarget && hasAuth(exchangeAuditPermission)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openAuditInvestigation(exchangeAuditTarget)"
|
||||
>
|
||||
{{ userType === 3 ? '活动记录' : '换货审计' }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="oldAssetAuditTarget && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openAuditInvestigation(oldAssetAuditTarget)"
|
||||
>
|
||||
旧资产审计
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="newAssetAuditTarget && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openAuditInvestigation(newAssetAuditTarget)"
|
||||
>
|
||||
新资产审计
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
@@ -35,15 +59,51 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'ExchangeDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const exchangeDetail = ref<ExchangeResponse | null>(null)
|
||||
const exchangeId = ref<number>(0)
|
||||
const userType = computed(() => Number(userStore.info.user_type))
|
||||
const exchangeAuditPermission = computed(() =>
|
||||
userType.value === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
|
||||
)
|
||||
const exchangeAuditTarget = computed(() => {
|
||||
if (!exchangeDetail.value || userType.value === 4) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType: userType.value,
|
||||
resourceType: 'exchange_order',
|
||||
internalId: exchangeDetail.value.id,
|
||||
businessIdentifier: exchangeDetail.value.exchange_no
|
||||
})
|
||||
})
|
||||
const oldAssetAuditTarget = computed(() => {
|
||||
if (!exchangeDetail.value || ![1, 2].includes(userType.value)) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType: userType.value,
|
||||
resourceType: exchangeDetail.value.old_asset_type,
|
||||
internalId: exchangeDetail.value.old_asset_id
|
||||
})
|
||||
})
|
||||
const newAssetAuditTarget = computed(() => {
|
||||
if (!exchangeDetail.value || ![1, 2].includes(userType.value)) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType: userType.value,
|
||||
resourceType: exchangeDetail.value.new_asset_type || '',
|
||||
internalId: exchangeDetail.value.new_asset_id
|
||||
})
|
||||
})
|
||||
|
||||
const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => {
|
||||
if (shopName) return shopName
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="120"
|
||||
:actionsWidth="160"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -438,10 +438,15 @@
|
||||
getProcessingStatusText
|
||||
} from '@/utils/business/approvalSummary'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'ExchangeManagement' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const currentExchangeRow = ref<ExchangeResponse | null>(null)
|
||||
@@ -1548,6 +1553,45 @@
|
||||
const actions: any[] = []
|
||||
const status = row.status
|
||||
const flowType = row.flow_type || 'shipping' // 历史数据兼容
|
||||
const userType = Number(userStore.info.user_type)
|
||||
const entryPermission =
|
||||
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
|
||||
if (userType !== 4 && hasAuth(entryPermission)) {
|
||||
const exchangeTarget = resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: 'exchange_order',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.exchange_no
|
||||
})
|
||||
if (exchangeTarget)
|
||||
actions.push({
|
||||
label: userType === 3 ? '活动记录' : '换货审计',
|
||||
handler: () => openAuditInvestigation(exchangeTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
if ([1, 2].includes(userType)) {
|
||||
const oldAssetTarget = resolveAuditResourceTarget({
|
||||
resourceType: row.old_asset_type,
|
||||
internalId: row.old_asset_id
|
||||
})
|
||||
if (oldAssetTarget)
|
||||
actions.push({
|
||||
label: '旧资产审计',
|
||||
handler: () => openAuditInvestigation(oldAssetTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
const newAssetTarget = resolveAuditResourceTarget({
|
||||
resourceType: row.new_asset_type || '',
|
||||
internalId: row.new_asset_id
|
||||
})
|
||||
if (newAssetTarget)
|
||||
actions.push({
|
||||
label: '新资产审计',
|
||||
handler: () => openAuditInvestigation(newAssetTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 直接换货不支持任何操作(发货、确认完成、取消)
|
||||
if (flowType === 'direct') {
|
||||
|
||||
@@ -1024,6 +1024,9 @@
|
||||
import { computed, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import {
|
||||
CardService,
|
||||
ShopService,
|
||||
@@ -2331,6 +2334,27 @@
|
||||
})
|
||||
}
|
||||
|
||||
const userType = Number(userStore.getUserInfo.user_type)
|
||||
const auditPermission =
|
||||
userType === 3
|
||||
? AUDIT_PERMISSIONS.agentActivity
|
||||
: userType === 4
|
||||
? AUDIT_PERMISSIONS.enterpriseActivity
|
||||
: AUDIT_PERMISSIONS.cardEntry
|
||||
if (hasAuth(auditPermission)) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: 'iot_card',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.iccid
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: userType >= 3 ? '活动记录' : '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
@@ -2448,8 +2472,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedCards.value = []
|
||||
await getTableData()
|
||||
|
||||
@@ -10,6 +10,22 @@
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">资产分配详情</h2>
|
||||
<ElButton
|
||||
v-if="recordAuditTarget && hasAuth(recordAuditPermission)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openAuditInvestigation(recordAuditTarget)"
|
||||
>
|
||||
{{ userType === 3 ? '活动记录' : '分配审计' }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="assetAuditTarget && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openAuditInvestigation(assetAuditTarget)"
|
||||
>
|
||||
资产审计
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
@@ -25,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
@@ -34,14 +50,42 @@
|
||||
import { CardService } from '@/api/modules'
|
||||
import type { AssetAllocationRecord } from '@/types/api/card'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'AssetAssignDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<AssetAllocationRecord | null>(null)
|
||||
const userType = computed(() => Number(userStore.info.user_type))
|
||||
const recordAuditPermission = computed(() =>
|
||||
userType.value === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
|
||||
)
|
||||
const recordAuditTarget = computed(() => {
|
||||
if (!detailData.value || userType.value === 4) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType: userType.value,
|
||||
resourceType: 'asset_allocation_record',
|
||||
internalId: detailData.value.id,
|
||||
businessIdentifier: detailData.value.allocation_no
|
||||
})
|
||||
})
|
||||
const assetAuditTarget = computed(() => {
|
||||
if (!detailData.value || ![1, 2].includes(userType.value)) return null
|
||||
return resolveAuditResourceTarget({
|
||||
userType: userType.value,
|
||||
resourceType: detailData.value.asset_type,
|
||||
internalId: detailData.value.asset_id
|
||||
})
|
||||
})
|
||||
|
||||
// 详情页配置
|
||||
const detailSections: DetailSection[] = [
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="180"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -51,10 +53,15 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'AssetAllocationRecords' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -221,6 +228,40 @@
|
||||
|
||||
const recordList = ref<AssetAllocationRecord[]>([])
|
||||
|
||||
const getActions = (row: AssetAllocationRecord) => {
|
||||
const actions: Array<{ label: string; handler: () => void; type: 'primary' }> = []
|
||||
const userType = Number(userStore.info.user_type)
|
||||
const entryPermission =
|
||||
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
|
||||
if (!hasAuth(entryPermission) || userType === 4) return actions
|
||||
const recordTarget = resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: 'asset_allocation_record',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.allocation_no
|
||||
})
|
||||
if (recordTarget)
|
||||
actions.push({
|
||||
label: userType === 3 ? '活动记录' : '分配审计',
|
||||
handler: () => openAuditInvestigation(recordTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
if ([1, 2].includes(userType)) {
|
||||
const assetTarget = resolveAuditResourceTarget({
|
||||
userType,
|
||||
resourceType: row.asset_type,
|
||||
internalId: row.asset_id
|
||||
})
|
||||
if (assetTarget)
|
||||
actions.push({
|
||||
label: '资产审计',
|
||||
handler: () => openAuditInvestigation(assetTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// 获取分配类型标签类型
|
||||
const getAllocationTypeType = (type: AllocationTypeEnum) => {
|
||||
return type === 'allocate' ? 'success' : 'warning'
|
||||
|
||||
323
src/views/audit/events/detail.vue
Normal file
323
src/views/audit/events/detail.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="router.back()"
|
||||
><template #icon
|
||||
><ElIcon><ArrowLeft /></ElIcon></template
|
||||
>返回</ElButton
|
||||
>
|
||||
<h2 class="detail-title">审计事件详情</h2>
|
||||
<ElTag v-if="detail" :type="auditRiskMeta[detail.risk_level]?.type"
|
||||
>{{ auditRiskMeta[detail.risk_level]?.label || detail.risk_level }}风险</ElTag
|
||||
>
|
||||
</div>
|
||||
<div v-loading="loading">
|
||||
<template v-if="detail">
|
||||
<ElCard shadow="never" class="section-card">
|
||||
<ElDescriptions :column="3" border>
|
||||
<ElDescriptionsItem label="分类">{{
|
||||
auditCategoryLabels[detail.category] || detail.category
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="动作">{{ detail.action_name }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="结果"
|
||||
><ElTag :type="auditResultMeta[detail.result]?.type">{{
|
||||
auditResultMeta[detail.result]?.label
|
||||
}}</ElTag></ElDescriptionsItem
|
||||
>
|
||||
<ElDescriptionsItem label="来源">{{
|
||||
auditSourceLabels[detail.source] || detail.source
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="发生时间">{{
|
||||
formatDateTime(detail.occurred_at)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="写入时间">{{
|
||||
formatDateTime(detail.created_at)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务范围">{{ scopeDisplay }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="摘要" :span="3">{{ detail.summary }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="detail.error_code || detail.error_summary"
|
||||
label="错误信息"
|
||||
:span="3"
|
||||
>
|
||||
<code v-if="detail.error_code">{{ detail.error_code }}</code>
|
||||
<span v-if="detail.error_code && detail.error_summary"> · </span>
|
||||
<span>{{ detail.error_summary }}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
<div class="detail-grid" :class="{ 'detail-grid--single': !userStore.isSuperAdmin }">
|
||||
<ElCard shadow="never"
|
||||
><template #header>操作者快照</template
|
||||
><ElDescriptions :column="1"
|
||||
><ElDescriptionsItem label="名称">{{ detail.actor_name || '-' }}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="类型">{{
|
||||
auditActorKindLabels[detail.actor_kind] || detail.actor_kind
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="店铺">{{
|
||||
namedId(detail.actor_shop_name, detail.actor_shop_id)
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="企业">{{
|
||||
namedId(detail.actor_enterprise_name, detail.actor_enterprise_id)
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
></ElCard
|
||||
>
|
||||
<ElCard v-if="userStore.isSuperAdmin" shadow="never"
|
||||
><template #header>请求上下文</template
|
||||
><ElDescriptions :column="1"
|
||||
><ElDescriptionsItem label="请求">{{
|
||||
[detail.request_method, detail.request_path].filter(Boolean).join(' ') || '-'
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="request_id">{{
|
||||
detail.request_id || '-'
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="correlation_id">{{
|
||||
detail.correlation_id || '-'
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="IP">{{ detail.ip_address || '-' }}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="User-Agent">{{
|
||||
detail.user_agent || '-'
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
></ElCard
|
||||
>
|
||||
</div>
|
||||
<ElCard v-if="hasBatchData" shadow="never" class="section-card">
|
||||
<template #header>批次执行统计</template>
|
||||
<ElDescriptions :column="3" border>
|
||||
<ElDescriptionsItem label="处理总数">{{
|
||||
detail.batch_total || 0
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="成功数">{{
|
||||
detail.success_count || 0
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="失败数">{{ detail.fail_count || 0 }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
<ElCard shadow="never" class="section-card"
|
||||
><template #header>调查引用</template
|
||||
><AuditInvestigationLinks
|
||||
:refs="detail.investigation_refs"
|
||||
:current-event-id="detail.event_id"
|
||||
/></ElCard>
|
||||
<ElCard shadow="never" class="section-card">
|
||||
<template #header>相关资源与快照</template>
|
||||
<ElTable :data="detail.resources || []" :row-key="resourceRowKey">
|
||||
<ElTableColumn type="expand" width="48">
|
||||
<template #default="{ row }">
|
||||
<div class="resource-snapshot-grid">
|
||||
<section>
|
||||
<h4>身份快照</h4>
|
||||
<pre>{{ auditJson(row.identity_snapshot) }}</pre>
|
||||
</section>
|
||||
<section>
|
||||
<h4>变更前</h4>
|
||||
<pre>{{ auditJson(row.before_data) }}</pre>
|
||||
</section>
|
||||
<section>
|
||||
<h4>变更后</h4>
|
||||
<pre>{{ auditJson(row.after_data) }}</pre>
|
||||
</section>
|
||||
<section>
|
||||
<h4>主体安全数据</h4>
|
||||
<pre>{{ auditJson(row.subject_data) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="display_name"
|
||||
label="资源"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="类型" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{
|
||||
auditResourceTypeLabels[row.resource_type] || row.resource_type
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="关系" width="110">
|
||||
<template #default="{ row }">{{
|
||||
auditResourceRelationLabels[row.relation] || row.relation
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="role" label="业务角色" min-width="150" />
|
||||
<ElTableColumn label="主体可见性" min-width="150">
|
||||
<template #default="{ row }">{{
|
||||
auditSubjectVisibilityLabels[row.subject_visibility] ||
|
||||
row.subject_visibility ||
|
||||
'-'
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="subject_summary"
|
||||
label="主体摘要"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="写入时间" width="180">
|
||||
<template #default="{ row }">{{
|
||||
row.created_at ? formatDateTime(row.created_at) : '-'
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
<ElCard v-if="userStore.isSuperAdmin" shadow="never">
|
||||
<template #header>元数据</template>
|
||||
<pre>{{ auditJson(detail.metadata) }}</pre>
|
||||
</ElCard>
|
||||
</template>
|
||||
<ElEmpty v-else-if="!loading" description="未找到审计事件" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type { AuditEventDetail, AuditResourceView } from '@/types/api'
|
||||
import {
|
||||
auditActorKindLabels,
|
||||
auditCategoryLabels,
|
||||
auditJson,
|
||||
auditResourceRelationLabels,
|
||||
auditResourceTypeLabels,
|
||||
auditResultMeta,
|
||||
auditRiskMeta,
|
||||
auditScopeTypeLabels,
|
||||
auditSourceLabels,
|
||||
auditSubjectVisibilityLabels
|
||||
} from '@/utils/business/audit'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import AuditInvestigationLinks from '@/components/business/audit/AuditInvestigationLinks.vue'
|
||||
|
||||
defineOptions({ name: 'AuditEventDetail' })
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const detail = ref<AuditEventDetail>()
|
||||
const scopeDisplay = computed(() => {
|
||||
if (!detail.value) return '-'
|
||||
const type = auditScopeTypeLabels[detail.value.scope_type] || detail.value.scope_type
|
||||
const identity = detail.value.scope_name || detail.value.scope_id
|
||||
return identity ? `${type} · ${identity}` : type
|
||||
})
|
||||
const hasBatchData = computed(() =>
|
||||
Boolean(
|
||||
(detail.value?.batch_total || 0) > 0 ||
|
||||
(detail.value?.success_count || 0) > 0 ||
|
||||
(detail.value?.fail_count || 0) > 0
|
||||
)
|
||||
)
|
||||
const namedId = (name?: string | null, id?: number | null) => {
|
||||
if (name && id !== null && id !== undefined) return `${name}(ID: ${id})`
|
||||
return name || (id !== null && id !== undefined ? `ID: ${id}` : '-')
|
||||
}
|
||||
const resourceRowKey = (row: AuditResourceView) =>
|
||||
[row.resource_type, row.resource_id || row.resource_key || row.sort_order || row.display_name]
|
||||
.filter((item) => item !== null && item !== undefined && item !== '')
|
||||
.join(':')
|
||||
onMounted(async () => {
|
||||
const id = String(route.params.eventId || '')
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = (await AuditService.getEventDetail(id)).data
|
||||
} catch {
|
||||
ElMessage.error('审计事件详情加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.detail-header .el-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-grid--single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.resource-snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.resource-snapshot-grid h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.resource-snapshot-grid pre {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.detail-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.resource-snapshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
src/views/audit/events/index.vue
Normal file
310
src/views/audit/events/index.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="audit-events-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
show-expand
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:column-list="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="load"
|
||||
/>
|
||||
|
||||
<ArtTable
|
||||
row-key="event_id"
|
||||
:loading="loading"
|
||||
:data="items"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:margin-top="10"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElButton, ElMessage, ElTag } from 'element-plus'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type { AuditEventQuery, AuditEventView } from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
defineOptions({ name: 'AuditEvents' })
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const items = ref<AuditEventView[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
const initialSearchState = {
|
||||
dateRange: [] as string[],
|
||||
category: undefined,
|
||||
result: undefined,
|
||||
risk: undefined,
|
||||
source: undefined,
|
||||
actor_kind: undefined,
|
||||
scope_type: undefined
|
||||
}
|
||||
let searchForm = reactive({ ...initialSearchState })
|
||||
const categoryOptions = [
|
||||
{ label: '配置', value: 'configuration' },
|
||||
{ label: '可靠性', value: 'reliability' },
|
||||
{ label: '资产', value: 'asset' },
|
||||
{ label: '安全', value: 'security' },
|
||||
{ label: '身份', value: 'identity' },
|
||||
{ label: '业务', value: 'business' }
|
||||
]
|
||||
const resultOptions = [
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
{ label: '拒绝', value: 'denied' },
|
||||
{ label: '部分成功', value: 'partial' },
|
||||
{ label: '未知', value: 'unknown' }
|
||||
]
|
||||
const riskOptions = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '普通', value: 'normal' },
|
||||
{ label: '高', value: 'high' },
|
||||
{ label: '严重', value: 'critical' }
|
||||
]
|
||||
const sourceOptions = [
|
||||
{ label: '后台管理 API', value: 'admin_api' },
|
||||
{ label: '个人客户 API', value: 'personal_api' },
|
||||
{ label: '代理 OpenAPI', value: 'openapi' },
|
||||
{ label: '异步 Worker', value: 'worker' },
|
||||
{ label: '计划任务', value: 'scheduler' },
|
||||
{ label: '外部系统回调', value: 'callback' }
|
||||
]
|
||||
const sourceLabels: Record<string, string> = Object.fromEntries(
|
||||
sourceOptions.map((item) => [item.value, item.label])
|
||||
)
|
||||
const actorOptions = [
|
||||
{ label: '人工账号', value: 'account' },
|
||||
{ label: '个人客户', value: 'personal_customer' },
|
||||
{ label: '开放接口账号', value: 'openapi' },
|
||||
{ label: '系统任务', value: 'system_task' },
|
||||
{ label: '计划任务', value: 'scheduled_job' },
|
||||
{ label: '外部系统', value: 'external_system' }
|
||||
]
|
||||
const scopeOptions = [
|
||||
{ label: '平台', value: 'platform' },
|
||||
{ label: '店铺', value: 'shop' },
|
||||
{ label: '个人客户', value: 'personal_customer' }
|
||||
]
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '起止时间',
|
||||
prop: 'dateRange',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '类别',
|
||||
prop: 'category',
|
||||
type: 'select',
|
||||
options: categoryOptions,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '结果',
|
||||
prop: 'result',
|
||||
type: 'select',
|
||||
options: resultOptions,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '风险',
|
||||
prop: 'risk',
|
||||
type: 'select',
|
||||
options: riskOptions,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '来源',
|
||||
prop: 'source',
|
||||
type: 'select',
|
||||
options: sourceOptions,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '操作者',
|
||||
prop: 'actor_kind',
|
||||
type: 'select',
|
||||
options: actorOptions,
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
label: '业务范围',
|
||||
prop: 'scope_type',
|
||||
type: 'select',
|
||||
options: scopeOptions,
|
||||
config: { clearable: true }
|
||||
}
|
||||
]
|
||||
const columnOptions = [
|
||||
{ label: '发生时间', prop: 'occurred_at' },
|
||||
{ label: '动作', prop: 'action_name' },
|
||||
{ label: '摘要', prop: 'summary' },
|
||||
{ label: '主要资源', prop: 'primary_resource' },
|
||||
{ label: '操作者', prop: 'actor_name' },
|
||||
{ label: '来源', prop: 'source' },
|
||||
{ label: '结果', prop: 'result' },
|
||||
{ label: '风险', prop: 'risk_level' },
|
||||
{ label: '操作', prop: 'actions' }
|
||||
]
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'occurred_at',
|
||||
label: '发生时间',
|
||||
width: 180,
|
||||
formatter: (row: AuditEventView) => formatDateTime(row.occurred_at)
|
||||
},
|
||||
{
|
||||
prop: 'action_name',
|
||||
label: '动作',
|
||||
minWidth: 220,
|
||||
formatter: (row: AuditEventView) => row.action_name || '-'
|
||||
},
|
||||
{ prop: 'summary', label: '摘要', minWidth: 240, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'primary_resource',
|
||||
label: '主要资源',
|
||||
minWidth: 210,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: AuditEventView) => {
|
||||
const resource =
|
||||
row.resources?.find((item) => item.relation === 'primary') || row.resources?.[0]
|
||||
if (!resource) return '-'
|
||||
return resource.display_name || resource.resource_key || resource.resource_id || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'actor_name',
|
||||
label: '操作者',
|
||||
minWidth: 170,
|
||||
formatter: (row: AuditEventView) => row.actor_name || row.actor_id || '-'
|
||||
},
|
||||
{
|
||||
prop: 'source',
|
||||
label: '来源',
|
||||
showOverflowTooltip: true,
|
||||
width: 130,
|
||||
formatter: (row: AuditEventView) => sourceLabels[row.source] || row.source
|
||||
},
|
||||
{
|
||||
prop: 'result',
|
||||
label: '结果',
|
||||
width: 100,
|
||||
formatter: (row: AuditEventView) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: auditResultMeta[row.result]?.type || 'info' },
|
||||
() => auditResultMeta[row.result]?.label || row.result
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'risk_level',
|
||||
label: '风险',
|
||||
width: 90,
|
||||
formatter: (row: AuditEventView) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: auditRiskMeta[row.risk_level]?.type || 'info', effect: 'plain' },
|
||||
() => auditRiskMeta[row.risk_level]?.label || row.risk_level
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'actions',
|
||||
label: '操作',
|
||||
width: 110,
|
||||
fixed: 'right',
|
||||
formatter: (row: AuditEventView) =>
|
||||
h(
|
||||
ElButton,
|
||||
{ type: 'primary', link: true, onClick: () => openDetail(row) },
|
||||
() => '查看详情'
|
||||
)
|
||||
}
|
||||
])
|
||||
|
||||
const toAuditDate = (value?: string, endExclusive = false) => {
|
||||
if (!value) return undefined
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
const date = new Date(year, month - 1, day + (endExclusive ? 1 : 0))
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
const buildQuery = (): AuditEventQuery => ({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
created_from: toAuditDate(searchForm.dateRange?.[0]),
|
||||
created_to: toAuditDate(searchForm.dateRange?.[1], true),
|
||||
category: searchForm.category,
|
||||
result: searchForm.result,
|
||||
risk: searchForm.risk,
|
||||
source: searchForm.source,
|
||||
actor_kind: searchForm.actor_kind,
|
||||
scope_type: searchForm.scope_type
|
||||
})
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = (await AuditService.getEvents(buildQuery())).data
|
||||
items.value = data.items
|
||||
pagination.total = data.total
|
||||
} catch {
|
||||
ElMessage.error('审计事件加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState, dateRange: [] })
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size
|
||||
load()
|
||||
}
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
load()
|
||||
}
|
||||
const openDetail = (row: AuditEventView) =>
|
||||
router.push(`/audit/events/${encodeURIComponent(row.event_id)}`)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audit-events-page {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
399
src/views/audit/integrations/detail.vue
Normal file
399
src/views/audit/integrations/detail.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<template>
|
||||
<div class="integration-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="router.back()">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">外部集成交互详情</h2>
|
||||
<ElTag
|
||||
v-if="detail"
|
||||
class="result-tag"
|
||||
:type="integrationCategoryMeta[detail.result.category]?.type || 'info'"
|
||||
>
|
||||
{{ auditResultDisplay(detail.result.code, detail.result.name) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading">
|
||||
<template v-if="detail">
|
||||
<ElCard shadow="never" class="section-card">
|
||||
<template #header>交互概览</template>
|
||||
<ElDescriptions :column="3" border>
|
||||
<ElDescriptionsItem label="提供方">
|
||||
{{ detail.identity.provider_name || detail.identity.provider }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="方向">
|
||||
{{ detail.identity.direction_name || detail.identity.direction }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作">
|
||||
{{ detail.identity.operation_name || detail.identity.operation }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="结果">
|
||||
<ElTag :type="integrationCategoryMeta[detail.result.category]?.type || 'info'">
|
||||
{{ auditResultDisplay(detail.result.code, detail.result.name) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="结果类别">
|
||||
{{
|
||||
integrationCategoryMeta[detail.result.category]?.label || detail.result.category
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="耗时"
|
||||
>{{ detail.result.duration_ms }} ms</ElDescriptionsItem
|
||||
>
|
||||
<ElDescriptionsItem label="HTTP 状态">
|
||||
{{ detail.result.http_status ?? '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="外部结果码">
|
||||
{{ detail.result.provider_code || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="状态变更">
|
||||
{{ booleanText(detail.result.state_changed) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="detail.identity.external_id" label="外部业务标识" :span="3">
|
||||
{{ detail.identity.external_id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="detail.result.provider_message"
|
||||
label="外部结果摘要"
|
||||
:span="3"
|
||||
>
|
||||
{{ detail.result.provider_message }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="detail.result.recovery_strategy"
|
||||
label="恢复策略说明"
|
||||
:span="3"
|
||||
>
|
||||
{{ detail.result.recovery_strategy }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<div class="detail-grid section-card">
|
||||
<ElCard v-if="hasResource" shadow="never">
|
||||
<template #header>关联资源</template>
|
||||
<ElDescriptions :column="1">
|
||||
<ElDescriptionsItem label="资源类型">
|
||||
{{
|
||||
auditResourceTypeLabels[detail.resource?.type || ''] ||
|
||||
detail.resource?.type ||
|
||||
'-'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务标识">
|
||||
{{ detail.resource?.key || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<template #header>调查入口</template>
|
||||
<div v-if="hasAssociations" class="link-actions">
|
||||
<ElButton
|
||||
v-if="detail.linkage.request_id"
|
||||
type="primary"
|
||||
plain
|
||||
@click="
|
||||
openAuditInvestigation({ mode: 'request', id: detail.linkage.request_id })
|
||||
"
|
||||
>
|
||||
请求链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="detail.linkage.correlation_id"
|
||||
type="primary"
|
||||
plain
|
||||
@click="
|
||||
openAuditInvestigation({
|
||||
mode: 'correlation',
|
||||
id: detail.linkage.correlation_id
|
||||
})
|
||||
"
|
||||
>
|
||||
业务关联链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="detail.resource?.type && detail.resource?.id"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openResourceTimeline"
|
||||
>
|
||||
资源审计时间线
|
||||
</ElButton>
|
||||
</div>
|
||||
<span v-else class="muted">无可用调查引用</span>
|
||||
<ElAlert
|
||||
v-if="hasUnavailableFidelity"
|
||||
class="fidelity-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="部分关联缺少可靠标识,已隐藏相应入口。"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid section-card">
|
||||
<ElCard shadow="never">
|
||||
<template #header>触发信息</template>
|
||||
<ElDescriptions :column="1">
|
||||
<ElDescriptionsItem label="触发来源">
|
||||
{{ detail.trigger.source || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务场景">
|
||||
{{ detail.trigger.scene || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="尝试序号">
|
||||
{{ detail.trigger.attempt }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<template #header>时间信息</template>
|
||||
<ElDescriptions :column="1">
|
||||
<ElDescriptionsItem label="创建时间">
|
||||
{{ formatDateTime(detail.timestamps.created_at) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="计划时间">
|
||||
{{ formatOptionalDate(detail.timestamps.scheduled_at) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="开始时间">
|
||||
{{ formatOptionalDate(detail.timestamps.started_at) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="更新时间">
|
||||
{{ formatDateTime(detail.timestamps.updated_at) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="section-card">
|
||||
<template #header>关联可靠性</template>
|
||||
<ElDescriptions :column="3" border>
|
||||
<ElDescriptionsItem label="尝试序列可靠">
|
||||
{{ booleanText(detail.fidelity.attempt_sequence_reliable) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务链路可用">
|
||||
{{ booleanText(detail.fidelity.correlation_available) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="资源引用可用">
|
||||
{{ booleanText(detail.fidelity.resource_id_available) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="重试序列可用">
|
||||
{{ booleanText(detail.fidelity.trigger_series_available) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="外部消息保真度" :span="2">
|
||||
{{ detail.fidelity.provider_message_fidelity || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<div v-if="hasContentSummary" class="content-grid section-card">
|
||||
<ElCard v-if="hasJsonContent(detail.content.request_summary)" shadow="never">
|
||||
<template #header>请求摘要</template>
|
||||
<pre>{{ auditJson(detail.content.request_summary) }}</pre>
|
||||
</ElCard>
|
||||
<ElCard v-if="hasJsonContent(detail.content.response_summary)" shadow="never">
|
||||
<template #header>响应摘要</template>
|
||||
<pre>{{ auditJson(detail.content.response_summary) }}</pre>
|
||||
</ElCard>
|
||||
<ElCard v-if="hasJsonContent(detail.content.metadata)" shadow="never">
|
||||
<template #header>扩展信息</template>
|
||||
<pre>{{ auditJson(detail.content.metadata) }}</pre>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ElCard v-if="detail.attempts?.length" shadow="never">
|
||||
<template #header>尝试记录</template>
|
||||
<ArtTable :data="detail.attempts" :pagination="false" :margin-top="0">
|
||||
<template #default>
|
||||
<ElTableColumn prop="attempt" label="序号" width="80" />
|
||||
<ElTableColumn label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.operation_name || row.operation }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="结果" width="110">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="integrationCategoryType(row.result_category)">
|
||||
{{ auditResultDisplay(row.result, row.result_name) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="耗时" width="100">
|
||||
<template #default="{ row }">{{ row.duration_ms }} ms</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="已发送" width="90">
|
||||
<template #default="{ row }">{{ booleanText(row.sent) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态变更" width="100">
|
||||
<template #default="{ row }">{{ booleanText(row.state_changed) }}</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</template>
|
||||
<ElEmpty v-else-if="!loading" description="未找到外部集成交互记录" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type { IntegrationDetailResponse } from '@/types/api'
|
||||
import {
|
||||
auditJson,
|
||||
auditResourceTypeLabels,
|
||||
auditResultDisplay,
|
||||
integrationCategoryMeta
|
||||
} from '@/utils/business/audit'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'AuditIntegrationDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const detail = ref<IntegrationDetailResponse>()
|
||||
|
||||
const hasUnavailableFidelity = computed(() =>
|
||||
Object.values(detail.value?.fidelity || {}).some((value) => value === false)
|
||||
)
|
||||
const hasResource = computed(() =>
|
||||
Boolean(detail.value?.resource?.type || detail.value?.resource?.key)
|
||||
)
|
||||
const hasAssociations = computed(() =>
|
||||
Boolean(
|
||||
detail.value?.linkage.request_id ||
|
||||
detail.value?.linkage.correlation_id ||
|
||||
(detail.value?.resource?.type && detail.value?.resource?.id)
|
||||
)
|
||||
)
|
||||
const hasJsonContent = (value?: Record<string, unknown> | null) =>
|
||||
Boolean(value && Object.keys(value).length)
|
||||
const hasContentSummary = computed(() =>
|
||||
Boolean(
|
||||
hasJsonContent(detail.value?.content.request_summary) ||
|
||||
hasJsonContent(detail.value?.content.response_summary) ||
|
||||
hasJsonContent(detail.value?.content.metadata)
|
||||
)
|
||||
)
|
||||
|
||||
const booleanText = (value: boolean) => (value ? '是' : '否')
|
||||
const integrationCategoryType = (category: unknown) =>
|
||||
integrationCategoryMeta[category as keyof typeof integrationCategoryMeta]?.type || 'info'
|
||||
const formatOptionalDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
|
||||
const openResourceTimeline = () => {
|
||||
if (!detail.value?.resource?.type || !detail.value.resource.id) return
|
||||
openAuditInvestigation({
|
||||
mode: 'resource',
|
||||
resourceType: detail.value.resource.type,
|
||||
id: detail.value.resource.id
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = String(route.params.integrationId || '')
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = (await AuditService.getIntegrationDetail(id)).data
|
||||
} catch {
|
||||
ElMessage.error('外部集成交互详情加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.integration-detail-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.result-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.fidelity-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 360px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (width <= 992px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.integration-detail-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
457
src/views/audit/integrations/index.vue
Normal file
457
src/views/audit/integrations/index.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<ArtTableFullScreen class="integration-scroll-container">
|
||||
<div class="integration-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="filter-card">
|
||||
<ElForm>
|
||||
<ElRow :gutter="16" class="filter-grid">
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElDatePicker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="query.provider" clearable placeholder="请选择提供方">
|
||||
<ElOption
|
||||
v-for="item in overview?.providers || []"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="query.direction" clearable placeholder="请选择方向">
|
||||
<ElOption
|
||||
v-for="item in overview?.directions || []"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="query.result" clearable placeholder="请选择结果">
|
||||
<ElOption
|
||||
v-for="item in overview?.results || []"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElButton type="primary" :icon="Search" @click="search">查询</ElButton>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
<div v-if="overview" class="metric-grid"
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--primary"
|
||||
><ElIcon><Connection /></ElIcon></span
|
||||
><div
|
||||
><span>交互总数</span><strong>{{ overview.total }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--danger"
|
||||
><ElIcon><WarningFilled /></ElIcon></span
|
||||
><div
|
||||
><span>异常数</span><strong class="danger">{{ overview.anomaly_count }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--success"
|
||||
><ElIcon><Refresh /></ElIcon></span
|
||||
><div
|
||||
><span>状态变更</span><strong>{{ overview.state_changed_count }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--warning"
|
||||
><ElIcon><Timer /></ElIcon></span
|
||||
><div
|
||||
><span>P95 耗时</span><strong>{{ overview.p95_duration_ms }} ms</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--info"
|
||||
><ElIcon><QuestionFilled /></ElIcon></span
|
||||
><div
|
||||
><span>结果不明</span><strong>{{ overview.unknown_count }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
></div
|
||||
>
|
||||
<div v-if="overview" class="overview-grid">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="section-title"
|
||||
><ElIcon><Box /></ElIcon><span>提供方分布</span></div
|
||||
>
|
||||
</template>
|
||||
<AuditDistributionChart
|
||||
title="提供方分布"
|
||||
:data="providerChartData"
|
||||
@select="apply('provider', $event)"
|
||||
/>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="section-title"
|
||||
><ElIcon><Switch /></ElIcon><span>方向分布</span></div
|
||||
>
|
||||
</template>
|
||||
<AuditDistributionChart
|
||||
title="方向分布"
|
||||
:data="directionChartData"
|
||||
@select="apply('direction', $event)"
|
||||
/>
|
||||
</ElCard>
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="section-title"
|
||||
><ElIcon><CircleCheckFilled /></ElIcon><span>结果分布</span></div
|
||||
>
|
||||
</template>
|
||||
<AuditDistributionChart
|
||||
title="结果分布"
|
||||
type="bar"
|
||||
:data="resultChartData"
|
||||
@select="apply('result', $event)"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
<ElCard shadow="never" class="art-table-card"
|
||||
><ArtTableHeader v-model:columns="headerColumns" @refresh="load"
|
||||
><template #left
|
||||
><span class="section-title"
|
||||
><ElIcon><List /></ElIcon><span>外部交互记录</span></span
|
||||
></template
|
||||
></ArtTableHeader
|
||||
><ArtTable
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
row-key="integration_id"
|
||||
:current-page="query.page"
|
||||
:page-size="query.page_size"
|
||||
:total="total"
|
||||
:margin-top="16"
|
||||
@size-change="
|
||||
(size) => {
|
||||
query.page_size = size
|
||||
loadList()
|
||||
}
|
||||
"
|
||||
@current-change="
|
||||
(current) => {
|
||||
query.page = current
|
||||
loadList()
|
||||
}
|
||||
"
|
||||
><template #default
|
||||
><ElTableColumn label="时间" width="180"
|
||||
><template #default="{ row }">{{
|
||||
formatDateTime(row.created_at)
|
||||
}}</template></ElTableColumn
|
||||
><ElTableColumn label="提供方" min-width="130"
|
||||
><template #default="{ row }">{{
|
||||
row.provider_name || row.provider
|
||||
}}</template></ElTableColumn
|
||||
><ElTableColumn label="方向" prop="direction_name" width="90" /><ElTableColumn
|
||||
label="操作"
|
||||
min-width="190"
|
||||
><template #default="{ row }">{{
|
||||
row.operation_name || row.operation
|
||||
}}</template></ElTableColumn
|
||||
><ElTableColumn label="结果" width="120"
|
||||
><template #default="{ row }"
|
||||
><ElTag :type="categoryMeta(row).type">{{
|
||||
auditResultDisplay(row.result, row.result_name)
|
||||
}}</ElTag></template
|
||||
></ElTableColumn
|
||||
><ElTableColumn label="耗时" width="100"
|
||||
><template #default="{ row }">{{
|
||||
row.duration_ms == null ? '-' : `${row.duration_ms} ms`
|
||||
}}</template></ElTableColumn
|
||||
><ElTableColumn label="资源" min-width="150"
|
||||
><template #default="{ row }">{{
|
||||
row.resource?.key || row.resource?.id || '-'
|
||||
}}</template></ElTableColumn
|
||||
><ElTableColumn label="操作" width="100" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><ElButton
|
||||
link
|
||||
type="primary"
|
||||
@click="
|
||||
router.push(`/audit/integrations/${encodeURIComponent(row.integration_id)}`)
|
||||
"
|
||||
>查看详情</ElButton
|
||||
></template
|
||||
></ElTableColumn
|
||||
></template
|
||||
></ArtTable
|
||||
></ElCard
|
||||
>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Box,
|
||||
CircleCheckFilled,
|
||||
Connection,
|
||||
List,
|
||||
QuestionFilled,
|
||||
Refresh,
|
||||
Search,
|
||||
Switch,
|
||||
Timer,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type {
|
||||
AuditNamedCount,
|
||||
IntegrationListItem,
|
||||
IntegrationOverview,
|
||||
IntegrationQuery
|
||||
} from '@/types/api'
|
||||
import { auditResultDisplay, integrationCategoryMeta } from '@/utils/business/audit'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import AuditDistributionChart from '@/components/business/audit/AuditDistributionChart.vue'
|
||||
|
||||
defineOptions({ name: 'AuditIntegrations' })
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const headerColumns = ref([])
|
||||
const dateRange = ref<string[]>([])
|
||||
const query = reactive<IntegrationQuery>({ page: 1, page_size: 20 })
|
||||
const overview = ref<IntegrationOverview>()
|
||||
const items = ref<IntegrationListItem[]>([])
|
||||
const total = ref(0)
|
||||
const aggregateChartData = (values: AuditNamedCount[]) => {
|
||||
const totals = new Map<string, AuditNamedCount>()
|
||||
values.forEach((item) => {
|
||||
const key = item.name || item.code
|
||||
const current = totals.get(key)
|
||||
if (current) current.count += Number(item.count) || 0
|
||||
else totals.set(key, { ...item, count: Number(item.count) || 0 })
|
||||
})
|
||||
return [...totals.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
const providerChartData = computed(() => aggregateChartData(overview.value?.providers || []))
|
||||
const directionChartData = computed(() => aggregateChartData(overview.value?.directions || []))
|
||||
const resultChartData = computed(() => aggregateChartData(overview.value?.results || []))
|
||||
const syncDates = () => {
|
||||
query.created_from = dateRange.value?.[0] || undefined
|
||||
query.created_to = dateRange.value?.[1] || undefined
|
||||
}
|
||||
const loadList = async () => {
|
||||
syncDates()
|
||||
const data = (await AuditService.getIntegrations(query)).data
|
||||
items.value = data.items
|
||||
total.value = data.total
|
||||
}
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
syncDates()
|
||||
overview.value = (await AuditService.getIntegrationOverview({ ...query, bucket: 'day' })).data
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('外部交互调查加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const search = () => {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
const apply = (field: 'provider' | 'direction' | 'result', code: string) => {
|
||||
;(query as Record<string, unknown>)[field] = code
|
||||
search()
|
||||
}
|
||||
const categoryMeta = (row: IntegrationListItem) =>
|
||||
integrationCategoryMeta[row.result_category] || {
|
||||
label: row.result_category,
|
||||
type: 'info' as const
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.integration-scroll-container {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.integration-page {
|
||||
box-sizing: border-box;
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-card,
|
||||
.metric-grid,
|
||||
.overview-grid {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-select),
|
||||
.filter-card :deep(.el-date-editor),
|
||||
.filter-card :deep(.el-button) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-grid span {
|
||||
display: block;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: inline-flex !important;
|
||||
flex: 0 0 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.metric-icon--primary {
|
||||
color: var(--el-color-primary) !important;
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--danger {
|
||||
color: var(--el-color-danger) !important;
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--success {
|
||||
color: var(--el-color-success) !important;
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--warning {
|
||||
color: var(--el-color-warning) !important;
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--info {
|
||||
color: var(--el-color-info) !important;
|
||||
background: var(--el-color-info-light-9);
|
||||
}
|
||||
|
||||
.metric-grid strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 1100px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
.filter-col {
|
||||
flex: 0 0 20%;
|
||||
max-width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 520px) {
|
||||
.integration-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
405
src/views/audit/risks/index.vue
Normal file
405
src/views/audit/risks/index.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<ArtTableFullScreen class="risk-scroll-container">
|
||||
<div class="risk-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="filter-card">
|
||||
<ElForm>
|
||||
<ElRow :gutter="16" class="filter-grid">
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElDatePicker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="query.risk" clearable placeholder="请选择风险">
|
||||
<ElOption
|
||||
v-for="item in overview?.risks || []"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="query.result" clearable placeholder="请选择结果">
|
||||
<ElOption
|
||||
v-for="item in overview?.results || []"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem>
|
||||
<ElSelect v-model="query.action" filterable clearable placeholder="请选择动作">
|
||||
<ElOption
|
||||
v-for="item in overview?.actions || []"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
|
||||
<ElFormItem class="query-form-item">
|
||||
<ElButton type="primary" :icon="Search" @click="search">查询</ElButton>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
<div v-if="overview" class="metric-grid"
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--primary"
|
||||
><ElIcon><DataAnalysis /></ElIcon></span
|
||||
><div
|
||||
><span>事件总数</span><strong>{{ overview.total }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--danger"
|
||||
><ElIcon><WarningFilled /></ElIcon></span
|
||||
><div
|
||||
><span>高风险信号</span
|
||||
><strong class="danger">{{ signalCount('high_risk') }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--warning"
|
||||
><ElIcon><Money /></ElIcon></span
|
||||
><div
|
||||
><span>资金信号</span><strong>{{ signalCount('finance') }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
><ElCard shadow="never" class="metric-card"
|
||||
><div class="metric-content"
|
||||
><span class="metric-icon metric-icon--success"
|
||||
><ElIcon><Lock /></ElIcon></span
|
||||
><div
|
||||
><span>安全信号</span><strong>{{ signalCount('security') }}</strong></div
|
||||
></div
|
||||
></ElCard
|
||||
></div
|
||||
>
|
||||
<div v-if="overview" class="overview-grid"
|
||||
><ElCard shadow="never"
|
||||
><template #header
|
||||
><div class="section-title"
|
||||
><ElIcon><WarningFilled /></ElIcon><span>风险分布</span></div
|
||||
></template
|
||||
><AuditDistributionChart
|
||||
title="风险分布"
|
||||
:data="riskChartData"
|
||||
@select="apply('risk', $event)" /></ElCard
|
||||
><ElCard shadow="never"
|
||||
><template #header
|
||||
><div class="section-title"
|
||||
><ElIcon><CircleCheckFilled /></ElIcon><span>结果分布</span></div
|
||||
></template
|
||||
><AuditDistributionChart
|
||||
title="结果分布"
|
||||
:data="resultChartData"
|
||||
@select="apply('result', $event)" /></ElCard
|
||||
><ElCard shadow="never"
|
||||
><template #header
|
||||
><div class="section-title"
|
||||
><ElIcon><Connection /></ElIcon><span>来源分布</span></div
|
||||
></template
|
||||
><AuditDistributionChart
|
||||
title="来源分布"
|
||||
type="bar"
|
||||
:data="sourceChartData"
|
||||
@select="apply('source', $event)" /></ElCard
|
||||
></div>
|
||||
<ElCard shadow="never" class="art-table-card" v-loading="loading"
|
||||
><ArtTableHeader v-model:columns="headerColumns" @refresh="load"
|
||||
><template #left
|
||||
><div class="card-title"
|
||||
><span class="section-title"
|
||||
><ElIcon><DataAnalysis /></ElIcon><span>风险事件明细</span></span
|
||||
></div
|
||||
></template
|
||||
></ArtTableHeader
|
||||
><div class="risk-event-table">
|
||||
<AuditEventTable
|
||||
:items="events"
|
||||
@detail="(row) => router.push(`/audit/events/${encodeURIComponent(row.event_id)}`)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pagination"
|
||||
><ElPagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadEvents" /></div
|
||||
></ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
CircleCheckFilled,
|
||||
Connection,
|
||||
DataAnalysis,
|
||||
Lock,
|
||||
Money,
|
||||
Search,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { AuditService } from '@/api/modules'
|
||||
import type {
|
||||
AuditEventView,
|
||||
AuditNamedCount,
|
||||
AuditRiskEventQuery,
|
||||
AuditRiskOverview
|
||||
} from '@/types/api'
|
||||
import AuditEventTable from '@/components/business/audit/AuditEventTable.vue'
|
||||
import AuditDistributionChart from '@/components/business/audit/AuditDistributionChart.vue'
|
||||
|
||||
defineOptions({ name: 'AuditRisks' })
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const headerColumns = ref([])
|
||||
const dateRange = ref<string[]>([])
|
||||
const overview = ref<AuditRiskOverview>()
|
||||
const events = ref<AuditEventView[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive<AuditRiskEventQuery>({ page: 1, page_size: 20 })
|
||||
const aggregateChartData = (items: AuditNamedCount[]) => {
|
||||
const totals = new Map<string, AuditNamedCount>()
|
||||
items.forEach((item) => {
|
||||
const key = item.name || item.code
|
||||
const current = totals.get(key)
|
||||
if (current) current.count += Number(item.count) || 0
|
||||
else totals.set(key, { ...item, count: Number(item.count) || 0 })
|
||||
})
|
||||
return [...totals.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
const riskChartData = computed(() => aggregateChartData(overview.value?.risks || []))
|
||||
const resultChartData = computed(() => aggregateChartData(overview.value?.results || []))
|
||||
const sourceChartData = computed(() => aggregateChartData(overview.value?.sources || []))
|
||||
const syncDates = () => {
|
||||
query.created_from = dateRange.value?.[0] || undefined
|
||||
query.created_to = dateRange.value?.[1] || undefined
|
||||
if (
|
||||
dateRange.value?.length === 2 &&
|
||||
new Date(dateRange.value[1]).getTime() - new Date(dateRange.value[0]).getTime() >
|
||||
31 * 86400000
|
||||
) {
|
||||
ElMessage.warning('风险查询时间范围最长为 31 天')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const loadEvents = async () => {
|
||||
if (!syncDates()) return
|
||||
const data = (await AuditService.getRiskEvents(query)).data
|
||||
events.value = data.items
|
||||
total.value = data.total
|
||||
}
|
||||
const load = async () => {
|
||||
if (!syncDates()) return
|
||||
loading.value = true
|
||||
try {
|
||||
overview.value = (await AuditService.getRiskOverview(query)).data
|
||||
await loadEvents()
|
||||
} catch {
|
||||
ElMessage.error('风险调查数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const search = () => {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
const signalCount = (code: string) =>
|
||||
overview.value?.signals.find((item) => item.code === code)?.count || 0
|
||||
const apply = (field: 'risk' | 'result' | 'source', code: string) => {
|
||||
;(query as Record<string, unknown>)[field] = code
|
||||
search()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-scroll-container {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.risk-page {
|
||||
box-sizing: border-box;
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filter-card,
|
||||
.metric-grid,
|
||||
.overview-grid {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-select),
|
||||
.filter-card :deep(.el-date-editor) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.query-form-item :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-grid span {
|
||||
display: block;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: inline-flex !important;
|
||||
flex: 0 0 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.metric-icon--primary {
|
||||
color: var(--el-color-primary) !important;
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--danger {
|
||||
color: var(--el-color-danger) !important;
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--warning {
|
||||
color: var(--el-color-warning) !important;
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.metric-icon--success {
|
||||
color: var(--el-color-success) !important;
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
.metric-grid strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.risk-event-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
.filter-col {
|
||||
flex: 0 0 20%;
|
||||
max-width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 520px) {
|
||||
.risk-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,8 @@
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:actions="getAuditActions"
|
||||
:actionsWidth="120"
|
||||
:marginTop="10"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
@@ -458,6 +460,9 @@
|
||||
} from '@/config/constants/commission'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveFinanceAuditTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'AgentCommission' })
|
||||
|
||||
@@ -471,6 +476,20 @@
|
||||
const tableRef = ref()
|
||||
const summaryList = ref<ShopFundSummaryItem[]>([])
|
||||
|
||||
const getAuditActions = (row: ShopFundSummaryItem) => {
|
||||
if (!hasAuth(AUDIT_PERMISSIONS.financeTimeline)) return []
|
||||
const target = resolveFinanceAuditTarget('shop_id', row.shop_id)
|
||||
return target
|
||||
? [
|
||||
{
|
||||
label: '资金链路',
|
||||
handler: () => openAuditInvestigation(target),
|
||||
type: 'primary' as const
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
shop_name: '',
|
||||
|
||||
@@ -10,6 +10,34 @@
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">{{ pageTitle }}</h2>
|
||||
<ElButton
|
||||
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openRechargeAudit"
|
||||
>
|
||||
审计记录
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openRechargeFinance"
|
||||
>
|
||||
资金链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="
|
||||
detailData?.approval_instance_id &&
|
||||
isPlatformUser &&
|
||||
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
|
||||
"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openApprovalAudit"
|
||||
>
|
||||
审批审计
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
@@ -42,15 +70,44 @@
|
||||
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import { formatRejectionReason } from './agentRechargeDisplay'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'AgentRechargeDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<AgentRecharge | null>(null)
|
||||
const paymentVoucherFileKeys = ref<string[]>([])
|
||||
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
const openRechargeAudit = () => {
|
||||
const target = resolveAuditResourceTarget({
|
||||
resourceType: 'agent_recharge',
|
||||
internalId: detailData.value?.id
|
||||
})
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openRechargeFinance = () => {
|
||||
const target = resolveFinanceAuditTarget('recharge_id', detailData.value?.id)
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openApprovalAudit = () => {
|
||||
const target = resolveAuditResourceTarget({
|
||||
resourceType: 'approval_instance',
|
||||
internalId: detailData.value?.approval_instance_id
|
||||
})
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => `充值订单详情`)
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:inlineActionsCount="2"
|
||||
:actionsWidth="200"
|
||||
:inlineActionsCount="1"
|
||||
:actionsWidth="160"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -317,6 +317,12 @@
|
||||
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
@@ -1284,12 +1290,46 @@
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: AgentRecharge) => {
|
||||
return buildAgentRechargeActions(row, {
|
||||
const actions = buildAgentRechargeActions(row, {
|
||||
hasAuth,
|
||||
onViewPaymentVoucher: handleViewPaymentVoucher,
|
||||
onConfirmPayment: handleShowConfirmPay,
|
||||
onReject: handleShowReject
|
||||
})
|
||||
if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
|
||||
const resourceTarget = resolveAuditResourceTarget({
|
||||
resourceType: 'agent_recharge',
|
||||
internalId: row.id
|
||||
})
|
||||
if (resourceTarget)
|
||||
actions.unshift({
|
||||
label: '审计记录',
|
||||
handler: () => openAuditInvestigation(resourceTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.financeTimeline)) {
|
||||
const financeTarget = resolveFinanceAuditTarget('recharge_id', row.id)
|
||||
if (financeTarget)
|
||||
actions.unshift({
|
||||
label: '资金链路',
|
||||
handler: () => openAuditInvestigation(financeTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
|
||||
const approvalTarget = resolveAuditResourceTarget({
|
||||
resourceType: 'approval_instance',
|
||||
internalId: row.approval_instance_id
|
||||
})
|
||||
if (approvalTarget)
|
||||
actions.unshift({
|
||||
label: '审批审计',
|
||||
handler: () => openAuditInvestigation(approvalTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// 查看支付凭证
|
||||
|
||||
@@ -10,6 +10,34 @@
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">{{ pageTitle }}</h2>
|
||||
<ElButton
|
||||
v-if="refund && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.refundEntry)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openRefundAudit"
|
||||
>
|
||||
审计记录
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="refund && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openRefundFinance"
|
||||
>
|
||||
资金链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="
|
||||
refund?.approval_instance_id &&
|
||||
isPlatformUser &&
|
||||
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
|
||||
"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openApprovalAudit"
|
||||
>
|
||||
审批审计
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="refund && canResubmit(refund) && hasAuth('refund:resubmit')"
|
||||
type="primary"
|
||||
@@ -119,16 +147,43 @@
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'RefundDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const refund = ref<Refund | null>(null)
|
||||
const refundVoucherFileKeys = ref<string[]>([])
|
||||
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
const openRefundAudit = () => {
|
||||
const target = resolveAuditResourceTarget({
|
||||
resourceType: 'refund',
|
||||
internalId: refund.value?.id
|
||||
})
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openRefundFinance = () => {
|
||||
const target = resolveFinanceAuditTarget('refund_id', refund.value?.id)
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openApprovalAudit = () => {
|
||||
const target = resolveAuditResourceTarget({
|
||||
resourceType: 'approval_instance',
|
||||
internalId: refund.value?.approval_instance_id
|
||||
})
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => `退款详情`)
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="220"
|
||||
:inlineActionsCount="2"
|
||||
:actionsWidth="160"
|
||||
:inlineActionsCount="1"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -164,6 +164,13 @@
|
||||
} from '@/utils/business/approvalSummary'
|
||||
import { toVoucherKeyList } from '@/utils/business'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
@@ -174,6 +181,7 @@
|
||||
defineOptions({ name: 'RefundList' })
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -730,6 +738,43 @@
|
||||
// 获取操作按钮
|
||||
const getActions = (row: Refund) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (
|
||||
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
|
||||
hasAuth(AUDIT_PERMISSIONS.refundEntry)
|
||||
) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.getUserInfo.user_type,
|
||||
resourceType: 'refund',
|
||||
internalId: row.id
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
if (hasAuth(AUDIT_PERMISSIONS.financeTimeline)) {
|
||||
const financeTarget = resolveFinanceAuditTarget('refund_id', row.id)
|
||||
if (financeTarget)
|
||||
actions.push({
|
||||
label: '资金链路',
|
||||
handler: () => openAuditInvestigation(financeTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
const approvalTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.getUserInfo.user_type,
|
||||
resourceType: 'approval_instance',
|
||||
internalId: row.approval_instance_id
|
||||
})
|
||||
if (approvalTarget)
|
||||
actions.push({
|
||||
label: '审批审计',
|
||||
handler: () => openAuditInvestigation(approvalTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
const voucherKeys = getRefundAttachmentKeys(row)
|
||||
|
||||
if (canResubmit(row) && hasAuth('refund:resubmit')) {
|
||||
|
||||
@@ -10,6 +10,22 @@
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">订单详情</h2>
|
||||
<ElButton
|
||||
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.orderEntry)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openOrderAudit"
|
||||
>
|
||||
审计记录
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openOrderFinance"
|
||||
>
|
||||
资金链路
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
@@ -82,6 +98,12 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'OrderDetail' })
|
||||
|
||||
@@ -93,6 +115,18 @@
|
||||
const loading = ref(false)
|
||||
const detailData = ref<Order | null>(null)
|
||||
const paymentVoucherFileKeys = ref<string[]>([])
|
||||
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
const openOrderAudit = () => {
|
||||
const target = resolveAuditResourceTarget({
|
||||
resourceType: 'order',
|
||||
internalId: detailData.value?.id
|
||||
})
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
const openOrderFinance = () => {
|
||||
const target = resolveFinanceAuditTarget('order_id', detailData.value?.id)
|
||||
if (target) openAuditInvestigation(target)
|
||||
}
|
||||
|
||||
// 格式化货币 - 将分转换为元
|
||||
const formatCurrency = (amount: number): string => {
|
||||
|
||||
@@ -282,6 +282,12 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
@@ -1351,6 +1357,31 @@
|
||||
const getActions = (row: Order) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (
|
||||
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
|
||||
hasAuth(AUDIT_PERMISSIONS.orderEntry)
|
||||
) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.getUserInfo.user_type,
|
||||
resourceType: 'order',
|
||||
internalId: row.id
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
if (hasAuth(AUDIT_PERMISSIONS.financeTimeline)) {
|
||||
const financeTarget = resolveFinanceAuditTarget('order_id', row.id)
|
||||
if (financeTarget)
|
||||
actions.push({
|
||||
label: '资金链路',
|
||||
handler: () => openAuditInvestigation(financeTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
}
|
||||
// 线下支付且有凭证可以查看
|
||||
if (
|
||||
row.payment_method === 'offline' &&
|
||||
|
||||
@@ -351,6 +351,12 @@
|
||||
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
|
||||
import { regionData } from '@/utils/constants/regionData'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
} from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
|
||||
defineOptions({ name: 'Shop' })
|
||||
|
||||
@@ -809,6 +815,32 @@
|
||||
const getActions = (row: ShopResponse) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth(AUDIT_PERMISSIONS.shopEntry)) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.getUserInfo.user_type,
|
||||
resourceType: 'shop',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.shop_code
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (
|
||||
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
|
||||
hasAuth(AUDIT_PERMISSIONS.financeTimeline)
|
||||
) {
|
||||
const financeTarget = resolveFinanceAuditTarget('shop_id', row.id)
|
||||
if (financeTarget)
|
||||
actions.push({
|
||||
label: '资金链路',
|
||||
handler: () => openAuditInvestigation(financeTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (hasAuth('shop:look_customer')) {
|
||||
actions.push({
|
||||
label: '账号列表',
|
||||
|
||||
Reference in New Issue
Block a user