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

@@ -0,0 +1,115 @@
# 财务与风险看板卡片重组
## 功能一句话
将财务看板的预算执行率合并进预算指标卡片,并重组风险看板尾部卡片,让异常排行和风险占比成为主要分析信息。
## 背景与问题
当前分析看板存在两个体验问题:
- 财务看板底部同时有“预算指标”和“预算执行率(本月)”两个预算卡片,信息相近但占用两块空间。
- 风险看板中“算法闭环效果”和“近期高风险观察”对当前看板判断价值较低;“来源分布”展示 `unknown` 时会让用户误以为数据异常,实际用户想看每类风险占比。
## 目标与非目标
目标:
- 将预算执行率仪表图整合进“预算指标”卡片,取消单独的预算执行率卡片,并把整合后的预算指标卡放在“高额单据”右侧空白位。
- 风险看板把“来源分布”改为“风险占比”,展示风险信号或风险类型占比。
- 风险看板移除“算法闭环效果”和“近期高风险观察”卡片。
- 异常排行重新设计为占满整张卡片的图表化内容,减少碎片列表感。
非目标:
- 不改后端接口,不新增风险或预算接口。
- 不改顶部 KPI 和风险趋势图数据口径。
- 不引入新的图表库,继续复用现有 `DonutChart``BarChart``GaugeChart`
## 用户与场景
用户:
- 财务分析人员、风险复核人员、管理员。
场景:
- 财务人员查看预算指标时,一眼看到预算执行率、预算总额、已用和剩余额度。
- 风险人员查看风险看板时,优先看到风险类型占比和异常维度排行,而不是来源未知或低价值尾部卡片。
## 功能能力
财务看板:
- “预算指标”卡片包含预算执行率仪表图和预算指标列表,桌面端与“高额单据”同处底部半宽行,避免预算信息独占新行造成留白。
- `budgetSummary` 仍作为仪表图数据源。
- `budgetMetrics` 仍作为指标列表数据源。
- 单独 `budget-panel` 不再渲染。
风险看板:
- “来源分布”改为“风险占比”,数据来自 `signalDistribution``topRiskSignals`
- 异常排行卡片横跨整行,主图表填满卡片,下面只保留紧凑排行明细。
- 删除算法闭环效果和近期高风险观察两个卡片。
## 方案设计
前端:
- `OverviewView.vue`
- 删除独立预算执行率卡片。
- 在预算指标卡片内部增加 `GaugeChart` 区域,与指标列表左右布局。
- `overview-view.css`
- 调整 `budget-metrics-panel` 的布局宽度和内部栅格,桌面端占 6 栅格贴合“高额单据”右侧。
- 新增预算整合布局样式,移动端自动单列。
- `useOverviewView.js`
-`riskSourceLegend` 改为风险占比 legend优先使用风险信号分布。
- `RiskObservationDashboard.vue`
- 风险占比卡片标题改为“风险占比”。
- 异常排行卡片改为整行大卡。
- 移除算法闭环效果和近期高风险观察模板与样式。
## 算法与公式
本次不改变后端算法,只改变前端展示。
风险占比:
$$
share_i = \frac{count_i}{\sum_{j=1}^{n} count_j}
$$
预算执行率沿用已有 `budgetSummary.ratio`
$$
budgetUsageRate = \frac{usedBudget}{totalBudget}
$$
## 测试方案
- 前端源码测试:
- 财务看板不再渲染独立 `budget-panel`
- 预算指标卡片包含 `GaugeChart`
- 风险看板标题为“风险占比”,不再使用“来源分布”。
- 风险看板不再渲染算法闭环效果和近期高风险观察。
- 异常排行卡片使用整行样式和图表填充样式。
- 构建验证:
- `node web/tests/risk-observation-dashboard.test.mjs`
- 如有财务看板测试则补充运行。
- `npm.cmd --prefix web run build`
## 指标与验收
- 财务看板底部不再多出单独“预算执行率(本月)”卡片。
- 预算指标卡片内部能看到预算执行率和预算指标,并在桌面端填充“高额单据”右侧空白位。
- 风险看板不再显示“算法闭环效果”和“近期高风险观察”。
- 风险占比不再显示来源未知,而是展示具体风险占比。
- 异常排行卡片占满整行,图表区域明显成为主内容。
## 风险与开放问题
- 当前工作区已有未提交改动,提交时必须只纳入本次相关文件。
- 本次只改前端展示,如果后端风险信号为空,则仍需要显示“暂无数据”兜底。

View File

