This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user