feat: 审计链路
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 59s

This commit is contained in:
luo
2026-08-07 18:33:37 +08:00
parent fa3d7088f9
commit b8b2854aa6
54 changed files with 5448 additions and 12 deletions

View 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>