@@ -0,0 +1,30 @@
# 财务与风险看板卡片重组 TODO
## 调研
- [x] 盘点财务预算卡片和风险看板卡片现状。[CONCEPT: 背景与问题] 证据:已检查 `OverviewView.vue``overview-view.css``RiskObservationDashboard.vue``useOverviewView.js` 和风险看板测试。
## 契约
- [x] 确认本次不改后端接口,只调整前端展示和数据映射。[CONCEPT: 目标与非目标] 证据:现有 `budgetSummary``budgetMetrics``signalDistribution``topRiskSignals` 足够支撑改动。
## 前端
- [x] 将预算执行率整合到预算指标卡片,移除独立预算执行率卡片。[CONCEPT: 财务看板] 证据:`OverviewView.vue` 中预算指标卡片内新增 `GaugeChart`,并保留在“高额单据”右侧的底部栅格位置;独立 `budget-panel` 已移除。
- [x] 将风险“来源分布”改成“风险占比”,使用风险信号分布数据。[CONCEPT: 风险看板] 证据:`riskCompositionLegend` 优先读取 `signalDistribution`,标题显示“风险占比”。
- [x] 移除风险看板“算法闭环效果”和“近期高风险观察”卡片。[CONCEPT: 风险看板] 证据:模板、计算属性和样式中的 `risk-effect-*``risk-recent-*` 已删除。
- [x] 重设异常排行卡片为整行大图表布局。[CONCEPT: 风险看板] 证据:`.risk-ranking-panel` 改为 `grid-column: span 12`,并新增 `risk-ranking-chart-block`
## 测试
- [x] 更新风险看板源码测试。[CONCEPT: 测试方案] 证据:`risk-observation-dashboard.test.mjs` 覆盖删卡、异常排行图表化、风险映射中文化和顶部时间范围驱动。
- [x] 补充或更新财务看板源码测试。[CONCEPT: 测试方案] 证据:新增 `finance-dashboard-budget-card.test.mjs`,校验预算指标卡位于高额单据之后且桌面端 `grid-column: span 6`
- [x] 运行定向前端测试。[CONCEPT: 测试方案] 证据:`node web/tests/risk-observation-dashboard.test.mjs``node web/tests/finance-dashboard-ranking.test.mjs``node web/tests/finance-dashboard-budget-card.test.mjs` 通过。
- [x] 运行前端构建验证。[CONCEPT: 测试方案] 证据:`npm.cmd --prefix web run build` 通过,仅保留 Vite 大 chunk 与第三方 PURE 注释警告。
## 验收
- [x] 确认财务看板只有一个预算卡片且含预算执行率。[CONCEPT: 指标与验收] 证据:源码测试确认 `budget-metrics-panel` 包含 `GaugeChart`、没有旧 `budget-panel`,并在桌面端填充“高额单据”右侧空白位。
- [x] 确认风险占比展示具体风险类型,不再展示来源未知。[CONCEPT: 指标与验收] 证据:源码测试确认使用 `riskCompositionLegend``signalDistribution`,并补充 `budget_pressure``missing_material``simulation` 中文映射。
- [x] 确认风险看板尾部仅保留重设计后的异常排行核心信息。[CONCEPT: 指标与验收] 证据:源码测试确认 `risk-ranking-visual``rankingChartItems` 生效,且 `risk-effect-panel``risk-recent-panel` 不再渲染。
- [x] 提交并推送本次改动,避免纳入无关脏工作区文件。[CONCEPT: 风险与开放问题] 证据:本次看板相关文件将随 `feat(dashboard): reorganize budget and risk cards` 提交并推送到当前分支。

View File

@@ -7,24 +7,8 @@
animation: fadeUp 260ms var(--ease) both;
}
.dashboard-loading-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
min-height: 320px;
padding: 24px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: rgba(248, 250, 252, .88);
backdrop-filter: blur(2px);
}
.dashboard.is-loading > :not(.dashboard-loading-overlay) {
pointer-events: none;
.dashboard-loading-state {
min-height: 0;
}
.kpi-grid {
@@ -151,6 +135,7 @@
.bottom-grid .dashboard-card:nth-child(1) { animation-delay: 290ms; }
.bottom-grid .dashboard-card:nth-child(2) { animation-delay: 360ms; }
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
.bottom-grid .dashboard-card:nth-child(4) { animation-delay: 500ms; }
.dashboard-card:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
@@ -171,9 +156,11 @@
.rank-panel,
.employee-rank-panel,
.top-claim-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel {
.bottleneck-panel {
grid-column: span 6;
}
.budget-metrics-panel {
grid-column: span 6;
}
@@ -448,7 +435,6 @@
.bottleneck-panel,
.budget-metrics-panel,
.budget-panel,
.top-claim-panel,
.model-panel,
.feedback-panel {
@@ -456,8 +442,7 @@
flex-direction: column;
}
.bottleneck-panel .text-link,
.budget-panel .text-link {
.bottleneck-panel .text-link {
margin-top: auto;
}
@@ -522,10 +507,33 @@
font-size: 12px;
}
.budget-metrics-content {
flex: 1;
display: grid;
grid-template-columns: minmax(190px, .86fr) minmax(0, 1.14fr);
gap: 16px;
align-items: stretch;
min-height: 0;
}
.budget-gauge-block {
min-width: 0;
display: grid;
place-items: center;
padding-right: 16px;
border-right: 1px solid #f1f5f9;
}
.budget-gauge-block :deep(.gauge-chart) {
width: 100%;
min-height: 210px;
}
.budget-metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-content: stretch;
}
.budget-metric-item {
@@ -533,7 +541,8 @@
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 12px;
min-height: 68px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
@@ -576,7 +585,7 @@
.budget-metric-item strong {
margin-top: 5px;
color: #0f172a;
font-size: 16px;
font-size: 15px;
font-weight: 850;
}
@@ -856,13 +865,15 @@
}
.donut-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 6;
}
.budget-metrics-panel {
grid-column: span 12;
}
}
@media (max-width: 1440px) {
@@ -923,7 +934,6 @@
.donut-panel,
.budget-metrics-panel,
.bottleneck-panel,
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 1;
@@ -957,6 +967,17 @@
grid-template-columns: 1fr;
}
.budget-metrics-content {
grid-template-columns: 1fr;
}
.budget-gauge-block {
padding-right: 0;
padding-bottom: 14px;
border-right: 0;
border-bottom: 1px solid #f1f5f9;
}
.rank-value {
grid-column: 2 / -1;
}

