feat(dashboard): reorganize budget and risk cards

This commit is contained in:
caoxiaozhu
2026-06-03 10:47:11 +08:00
parent faa39e6c06
commit 27dd2f0a0d
10 changed files with 554 additions and 564 deletions

View File

@@ -1,42 +1,34 @@
<template>
<section class="risk-observation-dashboard" :class="{ 'is-loading': showBlockingLoading }">
<div v-if="showBlockingLoading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
<div v-if="showBlockingLoading" class="table-state risk-dashboard-loading-state" role="status" aria-live="polite">
<TableLoadingState
:title="loadingLabel"
message="正在同步风险观察、风险等级和近期高风险记录"
icon="mdi mdi-shield-search"
variant="overlay"
motion="loop"
floating
/>
</div>
<article class="panel dashboard-card risk-trend-panel">
<div class="card-head">
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
<div class="risk-window-controls">
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
<span class="risk-window-label"> {{ dashboard.windowDays }} </span>
<EnterpriseSelect
class="risk-window-select"
:model-value="activeWindowDays"
:options="windowOptions"
size="small"
aria-label="风险看板时间窗口"
@update:model-value="emit('update:windowDays', $event)"
/>
<template v-else>
<article class="panel dashboard-card risk-trend-panel">
<div class="card-head">
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
<div class="risk-window-controls">
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
<span class="risk-window-label" aria-label="风险看板时间窗口"> {{ dashboard.windowDays }} </span>
</div>
</div>
</div>
<div v-if="loading" class="risk-dashboard-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载风险看板数据</span>
</div>
<div v-else-if="error" class="risk-dashboard-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<RiskDailyTrendChart v-else :rows="dailyRows" />
</article>
<div v-if="loading" class="risk-dashboard-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载风险看板数据</span>
</div>
<div v-else-if="error" class="risk-dashboard-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<RiskDailyTrendChart v-else :rows="dailyRows" />
</article>
<article class="panel dashboard-card risk-level-panel">
<article class="panel dashboard-card risk-level-panel">
<div class="card-head">
<h3>风险等级分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
@@ -45,20 +37,20 @@
:center-value="String(dashboard.totalObservations)"
center-label="风险观察"
/>
</article>
</article>
<article class="panel dashboard-card risk-source-panel">
<article class="panel dashboard-card risk-composition-panel">
<div class="card-head">
<h3>来源分布 <i class="mdi mdi-information-outline"></i></h3>
<h3>风险占比 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="sourceLegend"
:center-value="String(dashboard.totalObservations)"
center-label="归集来源"
center-label="风险项"
/>
</article>
</article>
<article class="panel dashboard-card risk-dimension-panel">
<article class="panel dashboard-card risk-dimension-panel">
<div class="card-head">
<h3>业务维度分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
@@ -79,9 +71,9 @@
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section>
</div>
</article>
</article>
<article class="panel dashboard-card risk-signal-panel">
<article class="panel dashboard-card risk-signal-panel">
<div class="card-head">
<h3>风险信号排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
@@ -95,19 +87,21 @@
<i class="mdi mdi-shield-check-outline"></i>
<span>当前周期暂无风险信号</span>
</div>
</article>
</article>
<article class="panel dashboard-card risk-ranking-panel">
<article class="panel dashboard-card risk-ranking-panel">
<div class="card-head">
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div v-if="rankingChartItems.length" class="risk-ranking-visual">
<BarChart
:items="rankingChartItems"
value-prefix=""
value-suffix=""
:compact="false"
/>
<div class="risk-ranking-chart-block">
<BarChart
:items="rankingChartItems"
value-prefix=""
value-suffix=""
:compact="false"
/>
</div>
<div class="risk-ranking-detail-grid">
<section
v-for="group in rankingDetailGroups"
@@ -132,65 +126,20 @@
<i class="mdi mdi-chart-bar"></i>
<span>当前周期暂无异常排行</span>
</div>
</article>
<article class="panel dashboard-card risk-effect-panel">
<div class="card-head">
<h3>算法闭环效果 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-effect-grid">
<div v-for="item in effectItems" :key="item.label" class="risk-effect-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
<article class="panel dashboard-card risk-recent-panel">
<div class="card-head">
<h3>近期高风险观察 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div v-if="recentHighObservations.length" class="risk-recent-list">
<button
v-for="item in recentHighObservations"
:key="item.observationKey || item.id"
class="risk-recent-row"
type="button"
:disabled="!item.claimId"
@click="openClaim(item)"
>
<span class="risk-recent-level" :class="item.riskLevel">
{{ formatRiskLevel(item.riskLevel) }}
</span>
<span class="risk-recent-main">
<strong>{{ formatObservationTitle(item) }}</strong>
<small>{{ item.claimNo || item.claimId || '未关联单据' }}</small>
</span>
<span class="risk-recent-score">{{ item.riskScore }}</span>
</button>
</div>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-check-circle-outline"></i>
<span>暂无高风险观察</span>
</div>
</article>
</article>
</template>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import BarChart from '../charts/BarChart.vue'
import DonutChart from '../charts/DonutChart.vue'
import RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import {
formatRiskDimensionLabel,
formatRiskLevelLabel,
formatRiskObservationTitle,
formatRiskSignalLabel
formatRiskDimensionLabel
} from '../../utils/riskLabels.js'
const props = defineProps({
@@ -201,13 +150,9 @@ const props = defineProps({
sourceLegend: { type: Array, default: () => [] },
signalRanking: { type: Array, default: () => [] },
dailyRows: { type: Array, default: () => [] },
windowOptions: { type: Array, default: () => [] },
activeWindowDays: { type: Number, default: 30 },
lastUpdatedAt: { type: String, default: '' }
})
const emit = defineEmits(['update:windowDays'])
const router = useRouter()
const loadingLabel = computed(() => (
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
))
@@ -227,7 +172,6 @@ const lastUpdatedLabel = computed(() => {
})}`
})
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
const dimensionGroups = computed(() => [
buildDimensionGroup('部门', props.dashboard.departmentDistribution, 'department'),
buildDimensionGroup('费用类型', props.dashboard.expenseTypeDistribution, 'expense_type'),
@@ -269,22 +213,6 @@ const rankingDetailGroups = computed(() => rankingGroups.value
...group,
rows: group.rows.slice(0, 3)
})))
const effectItems = computed(() => {
const sourceDistribution = props.dashboard.sourceDistribution || {}
const total = Number(props.dashboard.totalObservations || 0)
const pending = Number(props.dashboard.pendingCount || 0)
const processedRate = total > 0 ? Math.max(0, (total - pending) / total) : 0
return [
{ label: '规则命中', value: sourceDistribution.rule_center || 0 },
{ label: '图谱异常', value: sourceDistribution.financial_risk_graph || 0 },
{ label: '待复核线索', value: props.dashboard.riskClueCount || pending },
{ label: '反馈样本', value: props.dashboard.feedbackSampleCount || 0 },
{ label: '确认率', value: formatPercent(props.dashboard.confirmationRate) },
{ label: '误报率', value: formatPercent(props.dashboard.falsePositiveRate) },
{ label: '完成率', value: formatPercent(processedRate) }
]
})
function buildDimensionGroup(label, distribution = {}, kind = '') {
const rows = Object.entries(distribution || {})
@@ -328,36 +256,9 @@ function formatAmount(value) {
return `¥${Math.round(amount)}`
}
function formatPercent(value) {
return `${Math.round(Number(value || 0) * 100)}%`
}
function formatRiskLevel(value) {
return formatRiskLevelLabel(value)
}
function formatDimensionName(value, kind = '') {
return formatRiskDimensionLabel(value, kind)
}
function formatSignal(value) {
return formatRiskSignalLabel(value)
}
function formatObservationTitle(item) {
return formatRiskObservationTitle(item)
}
function openClaim(item) {
if (!item.claimId) {
return
}
router.push({
name: 'app-document-detail',
params: { requestId: item.claimId }
})
}
</script>
<style scoped>
@@ -369,23 +270,9 @@ function openClaim(item) {
min-width: 0;
}
.risk-dashboard-loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: rgba(248, 250, 252, .82);
backdrop-filter: blur(2px);
}
.risk-observation-dashboard.is-loading .dashboard-card {
pointer-events: none;
.risk-dashboard-loading-state {
grid-column: 1 / -1;
min-height: 0;
}
.dashboard-card {
@@ -440,21 +327,18 @@ function openClaim(item) {
gap: 8px;
}
.risk-window-select {
width: 108px;
}
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel {
.risk-dimension-panel {
grid-column: span 6;
}
.risk-ranking-panel {
grid-column: span 12;
}
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
.risk-composition-panel {
grid-column: span 3;
}
@@ -500,19 +384,29 @@ function openClaim(item) {
.risk-ranking-visual {
display: grid;
gap: 14px;
gap: 16px;
min-height: 430px;
}
.risk-ranking-visual :deep(.bar-chart) {
min-height: 250px;
.risk-ranking-chart-block {
min-width: 0;
display: flex;
min-height: 320px;
padding: 2px 0 4px;
}
.risk-ranking-visual :deep(.chart-area) {
height: 250px;
.risk-ranking-chart-block :deep(.bar-chart) {
flex: 1;
min-height: 320px;
}
.risk-ranking-chart-block :deep(.chart-area) {
height: 320px;
}
.risk-ranking-detail-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: stretch;
}
.risk-dimension-group,
@@ -527,6 +421,10 @@ function openClaim(item) {
background: #f8fafc;
}
.risk-ranking-group {
min-height: 132px;
}
.risk-dimension-title,
.risk-ranking-title {
color: #334155;
@@ -639,123 +537,6 @@ function openClaim(item) {
text-align: right;
}
.risk-effect-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.risk-effect-item {
min-height: 84px;
display: grid;
align-content: center;
gap: 6px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.risk-effect-item span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.risk-effect-item strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.risk-recent-list {
display: grid;
gap: 8px;
}
.risk-recent-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: 46px minmax(0, 1fr) 42px;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 180ms ease, background 180ms ease;
}
.risk-recent-row:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb), .26);
background: #f8fafc;
}
.risk-recent-row:disabled {
cursor: default;
}
.risk-recent-level {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 4px;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 900;
}
.risk-recent-level.critical,
.risk-recent-level.high {
background: rgba(239, 68, 68, .1);
color: #b91c1c;
}
.risk-recent-level.medium {
background: rgba(245, 158, 11, .12);
color: #b45309;
}
.risk-recent-main {
min-width: 0;
display: grid;
gap: 3px;
}
.risk-recent-main strong,
.risk-recent-main small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-recent-main strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.risk-recent-main small {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.risk-recent-score {
color: #0f172a;
font-size: 16px;
font-weight: 900;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 1280px) {
.risk-trend-panel,
.risk-signal-panel,
@@ -765,9 +546,7 @@ function openClaim(item) {
}
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
.risk-composition-panel {
grid-column: span 6;
}
}
@@ -782,9 +561,7 @@ function openClaim(item) {
.risk-dimension-panel,
.risk-ranking-panel,
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
.risk-composition-panel {
grid-column: 1;
}