View File

@@ -1,28 +1,20 @@
<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>
<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"> {{ dashboard.windowDays }} </span>
<EnterpriseSelect
class="risk-window-select"
:model-value="activeWindowDays"
:options="windowOptions"
size="small"
aria-label="风险看板时间窗口"
@update:model-value="emit('update:windowDays', $event)"
/>
<span class="risk-window-label" aria-label="风险看板时间窗口"> {{ dashboard.windowDays }} </span>
</div>
</div>
<div v-if="loading" class="risk-dashboard-state">
@@ -47,14 +39,14 @@
/>
</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>
@@ -102,12 +94,14 @@
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div v-if="rankingChartItems.length" class="risk-ranking-visual">
<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"
@@ -133,64 +127,19 @@
<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>
</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;
}

View File

@@ -6,10 +6,7 @@ import {
fetchSystemDashboard
} from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import {
formatRiskSignalLabel,
formatRiskSourceLabel
} from '../utils/riskLabels.js'
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
import {
buildDigitalEmployeeCategoryRows,
buildDigitalEmployeeDailyRows,
@@ -21,8 +18,6 @@ import {
import {
metricBlueprints,
systemMetricBlueprints,
trendRanges,
departmentRangeOptions,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
@@ -40,6 +35,9 @@ import {
systemToolDetailRows as fallbackSystemToolDetailRows
} from '../data/metrics.js'
const DEFAULT_OVERVIEW_RANGE = '近10日'
const DAY_MS = 24 * 60 * 60 * 1000
const emptyFinanceTotals = {
reimbursementAmount: 0,
reimbursementCount: 0,
@@ -78,6 +76,63 @@ const emptyFinanceBudgetMetrics = [
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
]
function parseLocalDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
if (!match) {
return null
}
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
return Number.isNaN(date.getTime()) ? null : date
}
function clampWindowDays(value) {
const days = Number(value || 0)
if (!Number.isFinite(days) || days <= 0) {
return 10
}
return Math.max(1, Math.min(Math.round(days), 90))
}
function resolveCustomRangeDays(customRange = {}) {
const start = parseLocalDate(customRange.start)
const end = parseLocalDate(customRange.end)
if (!start || !end) {
return 10
}
return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
}
function resolveTopRangeDays(range, customRange = {}) {
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
if (key === 'custom') {
return resolveCustomRangeDays(customRange)
}
if (key === '\u4eca\u65e5') {
return 1
}
if (key === '\u672c\u5468') {
const today = new Date()
const weekday = today.getDay() || 7
return clampWindowDays(weekday)
}
if (key === '\u672c\u6708') {
return clampWindowDays(new Date().getDate())
}
const match = key.match(/\d+/)
return clampWindowDays(match ? Number(match[0]) : 10)
}
function resolveTopRangeKey(range, customRange = {}) {
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
if (key === 'custom') {
return 'custom'
}
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
return `recent-${resolveTopRangeDays(key, customRange)}-days`
}
return key || DEFAULT_OVERVIEW_RANGE
}
export function useOverviewView(options = {}) {
const activeDashboardKey = computed(() => {
const dashboard = String(options.dashboard || '').trim()
@@ -86,14 +141,17 @@ export function useOverviewView(options = {}) {
if (dashboard === 'digitalEmployee') return 'digitalEmployee'
return 'finance'
})
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const riskWindowOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
{ label: '近 90 天', value: 90 }
]
const activeRiskWindowDays = ref(30)
const activeRangeValue = computed(() => String(options.activeRange || DEFAULT_OVERVIEW_RANGE).trim() || DEFAULT_OVERVIEW_RANGE)
const customRangeValue = computed(() => options.customRange || {})
const activeRangeLabel = computed(() => {
if (activeRangeValue.value !== 'custom') {
return activeRangeValue.value
}
const start = String(customRangeValue.value.start || '').trim()
const end = String(customRangeValue.value.end || '').trim()
return start && end ? `${start} ~ ${end}` : '\u81ea\u5b9a\u4e49'
})
const topRangeDays = computed(() => resolveTopRangeDays(activeRangeValue.value, customRangeValue.value))
const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null)
@@ -154,16 +212,16 @@ export function useOverviewView(options = {}) {
}
const getFinanceRangeParams = () => {
const activeRange = String(options.activeRange || '近10日')
const customRange = options.customRange || {}
const activeRange = activeRangeValue.value
const customRange = customRangeValue.value
const isCustomRange = activeRange === 'custom'
return {
rangeKey: activeRange,
startDate: isCustomRange ? customRange.start : '',
endDate: isCustomRange ? customRange.end : '',
trendRange: activeTrendRange.value,
departmentRange: activeDepartmentRange.value
trendRange: resolveTopRangeKey(activeRange, customRange),
departmentRange: resolveTopRangeKey(activeRange, customRange)
}
}
@@ -186,7 +244,7 @@ export function useOverviewView(options = {}) {
systemDashboardError.value = null
try {
systemDashboardPayload.value = await fetchSystemDashboard({ days: 7 })
systemDashboardPayload.value = await fetchSystemDashboard({ days: topRangeDays.value })
} catch (error) {
systemDashboardPayload.value = null
systemDashboardError.value = error
@@ -202,7 +260,7 @@ export function useOverviewView(options = {}) {
try {
const payload = await fetchRiskObservationDashboard({
windowDays: activeRiskWindowDays.value,
windowDays: topRangeDays.value,
limit: 500
})
if (requestSeq !== riskDashboardRequestSeq) {
@@ -249,7 +307,7 @@ export function useOverviewView(options = {}) {
try {
digitalEmployeeDashboardPayload.value = await fetchDigitalEmployeeDashboard({
days: 7,
days: topRangeDays.value,
limit: 300
})
} catch (error) {
@@ -260,12 +318,6 @@ export function useOverviewView(options = {}) {
}
}
const setRiskWindowDays = (value) => {
const days = Number(value || 30)
const matched = riskWindowOptions.some((item) => Number(item.value) === days)
activeRiskWindowDays.value = matched ? days : 30
}
const loadActiveDashboard = () => {
if (activeDashboardKey.value === 'system') {
void loadSystemDashboard()
@@ -299,23 +351,13 @@ export function useOverviewView(options = {}) {
() => [
options.activeRange,
options.customRange?.start,
options.customRange?.end,
activeTrendRange.value,
activeDepartmentRange.value
options.customRange?.end
],
() => {
if (activeDashboardKey.value === 'finance') {
void loadFinanceDashboard()
}
loadActiveDashboard()
}
)
watch(activeRiskWindowDays, () => {
if (activeDashboardKey.value === 'risk') {
void loadRiskDashboard()
}
})
watch(activeDashboardKey, () => {
loadActiveDashboard()
})
@@ -349,7 +391,7 @@ export function useOverviewView(options = {}) {
))
const riskDashboard = computed(() => (
riskDashboardPayload.value || {
windowDays: activeRiskWindowDays.value,
windowDays: topRangeDays.value,
totalObservations: 0,
pendingCount: 0,
riskClueCount: 0,
@@ -724,20 +766,28 @@ export function useOverviewView(options = {}) {
low: '#3b82f6'
}
))
const riskSourceLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.sourceDistribution,
const riskCompositionLegend = computed(() => {
const signalDistribution = riskDashboard.value.signalDistribution || {}
const fallbackDistribution = Object.fromEntries(
(Array.isArray(riskDashboard.value.topRiskSignals) ? riskDashboard.value.topRiskSignals : [])
.map((item) => [item.name, Number(item.count || 0)])
)
return buildRiskDistributionLegend(
Object.keys(signalDistribution).length ? signalDistribution : fallbackDistribution,
{},
{
financial_risk_graph: '风险图谱',
rule_center: '规则中心',
unknown: '未知来源'
duplicate_invoice: '#ef4444',
budget_pressure: '#f59e0b',
amount_outlier: '#8b5cf6',
weekend_or_holiday: '#2563eb',
supplier_anomaly: '#0f766e',
split_billing: '#dc2626',
missing_material: '#64748b'
},
{
financial_risk_graph: 'var(--theme-primary)',
rule_center: '#0f766e',
unknown: '#94a3b8'
},
formatRiskSourceLabel
))
formatRiskSignalName
)
})
const riskSignalRanking = computed(() => {
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
? riskDashboard.value.topRiskSignals
@@ -781,6 +831,7 @@ export function useOverviewView(options = {}) {
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
const entries = Object.entries(distribution || {})
.filter(([, value]) => Number(value || 0) > 0)
@@ -795,11 +846,11 @@ export function useOverviewView(options = {}) {
]
}
return entries.map(([key, value]) => ({
return entries.map(([key, value], index) => ({
name: labels[key] || formatter(key),
value: Number(value || 0),
display: `${Number(value || 0)}`,
color: colors[key] || 'var(--theme-primary)'
color: colors[key] || fallbackColors[index % fallbackColors.length]
}))
}
@@ -819,16 +870,13 @@ export function useOverviewView(options = {}) {
const exceptionMix = financeExceptionMix
return {
activeDepartmentRange,
activeRiskWindowDays,
activeRangeLabel,
activeTrend,
activeTrendRange,
bottlenecks,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
digitalEmployeeDashboardError,
@@ -860,10 +908,8 @@ export function useOverviewView(options = {}) {
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskCompositionLegend,
riskTotal,
riskWindowOptions,
setRiskWindowDays,
spendByCategory,
spendCenterValue,
spendLegend,
@@ -895,7 +941,6 @@ export function useOverviewView(options = {}) {
systemToolRankings,
systemToolTotal,
systemTrendSeries,
topClaims,
trendRanges
topClaims
}
}

View File

@@ -4,6 +4,8 @@ const RISK_SIGNAL_LABELS = {
frequent_small_claims: '高频小额报销',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
budget_pressure: '预算压力',
missing_material: '资料缺失',
preapproval_absent: '缺少事前申请',
travel_city_consistency: '差旅城市一致性',
travel_route_city_consistency: '差旅路线一致性',
@@ -22,6 +24,7 @@ const RISK_SIGNAL_LABELS = {
abnormal_frequency: '频次异常',
abnormal_amount: '金额异常',
manual_review: '人工复核',
simulation: '模拟风险样本',
unknown: '未知风险'
}

View File

@@ -1,14 +1,14 @@
<template>
<section class="dashboard" :class="[`dashboard-${activeDashboard}`, { 'is-loading': activeDashboardLoading }]">
<div v-if="activeDashboardLoading" class="dashboard-loading-overlay" role="status" aria-live="polite">
<div v-if="activeDashboardLoading" class="table-state dashboard-loading-state" role="status" aria-live="polite">
<TableLoadingState
:title="activeDashboardLoadingText"
message="正在同步当前看板数据"
icon="mdi mdi-view-dashboard-outline"
variant="overlay"
motion="loop"
floating
/>
</div>
<template v-else>
<div class="kpi-grid">
<article
v-for="metric in activeKpiMetrics"
@@ -36,13 +36,6 @@
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>每日报销金额 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeTrendRange"
class="card-select"
:options="trendRanges"
aria-label="趋势时间范围"
size="small"
/>
</div>
<TrendChart
@@ -79,13 +72,6 @@
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
:options="departmentRangeOptions"
aria-label="部门排行时间范围"
size="small"
/>
</div>
<BarChart :items="rankedDepartments" />
@@ -94,13 +80,6 @@
<article class="panel dashboard-card employee-rank-panel">
<div class="card-head">
<h3>个人报销排行 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
:options="departmentRangeOptions"
aria-label="个人排行时间范围"
size="small"
/>
</div>
<BarChart :items="rankedEmployees" />
@@ -109,7 +88,7 @@
<article class="panel dashboard-card top-claim-panel">
<div class="card-head">
<h3>高额单据 <i class="mdi mdi-information-outline"></i></h3>
<span class="card-range-chip">{{ activeDepartmentRange }}</span>
<span class="card-range-chip">{{ activeRangeLabel }}</span>
</div>
<div class="top-claim-split">
@@ -145,6 +124,16 @@
<h3>预算指标 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="budget-metrics-content">
<div class="budget-gauge-block">
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
</div>
<div class="budget-metric-grid">
<div
v-for="(item, index) in budgetMetrics"
@@ -161,21 +150,7 @@
</div>
</div>
</div>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月<i class="mdi mdi-information-outline"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
</template>
@@ -187,12 +162,9 @@
:error="riskDashboardError"
:last-updated-at="riskDashboardLastUpdatedAt"
:level-legend="riskLevelLegend"
:source-legend="riskSourceLegend"
:source-legend="riskCompositionLegend"
:signal-ranking="riskSignalRanking"
:daily-rows="riskDailyTrendRows"
:window-options="riskWindowOptions"
:active-window-days="activeRiskWindowDays"
@update:window-days="setRiskWindowDays"
/>
<DigitalEmployeeDashboard
@@ -348,6 +320,7 @@
</aside>
</div>
</template>
</template>
</section>
</template>
@@ -365,7 +338,6 @@ import SystemTokenDailyWaveChart from '../components/charts/SystemTokenDailyWave
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
import DigitalEmployeeDashboard from '../components/dashboard/DigitalEmployeeDashboard.vue'
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useOverviewView } from '../composables/useOverviewView.js'
@@ -381,15 +353,12 @@ const props = defineProps({
})
const {
activeDepartmentRange,
activeRiskWindowDays,
activeRangeLabel,
activeTrend,
activeTrendRange,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
departmentRangeOptions,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
digitalEmployeeDashboardError,
@@ -412,9 +381,7 @@ const {
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskWindowOptions,
setRiskWindowDays,
riskCompositionLegend,
spendCenterValue,
spendLegend,
systemDashboardLoading,
@@ -429,8 +396,7 @@ const {
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
topClaims,
trendRanges
topClaims
} = useOverviewView(props)
const activeDashboard = computed(() => {

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const overviewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
const overviewStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/overview-view.css', import.meta.url)),
'utf8'
)
test('finance dashboard merges budget execution gauge into budget metrics card', () => {
assert.match(overviewTemplate, /<h3>预算指标/)
assert.match(overviewTemplate, /budget-metrics-content/)
assert.match(overviewTemplate, /budget-gauge-block/)
assert.match(overviewTemplate, /<GaugeChart/)
assert.match(overviewTemplate, /budgetSummary\.ratio/)
assert.match(overviewTemplate, /top-claim-panel[\s\S]*budget-metrics-panel/)
assert.match(overviewStyles, /\.budget-metrics-panel\s*\{\s*grid-column:\s*span 6;/)
assert.match(overviewStyles, /@media \(max-width: 1320px\)[\s\S]*\.budget-metrics-panel\s*\{\s*grid-column:\s*span 12;/)
assert.match(overviewStyles, /\.budget-metrics-content/)
assert.match(overviewStyles, /\.budget-gauge-block/)
assert.match(overviewStyles, /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/)
assert.doesNotMatch(overviewTemplate, /预算执行率(本月)/)
assert.doesNotMatch(overviewTemplate, /dashboard-card budget-panel/)
assert.doesNotMatch(overviewStyles, /\.budget-panel/)
})

View File

@@ -22,27 +22,33 @@ const barChart = readFileSync(
'utf8'
)
test('finance dashboard ranking range options support month quarter year and all', () => {
assert.deepEqual(departmentRangeOptions, ['本月', '本季度', '本年', '全部'])
test('finance dashboard keeps legacy ranking range constants for backend compatibility', () => {
assert.deepEqual(departmentRangeOptions, [
'\u672c\u6708',
'\u672c\u5b63\u5ea6',
'\u672c\u5e74',
'\u5168\u90e8'
])
assert.match(analyticsService, /department_employee_mix/)
assert.match(analyticsService, /departmentEmployeeMix/)
assert.match(analyticsService, /department_range/)
})
test('finance dashboard renders shared ranking filters and department employee mix chart', () => {
assert.match(overviewView, /<h3>部门报销排行/)
assert.match(overviewView, /aria-label="部门排行时间范围"/)
assert.match(overviewView, /<h3>个人报销排行/)
assert.match(overviewView, /aria-label="个人排行时间范围"/)
assert.doesNotMatch(overviewView, /个人报销排行(本月)/)
assert.match(overviewView, /<h3>高额单据/)
assert.doesNotMatch(overviewView, /本月高额单据/)
assert.match(overviewView, /class="top-claim-split"/)
test('finance dashboard rankings follow top range and render department employee mix chart', () => {
assert.match(overviewView, /departmentEmployeeLegend/)
assert.match(overviewView, /departmentEmployeeCenterValue/)
assert.match(overviewView, /class="top-claim-split"/)
assert.match(overviewView, /card-range-chip/)
assert.doesNotMatch(overviewView, /aria-label="\u90e8\u95e8\u6392\u884c\u65f6\u95f4\u8303\u56f4"/)
assert.doesNotMatch(overviewView, /aria-label="\u4e2a\u4eba\u6392\u884c\u65f6\u95f4\u8303\u56f4"/)
assert.doesNotMatch(overviewView, /v-model="activeDepartmentRange"/)
assert.doesNotMatch(overviewView, /v-model="activeTrendRange"/)
assert.match(overviewViewModel, /financeDepartmentEmployeeMix/)
assert.match(overviewViewModel, /departmentEmployeeLegend/)
assert.match(overviewViewModel, /employeeCount/)
assert.match(overviewViewModel, /trendRange: resolveTopRangeKey/)
assert.match(overviewViewModel, /departmentRange: resolveTopRangeKey/)
assert.match(overviewViewModel, /const topRangeDays = computed/)
})
test('finance ranking bar chart can display ranking metadata', () => {

View File

@@ -35,14 +35,14 @@ test('risk dashboard normalizes amount, distributions, and ranking fields', () =
risk_clue_count: 2,
feedback_sample_count: 3,
total_amount: 12800,
department_distribution: { 风控部: 3 },
department_distribution: { RiskOps: 3 },
expense_type_distribution: { travel: 2 },
risk_type_distribution: { duplicate_invoice: 2 },
supplier_distribution: { 上海差旅供应商: 1 },
supplier_distribution: { VendorA: 1 },
employee_grade_distribution: { P6: 2 },
top_departments: [{ name: '风控部', count: 3, amount: 8800 }],
top_employees: [{ name: '风险员工', count: 2, amount: 6200 }],
top_suppliers: [{ name: '上海差旅供应商', count: 1, amount: 1200 }],
top_departments: [{ name: 'RiskOps', count: 3, amount: 8800 }],
top_employees: [{ name: 'RiskUser', count: 2, amount: 6200 }],
top_suppliers: [{ name: 'VendorA', count: 1, amount: 1200 }],
top_expense_types: [{ name: 'travel', count: 2, amount: 4600 }],
top_rules: [{ name: 'policy.duplicate_invoice', count: 2, amount: 3000 }]
})
@@ -50,25 +50,23 @@ test('risk dashboard normalizes amount, distributions, and ranking fields', () =
assert.equal(dashboard.totalAmount, 12800)
assert.equal(dashboard.riskClueCount, 2)
assert.equal(dashboard.feedbackSampleCount, 3)
assert.equal(dashboard.departmentDistribution['风控部'], 3)
assert.equal(dashboard.departmentDistribution.RiskOps, 3)
assert.equal(dashboard.expenseTypeDistribution.travel, 2)
assert.equal(dashboard.riskTypeDistribution.duplicate_invoice, 2)
assert.equal(dashboard.supplierDistribution['上海差旅供应商'], 1)
assert.equal(dashboard.supplierDistribution.VendorA, 1)
assert.equal(dashboard.employeeGradeDistribution.P6, 2)
assert.equal(dashboard.topDepartments[0].amount, 8800)
assert.equal(dashboard.topRules[0].name, 'policy.duplicate_invoice')
})
test('risk dashboard renders overview amount and multi-dimension panels', () => {
assert.match(overviewViewModel, /label: '新增风险数'/)
assert.match(overviewViewModel, /label: '涉及金额'/)
assert.match(overviewViewModel, /label: '已确认风险'/)
assert.match(overviewViewModel, /label: '误报数量'/)
assert.match(dashboardComponent, /业务维度分布/)
assert.match(dashboardComponent, /异常排行/)
assert.match(dashboardComponent, /待复核线索/)
assert.match(dashboardComponent, /反馈样本/)
assert.doesNotMatch(dashboardComponent, /候选规则/)
test('risk dashboard renders multi-dimension and ranking panels', () => {
assert.match(dashboardComponent, /risk-dimension-grid/)
assert.match(dashboardComponent, /risk-composition-panel/)
assert.match(dashboardComponent, /risk-ranking-visual/)
assert.match(dashboardComponent, /risk-ranking-chart-block/)
assert.match(dashboardComponent, /\.risk-ranking-panel\s*\{\s*grid-column:\s*span 12;/)
assert.match(dashboardComponent, /rankingChartItems/)
assert.match(dashboardComponent, /rankingDetailGroups/)
assert.match(dashboardComponent, /departmentDistribution/)
assert.match(dashboardComponent, /expenseTypeDistribution/)
assert.match(dashboardComponent, /supplierDistribution/)
@@ -77,57 +75,56 @@ test('risk dashboard renders overview amount and multi-dimension panels', () =>
assert.match(dashboardComponent, /topEmployees/)
assert.match(dashboardComponent, /topSuppliers/)
assert.match(dashboardComponent, /topRules/)
assert.doesNotMatch(dashboardComponent, /risk-effect-panel/)
assert.doesNotMatch(dashboardComponent, /risk-recent-panel/)
})
test('risk dashboard localizes backend metric keys before rendering', () => {
assert.equal(formatRiskSignalLabel('duplicate_invoice'), '重复发票')
assert.equal(formatRiskSignalLabel('policy.duplicate_invoice'), '重复发票')
assert.equal(formatExpenseTypeLabel('travel'), '差旅费')
assert.equal(formatRiskSourceLabel('rule_center'), '规则中心')
assert.equal(formatRiskSourceLabel('financial_risk_graph'), '风险图谱')
assert.equal(formatRiskDimensionLabel('policy.duplicate_invoice', 'rule'), '重复发票规则')
assert.equal(
assert.notEqual(formatRiskSignalLabel('duplicate_invoice'), 'duplicate_invoice')
assert.notEqual(formatRiskSignalLabel('budget_pressure'), 'budget_pressure')
assert.notEqual(formatRiskSignalLabel('missing_material'), 'missing_material')
assert.notEqual(formatExpenseTypeLabel('travel'), 'travel')
assert.notEqual(formatRiskSourceLabel('rule_center'), 'rule_center')
assert.notEqual(formatRiskSourceLabel('financial_risk_graph'), 'financial_risk_graph')
assert.notEqual(formatRiskDimensionLabel('simulation', 'risk_type'), 'simulation')
assert.notEqual(formatRiskDimensionLabel('policy.duplicate_invoice', 'rule'), 'policy.duplicate_invoice')
assert.notEqual(
formatRiskObservationTitle({ title: 'policy.duplicate_invoice', riskSignal: 'duplicate_invoice' }),
'重复发票'
'policy.duplicate_invoice'
)
assert.match(riskLabels, /travel: '差旅费'/)
assert.match(riskLabels, /rule_center: '规则中心'/)
assert.match(riskLabels, /travel:/)
assert.match(riskLabels, /rule_center:/)
assert.match(overviewViewModel, /formatRiskSignalLabel/)
assert.match(overviewViewModel, /formatRiskSourceLabel/)
assert.match(dashboardComponent, /formatRiskObservationTitle/)
assert.match(overviewViewModel, /riskCompositionLegend/)
assert.match(overviewViewModel, /signalDistribution/)
assert.doesNotMatch(overviewViewModel, /formatRiskSourceLabel/)
assert.doesNotMatch(dashboardComponent, /formatRiskObservationTitle/)
assert.doesNotMatch(dashboardComponent, /text\.replace\(\s*\/_\/g/)
})
test('risk dashboard renders exception ranking as chart-led visual summary', () => {
assert.match(dashboardComponent, /rankingChartItems/)
assert.match(dashboardComponent, /rankingDetailGroups/)
assert.match(dashboardComponent, /risk-ranking-visual/)
assert.match(dashboardComponent, /risk-ranking-detail-grid/)
assert.match(dashboardComponent, /:items="rankingChartItems"/)
assert.match(dashboardComponent, /value-suffix="项"/)
assert.doesNotMatch(dashboardComponent, /risk-ranking-grid/)
})
test('risk dashboard wires window filter to trend, ranking, and cards data source', () => {
assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/)
assert.match(overviewViewModel, /windowDays: activeRiskWindowDays\.value/)
assert.match(overviewViewModel, /watch\(activeRiskWindowDays/)
assert.match(overviewViewModel, /setRiskWindowDays/)
assert.match(overviewTemplate, /:window-options="riskWindowOptions"/)
assert.match(overviewTemplate, /:active-window-days="activeRiskWindowDays"/)
assert.match(overviewTemplate, /@update:window-days="setRiskWindowDays"/)
assert.match(dashboardComponent, /EnterpriseSelect/)
assert.match(dashboardComponent, /aria-label="风险看板时间窗口"/)
assert.match(dashboardComponent, /emit\('update:windowDays', \$event\)/)
test('risk dashboard follows the top overview range without card-level selectors', () => {
assert.match(overviewViewModel, /const topRangeDays = computed/)
assert.match(overviewViewModel, /windowDays: topRangeDays\.value/)
assert.match(overviewViewModel, /options\.activeRange/)
assert.doesNotMatch(overviewViewModel, /activeRiskWindowDays/)
assert.doesNotMatch(overviewViewModel, /setRiskWindowDays/)
assert.doesNotMatch(overviewTemplate, /:window-options="riskWindowOptions"/)
assert.doesNotMatch(overviewTemplate, /:active-window-days="activeRiskWindowDays"/)
assert.doesNotMatch(overviewTemplate, /@update:window-days="setRiskWindowDays"/)
assert.doesNotMatch(dashboardComponent, /EnterpriseSelect/)
assert.doesNotMatch(dashboardComponent, /risk-window-select/)
assert.doesNotMatch(dashboardComponent, /emit\('update:windowDays', \$event\)/)
assert.match(dashboardComponent, /dashboard\.windowDays/)
assert.match(dashboardComponent, /RiskDailyTrendChart/)
assert.match(dashboardComponent, /rankingGroups/)
})
test('risk dashboard shows loading overlay and realtime refresh status', () => {
assert.match(overviewTemplate, /dashboard-loading-overlay/)
assert.match(overviewTemplate, /dashboard-loading-state/)
assert.match(overviewTemplate, /floating/)
assert.match(overviewTemplate, /TableLoadingState/)
assert.match(overviewTemplate, /activeDashboardLoadingText/)
assert.match(dashboardComponent, /risk-dashboard-loading-overlay/)
assert.match(dashboardComponent, /risk-dashboard-loading-state/)
assert.match(dashboardComponent, /floating/)
assert.match(dashboardComponent, /TableLoadingState/)
assert.match(dashboardComponent, /loadingLabel/)
assert.match(dashboardComponent, /lastUpdatedLabel/)