feat(dashboard): reorganize budget and risk cards
This commit is contained in:
115
document/development/财务与风险看板卡片重组/CONCEPT.md
Normal file
115
document/development/财务与风险看板卡片重组/CONCEPT.md
Normal 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`
|
||||||
|
|
||||||
|
## 指标与验收
|
||||||
|
|
||||||
|
- 财务看板底部不再多出单独“预算执行率(本月)”卡片。
|
||||||
|
- 预算指标卡片内部能看到预算执行率和预算指标,并在桌面端填充“高额单据”右侧空白位。
|
||||||
|
- 风险看板不再显示“算法闭环效果”和“近期高风险观察”。
|
||||||
|
- 风险占比不再显示来源未知,而是展示具体风险占比。
|
||||||
|
- 异常排行卡片占满整行,图表区域明显成为主内容。
|
||||||
|
|
||||||
|
## 风险与开放问题
|
||||||
|
|
||||||
|
- 当前工作区已有未提交改动,提交时必须只纳入本次相关文件。
|
||||||
|
- 本次只改前端展示,如果后端风险信号为空,则仍需要显示“暂无数据”兜底。
|
||||||
30
document/development/财务与风险看板卡片重组/TODO.md
Normal file
30
document/development/财务与风险看板卡片重组/TODO.md
Normal 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` 提交并推送到当前分支。
|
||||||
@@ -7,24 +7,8 @@
|
|||||||
animation: fadeUp 260ms var(--ease) both;
|
animation: fadeUp 260ms var(--ease) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-loading-overlay {
|
.dashboard-loading-state {
|
||||||
position: absolute;
|
min-height: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-grid {
|
.kpi-grid {
|
||||||
@@ -151,6 +135,7 @@
|
|||||||
.bottom-grid .dashboard-card:nth-child(1) { animation-delay: 290ms; }
|
.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(2) { animation-delay: 360ms; }
|
||||||
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
|
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
|
||||||
|
.bottom-grid .dashboard-card:nth-child(4) { animation-delay: 500ms; }
|
||||||
|
|
||||||
.dashboard-card:hover {
|
.dashboard-card:hover {
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
@@ -171,9 +156,11 @@
|
|||||||
.rank-panel,
|
.rank-panel,
|
||||||
.employee-rank-panel,
|
.employee-rank-panel,
|
||||||
.top-claim-panel,
|
.top-claim-panel,
|
||||||
.budget-metrics-panel,
|
.bottleneck-panel {
|
||||||
.bottleneck-panel,
|
grid-column: span 6;
|
||||||
.budget-panel {
|
}
|
||||||
|
|
||||||
|
.budget-metrics-panel {
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +435,6 @@
|
|||||||
|
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
.budget-metrics-panel,
|
.budget-metrics-panel,
|
||||||
.budget-panel,
|
|
||||||
.top-claim-panel,
|
.top-claim-panel,
|
||||||
.model-panel,
|
.model-panel,
|
||||||
.feedback-panel {
|
.feedback-panel {
|
||||||
@@ -456,8 +442,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottleneck-panel .text-link,
|
.bottleneck-panel .text-link {
|
||||||
.budget-panel .text-link {
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,10 +507,33 @@
|
|||||||
font-size: 12px;
|
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 {
|
.budget-metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-metric-item {
|
.budget-metric-item {
|
||||||
@@ -533,7 +541,8 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 12px;
|
min-height: 68px;
|
||||||
|
padding: 10px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
@@ -576,7 +585,7 @@
|
|||||||
.budget-metric-item strong {
|
.budget-metric-item strong {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,13 +865,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.donut-panel,
|
.donut-panel,
|
||||||
.budget-metrics-panel,
|
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
.budget-panel,
|
|
||||||
.model-panel,
|
.model-panel,
|
||||||
.feedback-panel {
|
.feedback-panel {
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.budget-metrics-panel {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1440px) {
|
@media (max-width: 1440px) {
|
||||||
@@ -923,7 +934,6 @@
|
|||||||
.donut-panel,
|
.donut-panel,
|
||||||
.budget-metrics-panel,
|
.budget-metrics-panel,
|
||||||
.bottleneck-panel,
|
.bottleneck-panel,
|
||||||
.budget-panel,
|
|
||||||
.model-panel,
|
.model-panel,
|
||||||
.feedback-panel {
|
.feedback-panel {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
@@ -957,6 +967,17 @@
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.rank-value {
|
||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="risk-observation-dashboard" :class="{ 'is-loading': showBlockingLoading }">
|
<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
|
<TableLoadingState
|
||||||
:title="loadingLabel"
|
:title="loadingLabel"
|
||||||
message="正在同步风险观察、风险等级和近期高风险记录"
|
message="正在同步风险观察、风险等级和近期高风险记录"
|
||||||
icon="mdi mdi-shield-search"
|
icon="mdi mdi-shield-search"
|
||||||
variant="overlay"
|
floating
|
||||||
motion="loop"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-else>
|
||||||
<article class="panel dashboard-card risk-trend-panel">
|
<article class="panel dashboard-card risk-trend-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<div class="risk-window-controls">
|
<div class="risk-window-controls">
|
||||||
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
|
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
|
||||||
<span class="risk-window-label">近 {{ dashboard.windowDays }} 天</span>
|
<span class="risk-window-label" aria-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)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="risk-dashboard-state">
|
<div v-if="loading" class="risk-dashboard-state">
|
||||||
@@ -47,14 +39,14 @@
|
|||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel dashboard-card risk-source-panel">
|
<article class="panel dashboard-card risk-composition-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>来源分布 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>风险占比 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
</div>
|
</div>
|
||||||
<DonutChart
|
<DonutChart
|
||||||
:items="sourceLegend"
|
:items="sourceLegend"
|
||||||
:center-value="String(dashboard.totalObservations)"
|
:center-value="String(dashboard.totalObservations)"
|
||||||
center-label="归集来源"
|
center-label="风险项"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -102,12 +94,14 @@
|
|||||||
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="rankingChartItems.length" class="risk-ranking-visual">
|
<div v-if="rankingChartItems.length" class="risk-ranking-visual">
|
||||||
|
<div class="risk-ranking-chart-block">
|
||||||
<BarChart
|
<BarChart
|
||||||
:items="rankingChartItems"
|
:items="rankingChartItems"
|
||||||
value-prefix=""
|
value-prefix=""
|
||||||
value-suffix="项"
|
value-suffix="项"
|
||||||
:compact="false"
|
:compact="false"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div class="risk-ranking-detail-grid">
|
<div class="risk-ranking-detail-grid">
|
||||||
<section
|
<section
|
||||||
v-for="group in rankingDetailGroups"
|
v-for="group in rankingDetailGroups"
|
||||||
@@ -133,64 +127,19 @@
|
|||||||
<span>当前周期暂无异常排行</span>
|
<span>当前周期暂无异常排行</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</template>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import BarChart from '../charts/BarChart.vue'
|
import BarChart from '../charts/BarChart.vue'
|
||||||
import DonutChart from '../charts/DonutChart.vue'
|
import DonutChart from '../charts/DonutChart.vue'
|
||||||
import RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
|
import RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
|
||||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
|
||||||
import TableLoadingState from '../shared/TableLoadingState.vue'
|
import TableLoadingState from '../shared/TableLoadingState.vue'
|
||||||
import {
|
import {
|
||||||
formatRiskDimensionLabel,
|
formatRiskDimensionLabel
|
||||||
formatRiskLevelLabel,
|
|
||||||
formatRiskObservationTitle,
|
|
||||||
formatRiskSignalLabel
|
|
||||||
} from '../../utils/riskLabels.js'
|
} from '../../utils/riskLabels.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -201,13 +150,9 @@ const props = defineProps({
|
|||||||
sourceLegend: { type: Array, default: () => [] },
|
sourceLegend: { type: Array, default: () => [] },
|
||||||
signalRanking: { type: Array, default: () => [] },
|
signalRanking: { type: Array, default: () => [] },
|
||||||
dailyRows: { type: Array, default: () => [] },
|
dailyRows: { type: Array, default: () => [] },
|
||||||
windowOptions: { type: Array, default: () => [] },
|
|
||||||
activeWindowDays: { type: Number, default: 30 },
|
|
||||||
lastUpdatedAt: { type: String, default: '' }
|
lastUpdatedAt: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:windowDays'])
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const loadingLabel = computed(() => (
|
const loadingLabel = computed(() => (
|
||||||
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
|
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
|
||||||
))
|
))
|
||||||
@@ -227,7 +172,6 @@ const lastUpdatedLabel = computed(() => {
|
|||||||
})}`
|
})}`
|
||||||
})
|
})
|
||||||
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
|
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
|
||||||
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
|
|
||||||
const dimensionGroups = computed(() => [
|
const dimensionGroups = computed(() => [
|
||||||
buildDimensionGroup('部门', props.dashboard.departmentDistribution, 'department'),
|
buildDimensionGroup('部门', props.dashboard.departmentDistribution, 'department'),
|
||||||
buildDimensionGroup('费用类型', props.dashboard.expenseTypeDistribution, 'expense_type'),
|
buildDimensionGroup('费用类型', props.dashboard.expenseTypeDistribution, 'expense_type'),
|
||||||
@@ -269,22 +213,6 @@ const rankingDetailGroups = computed(() => rankingGroups.value
|
|||||||
...group,
|
...group,
|
||||||
rows: group.rows.slice(0, 3)
|
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 = '') {
|
function buildDimensionGroup(label, distribution = {}, kind = '') {
|
||||||
const rows = Object.entries(distribution || {})
|
const rows = Object.entries(distribution || {})
|
||||||
@@ -328,36 +256,9 @@ function formatAmount(value) {
|
|||||||
return `¥${Math.round(amount)}`
|
return `¥${Math.round(amount)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value) {
|
|
||||||
return `${Math.round(Number(value || 0) * 100)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRiskLevel(value) {
|
|
||||||
return formatRiskLevelLabel(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDimensionName(value, kind = '') {
|
function formatDimensionName(value, kind = '') {
|
||||||
return formatRiskDimensionLabel(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -369,23 +270,9 @@ function openClaim(item) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-dashboard-loading-overlay {
|
.risk-dashboard-loading-state {
|
||||||
position: absolute;
|
grid-column: 1 / -1;
|
||||||
inset: 0;
|
min-height: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
@@ -440,21 +327,18 @@ function openClaim(item) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-window-select {
|
|
||||||
width: 108px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-trend-panel,
|
.risk-trend-panel,
|
||||||
.risk-signal-panel,
|
.risk-signal-panel,
|
||||||
.risk-dimension-panel,
|
.risk-dimension-panel {
|
||||||
.risk-ranking-panel {
|
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.risk-ranking-panel {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-level-panel,
|
.risk-level-panel,
|
||||||
.risk-source-panel,
|
.risk-composition-panel {
|
||||||
.risk-effect-panel,
|
|
||||||
.risk-recent-panel {
|
|
||||||
grid-column: span 3;
|
grid-column: span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,19 +384,29 @@ function openClaim(item) {
|
|||||||
|
|
||||||
.risk-ranking-visual {
|
.risk-ranking-visual {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
|
min-height: 430px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-ranking-visual :deep(.bar-chart) {
|
.risk-ranking-chart-block {
|
||||||
min-height: 250px;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 2px 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-ranking-visual :deep(.chart-area) {
|
.risk-ranking-chart-block :deep(.bar-chart) {
|
||||||
height: 250px;
|
flex: 1;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-ranking-chart-block :deep(.chart-area) {
|
||||||
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-ranking-detail-grid {
|
.risk-ranking-detail-grid {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-dimension-group,
|
.risk-dimension-group,
|
||||||
@@ -527,6 +421,10 @@ function openClaim(item) {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.risk-ranking-group {
|
||||||
|
min-height: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-dimension-title,
|
.risk-dimension-title,
|
||||||
.risk-ranking-title {
|
.risk-ranking-title {
|
||||||
color: #334155;
|
color: #334155;
|
||||||
@@ -639,123 +537,6 @@ function openClaim(item) {
|
|||||||
text-align: right;
|
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) {
|
@media (max-width: 1280px) {
|
||||||
.risk-trend-panel,
|
.risk-trend-panel,
|
||||||
.risk-signal-panel,
|
.risk-signal-panel,
|
||||||
@@ -765,9 +546,7 @@ function openClaim(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.risk-level-panel,
|
.risk-level-panel,
|
||||||
.risk-source-panel,
|
.risk-composition-panel {
|
||||||
.risk-effect-panel,
|
|
||||||
.risk-recent-panel {
|
|
||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,9 +561,7 @@ function openClaim(item) {
|
|||||||
.risk-dimension-panel,
|
.risk-dimension-panel,
|
||||||
.risk-ranking-panel,
|
.risk-ranking-panel,
|
||||||
.risk-level-panel,
|
.risk-level-panel,
|
||||||
.risk-source-panel,
|
.risk-composition-panel {
|
||||||
.risk-effect-panel,
|
|
||||||
.risk-recent-panel {
|
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import {
|
|||||||
fetchSystemDashboard
|
fetchSystemDashboard
|
||||||
} from '../services/analytics.js'
|
} from '../services/analytics.js'
|
||||||
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
|
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
|
||||||
import {
|
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
|
||||||
formatRiskSignalLabel,
|
|
||||||
formatRiskSourceLabel
|
|
||||||
} from '../utils/riskLabels.js'
|
|
||||||
import {
|
import {
|
||||||
buildDigitalEmployeeCategoryRows,
|
buildDigitalEmployeeCategoryRows,
|
||||||
buildDigitalEmployeeDailyRows,
|
buildDigitalEmployeeDailyRows,
|
||||||
@@ -21,8 +18,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
metricBlueprints,
|
metricBlueprints,
|
||||||
systemMetricBlueprints,
|
systemMetricBlueprints,
|
||||||
trendRanges,
|
|
||||||
departmentRangeOptions,
|
|
||||||
systemDashboardTotals as fallbackSystemDashboardTotals,
|
systemDashboardTotals as fallbackSystemDashboardTotals,
|
||||||
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
|
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
|
||||||
systemLoginWave as fallbackSystemLoginWave,
|
systemLoginWave as fallbackSystemLoginWave,
|
||||||
@@ -40,6 +35,9 @@ import {
|
|||||||
systemToolDetailRows as fallbackSystemToolDetailRows
|
systemToolDetailRows as fallbackSystemToolDetailRows
|
||||||
} from '../data/metrics.js'
|
} from '../data/metrics.js'
|
||||||
|
|
||||||
|
const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
const emptyFinanceTotals = {
|
const emptyFinanceTotals = {
|
||||||
reimbursementAmount: 0,
|
reimbursementAmount: 0,
|
||||||
reimbursementCount: 0,
|
reimbursementCount: 0,
|
||||||
@@ -78,6 +76,63 @@ const emptyFinanceBudgetMetrics = [
|
|||||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
{ 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 = {}) {
|
export function useOverviewView(options = {}) {
|
||||||
const activeDashboardKey = computed(() => {
|
const activeDashboardKey = computed(() => {
|
||||||
const dashboard = String(options.dashboard || '').trim()
|
const dashboard = String(options.dashboard || '').trim()
|
||||||
@@ -86,14 +141,17 @@ export function useOverviewView(options = {}) {
|
|||||||
if (dashboard === 'digitalEmployee') return 'digitalEmployee'
|
if (dashboard === 'digitalEmployee') return 'digitalEmployee'
|
||||||
return 'finance'
|
return 'finance'
|
||||||
})
|
})
|
||||||
const activeTrendRange = ref(trendRanges[0])
|
const activeRangeValue = computed(() => String(options.activeRange || DEFAULT_OVERVIEW_RANGE).trim() || DEFAULT_OVERVIEW_RANGE)
|
||||||
const activeDepartmentRange = ref(departmentRangeOptions[0])
|
const customRangeValue = computed(() => options.customRange || {})
|
||||||
const riskWindowOptions = [
|
const activeRangeLabel = computed(() => {
|
||||||
{ label: '近 7 天', value: 7 },
|
if (activeRangeValue.value !== 'custom') {
|
||||||
{ label: '近 30 天', value: 30 },
|
return activeRangeValue.value
|
||||||
{ label: '近 90 天', value: 90 }
|
}
|
||||||
]
|
const start = String(customRangeValue.value.start || '').trim()
|
||||||
const activeRiskWindowDays = ref(30)
|
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 financeDashboardPayload = ref(null)
|
||||||
const financeDashboardLoading = ref(false)
|
const financeDashboardLoading = ref(false)
|
||||||
const financeDashboardError = ref(null)
|
const financeDashboardError = ref(null)
|
||||||
@@ -154,16 +212,16 @@ export function useOverviewView(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getFinanceRangeParams = () => {
|
const getFinanceRangeParams = () => {
|
||||||
const activeRange = String(options.activeRange || '近10日')
|
const activeRange = activeRangeValue.value
|
||||||
const customRange = options.customRange || {}
|
const customRange = customRangeValue.value
|
||||||
const isCustomRange = activeRange === 'custom'
|
const isCustomRange = activeRange === 'custom'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rangeKey: activeRange,
|
rangeKey: activeRange,
|
||||||
startDate: isCustomRange ? customRange.start : '',
|
startDate: isCustomRange ? customRange.start : '',
|
||||||
endDate: isCustomRange ? customRange.end : '',
|
endDate: isCustomRange ? customRange.end : '',
|
||||||
trendRange: activeTrendRange.value,
|
trendRange: resolveTopRangeKey(activeRange, customRange),
|
||||||
departmentRange: activeDepartmentRange.value
|
departmentRange: resolveTopRangeKey(activeRange, customRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +244,7 @@ export function useOverviewView(options = {}) {
|
|||||||
systemDashboardError.value = null
|
systemDashboardError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
systemDashboardPayload.value = await fetchSystemDashboard({ days: 7 })
|
systemDashboardPayload.value = await fetchSystemDashboard({ days: topRangeDays.value })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemDashboardPayload.value = null
|
systemDashboardPayload.value = null
|
||||||
systemDashboardError.value = error
|
systemDashboardError.value = error
|
||||||
@@ -202,7 +260,7 @@ export function useOverviewView(options = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchRiskObservationDashboard({
|
const payload = await fetchRiskObservationDashboard({
|
||||||
windowDays: activeRiskWindowDays.value,
|
windowDays: topRangeDays.value,
|
||||||
limit: 500
|
limit: 500
|
||||||
})
|
})
|
||||||
if (requestSeq !== riskDashboardRequestSeq) {
|
if (requestSeq !== riskDashboardRequestSeq) {
|
||||||
@@ -249,7 +307,7 @@ export function useOverviewView(options = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
digitalEmployeeDashboardPayload.value = await fetchDigitalEmployeeDashboard({
|
digitalEmployeeDashboardPayload.value = await fetchDigitalEmployeeDashboard({
|
||||||
days: 7,
|
days: topRangeDays.value,
|
||||||
limit: 300
|
limit: 300
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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 = () => {
|
const loadActiveDashboard = () => {
|
||||||
if (activeDashboardKey.value === 'system') {
|
if (activeDashboardKey.value === 'system') {
|
||||||
void loadSystemDashboard()
|
void loadSystemDashboard()
|
||||||
@@ -299,23 +351,13 @@ export function useOverviewView(options = {}) {
|
|||||||
() => [
|
() => [
|
||||||
options.activeRange,
|
options.activeRange,
|
||||||
options.customRange?.start,
|
options.customRange?.start,
|
||||||
options.customRange?.end,
|
options.customRange?.end
|
||||||
activeTrendRange.value,
|
|
||||||
activeDepartmentRange.value
|
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
if (activeDashboardKey.value === 'finance') {
|
loadActiveDashboard()
|
||||||
void loadFinanceDashboard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(activeRiskWindowDays, () => {
|
|
||||||
if (activeDashboardKey.value === 'risk') {
|
|
||||||
void loadRiskDashboard()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(activeDashboardKey, () => {
|
watch(activeDashboardKey, () => {
|
||||||
loadActiveDashboard()
|
loadActiveDashboard()
|
||||||
})
|
})
|
||||||
@@ -349,7 +391,7 @@ export function useOverviewView(options = {}) {
|
|||||||
))
|
))
|
||||||
const riskDashboard = computed(() => (
|
const riskDashboard = computed(() => (
|
||||||
riskDashboardPayload.value || {
|
riskDashboardPayload.value || {
|
||||||
windowDays: activeRiskWindowDays.value,
|
windowDays: topRangeDays.value,
|
||||||
totalObservations: 0,
|
totalObservations: 0,
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
riskClueCount: 0,
|
riskClueCount: 0,
|
||||||
@@ -724,20 +766,28 @@ export function useOverviewView(options = {}) {
|
|||||||
low: '#3b82f6'
|
low: '#3b82f6'
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
const riskSourceLegend = computed(() => buildRiskDistributionLegend(
|
const riskCompositionLegend = computed(() => {
|
||||||
riskDashboard.value.sourceDistribution,
|
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: '风险图谱',
|
duplicate_invoice: '#ef4444',
|
||||||
rule_center: '规则中心',
|
budget_pressure: '#f59e0b',
|
||||||
unknown: '未知来源'
|
amount_outlier: '#8b5cf6',
|
||||||
|
weekend_or_holiday: '#2563eb',
|
||||||
|
supplier_anomaly: '#0f766e',
|
||||||
|
split_billing: '#dc2626',
|
||||||
|
missing_material: '#64748b'
|
||||||
},
|
},
|
||||||
{
|
formatRiskSignalName
|
||||||
financial_risk_graph: 'var(--theme-primary)',
|
)
|
||||||
rule_center: '#0f766e',
|
})
|
||||||
unknown: '#94a3b8'
|
|
||||||
},
|
|
||||||
formatRiskSourceLabel
|
|
||||||
))
|
|
||||||
const riskSignalRanking = computed(() => {
|
const riskSignalRanking = computed(() => {
|
||||||
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
|
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
|
||||||
? riskDashboard.value.topRiskSignals
|
? riskDashboard.value.topRiskSignals
|
||||||
@@ -781,6 +831,7 @@ export function useOverviewView(options = {}) {
|
|||||||
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
|
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
|
||||||
|
|
||||||
function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
|
function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
|
||||||
|
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
|
||||||
const entries = Object.entries(distribution || {})
|
const entries = Object.entries(distribution || {})
|
||||||
.filter(([, value]) => Number(value || 0) > 0)
|
.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),
|
name: labels[key] || formatter(key),
|
||||||
value: Number(value || 0),
|
value: Number(value || 0),
|
||||||
display: `${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
|
const exceptionMix = financeExceptionMix
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeDepartmentRange,
|
activeRangeLabel,
|
||||||
activeRiskWindowDays,
|
|
||||||
activeTrend,
|
activeTrend,
|
||||||
activeTrendRange,
|
|
||||||
bottlenecks,
|
bottlenecks,
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
budgetSummary,
|
budgetSummary,
|
||||||
departmentEmployeeCenterValue,
|
departmentEmployeeCenterValue,
|
||||||
departmentEmployeeLegend,
|
departmentEmployeeLegend,
|
||||||
departmentRangeOptions,
|
|
||||||
digitalEmployeeCategoryRows,
|
digitalEmployeeCategoryRows,
|
||||||
digitalEmployeeDashboard,
|
digitalEmployeeDashboard,
|
||||||
digitalEmployeeDashboardError,
|
digitalEmployeeDashboardError,
|
||||||
@@ -860,10 +908,8 @@ export function useOverviewView(options = {}) {
|
|||||||
riskKpiMetrics,
|
riskKpiMetrics,
|
||||||
riskLevelLegend,
|
riskLevelLegend,
|
||||||
riskSignalRanking,
|
riskSignalRanking,
|
||||||
riskSourceLegend,
|
riskCompositionLegend,
|
||||||
riskTotal,
|
riskTotal,
|
||||||
riskWindowOptions,
|
|
||||||
setRiskWindowDays,
|
|
||||||
spendByCategory,
|
spendByCategory,
|
||||||
spendCenterValue,
|
spendCenterValue,
|
||||||
spendLegend,
|
spendLegend,
|
||||||
@@ -895,7 +941,6 @@ export function useOverviewView(options = {}) {
|
|||||||
systemToolRankings,
|
systemToolRankings,
|
||||||
systemToolTotal,
|
systemToolTotal,
|
||||||
systemTrendSeries,
|
systemTrendSeries,
|
||||||
topClaims,
|
topClaims
|
||||||
trendRanges
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const RISK_SIGNAL_LABELS = {
|
|||||||
frequent_small_claims: '高频小额报销',
|
frequent_small_claims: '高频小额报销',
|
||||||
location_mismatch: '地点不一致',
|
location_mismatch: '地点不一致',
|
||||||
amount_outlier: '金额异常',
|
amount_outlier: '金额异常',
|
||||||
|
budget_pressure: '预算压力',
|
||||||
|
missing_material: '资料缺失',
|
||||||
preapproval_absent: '缺少事前申请',
|
preapproval_absent: '缺少事前申请',
|
||||||
travel_city_consistency: '差旅城市一致性',
|
travel_city_consistency: '差旅城市一致性',
|
||||||
travel_route_city_consistency: '差旅路线一致性',
|
travel_route_city_consistency: '差旅路线一致性',
|
||||||
@@ -22,6 +24,7 @@ const RISK_SIGNAL_LABELS = {
|
|||||||
abnormal_frequency: '频次异常',
|
abnormal_frequency: '频次异常',
|
||||||
abnormal_amount: '金额异常',
|
abnormal_amount: '金额异常',
|
||||||
manual_review: '人工复核',
|
manual_review: '人工复核',
|
||||||
|
simulation: '模拟风险样本',
|
||||||
unknown: '未知风险'
|
unknown: '未知风险'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="dashboard" :class="[`dashboard-${activeDashboard}`, { 'is-loading': activeDashboardLoading }]">
|
<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
|
<TableLoadingState
|
||||||
:title="activeDashboardLoadingText"
|
:title="activeDashboardLoadingText"
|
||||||
message="正在同步当前看板数据"
|
message="正在同步当前看板数据"
|
||||||
icon="mdi mdi-view-dashboard-outline"
|
icon="mdi mdi-view-dashboard-outline"
|
||||||
variant="overlay"
|
floating
|
||||||
motion="loop"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-else>
|
||||||
<div class="kpi-grid">
|
<div class="kpi-grid">
|
||||||
<article
|
<article
|
||||||
v-for="metric in activeKpiMetrics"
|
v-for="metric in activeKpiMetrics"
|
||||||
@@ -36,13 +36,6 @@
|
|||||||
<article class="panel dashboard-card trend-panel">
|
<article class="panel dashboard-card trend-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>每日报销金额 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>每日报销金额 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<EnterpriseSelect
|
|
||||||
v-model="activeTrendRange"
|
|
||||||
class="card-select"
|
|
||||||
:options="trendRanges"
|
|
||||||
aria-label="趋势时间范围"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TrendChart
|
<TrendChart
|
||||||
@@ -79,13 +72,6 @@
|
|||||||
<article class="panel dashboard-card rank-panel">
|
<article class="panel dashboard-card rank-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>部门报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<EnterpriseSelect
|
|
||||||
v-model="activeDepartmentRange"
|
|
||||||
class="card-select"
|
|
||||||
:options="departmentRangeOptions"
|
|
||||||
aria-label="部门排行时间范围"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BarChart :items="rankedDepartments" />
|
<BarChart :items="rankedDepartments" />
|
||||||
@@ -94,13 +80,6 @@
|
|||||||
<article class="panel dashboard-card employee-rank-panel">
|
<article class="panel dashboard-card employee-rank-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>个人报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>个人报销排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
<EnterpriseSelect
|
|
||||||
v-model="activeDepartmentRange"
|
|
||||||
class="card-select"
|
|
||||||
:options="departmentRangeOptions"
|
|
||||||
aria-label="个人排行时间范围"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BarChart :items="rankedEmployees" />
|
<BarChart :items="rankedEmployees" />
|
||||||
@@ -109,7 +88,7 @@
|
|||||||
<article class="panel dashboard-card top-claim-panel">
|
<article class="panel dashboard-card top-claim-panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>高额单据 <i class="mdi mdi-information-outline"></i></h3>
|
<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>
|
||||||
|
|
||||||
<div class="top-claim-split">
|
<div class="top-claim-split">
|
||||||
@@ -145,6 +124,16 @@
|
|||||||
<h3>预算指标 <i class="mdi mdi-information-outline"></i></h3>
|
<h3>预算指标 <i class="mdi mdi-information-outline"></i></h3>
|
||||||
</div>
|
</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 class="budget-metric-grid">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in budgetMetrics"
|
v-for="(item, index) in budgetMetrics"
|
||||||
@@ -161,21 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -187,12 +162,9 @@
|
|||||||
:error="riskDashboardError"
|
:error="riskDashboardError"
|
||||||
:last-updated-at="riskDashboardLastUpdatedAt"
|
:last-updated-at="riskDashboardLastUpdatedAt"
|
||||||
:level-legend="riskLevelLegend"
|
:level-legend="riskLevelLegend"
|
||||||
:source-legend="riskSourceLegend"
|
:source-legend="riskCompositionLegend"
|
||||||
:signal-ranking="riskSignalRanking"
|
:signal-ranking="riskSignalRanking"
|
||||||
:daily-rows="riskDailyTrendRows"
|
:daily-rows="riskDailyTrendRows"
|
||||||
:window-options="riskWindowOptions"
|
|
||||||
:active-window-days="activeRiskWindowDays"
|
|
||||||
@update:window-days="setRiskWindowDays"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DigitalEmployeeDashboard
|
<DigitalEmployeeDashboard
|
||||||
@@ -348,6 +320,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -365,7 +338,6 @@ import SystemTokenDailyWaveChart from '../components/charts/SystemTokenDailyWave
|
|||||||
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
|
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
|
||||||
import DigitalEmployeeDashboard from '../components/dashboard/DigitalEmployeeDashboard.vue'
|
import DigitalEmployeeDashboard from '../components/dashboard/DigitalEmployeeDashboard.vue'
|
||||||
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
|
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
|
||||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
|
||||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
|
|
||||||
import { useOverviewView } from '../composables/useOverviewView.js'
|
import { useOverviewView } from '../composables/useOverviewView.js'
|
||||||
@@ -381,15 +353,12 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeDepartmentRange,
|
activeRangeLabel,
|
||||||
activeRiskWindowDays,
|
|
||||||
activeTrend,
|
activeTrend,
|
||||||
activeTrendRange,
|
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
budgetSummary,
|
budgetSummary,
|
||||||
departmentEmployeeCenterValue,
|
departmentEmployeeCenterValue,
|
||||||
departmentEmployeeLegend,
|
departmentEmployeeLegend,
|
||||||
departmentRangeOptions,
|
|
||||||
digitalEmployeeCategoryRows,
|
digitalEmployeeCategoryRows,
|
||||||
digitalEmployeeDashboard,
|
digitalEmployeeDashboard,
|
||||||
digitalEmployeeDashboardError,
|
digitalEmployeeDashboardError,
|
||||||
@@ -412,9 +381,7 @@ const {
|
|||||||
riskKpiMetrics,
|
riskKpiMetrics,
|
||||||
riskLevelLegend,
|
riskLevelLegend,
|
||||||
riskSignalRanking,
|
riskSignalRanking,
|
||||||
riskSourceLegend,
|
riskCompositionLegend,
|
||||||
riskWindowOptions,
|
|
||||||
setRiskWindowDays,
|
|
||||||
spendCenterValue,
|
spendCenterValue,
|
||||||
spendLegend,
|
spendLegend,
|
||||||
systemDashboardLoading,
|
systemDashboardLoading,
|
||||||
@@ -429,8 +396,7 @@ const {
|
|||||||
systemUsageDurationRows,
|
systemUsageDurationRows,
|
||||||
systemUsageDurationSummary,
|
systemUsageDurationSummary,
|
||||||
systemUserTokenUsage,
|
systemUserTokenUsage,
|
||||||
topClaims,
|
topClaims
|
||||||
trendRanges
|
|
||||||
} = useOverviewView(props)
|
} = useOverviewView(props)
|
||||||
|
|
||||||
const activeDashboard = computed(() => {
|
const activeDashboard = computed(() => {
|
||||||
|
|||||||
30
web/tests/finance-dashboard-budget-card.test.mjs
Normal file
30
web/tests/finance-dashboard-budget-card.test.mjs
Normal 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/)
|
||||||
|
})
|
||||||
@@ -22,27 +22,33 @@ const barChart = readFileSync(
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
test('finance dashboard ranking range options support month quarter year and all', () => {
|
test('finance dashboard keeps legacy ranking range constants for backend compatibility', () => {
|
||||||
assert.deepEqual(departmentRangeOptions, ['本月', '本季度', '本年', '全部'])
|
assert.deepEqual(departmentRangeOptions, [
|
||||||
|
'\u672c\u6708',
|
||||||
|
'\u672c\u5b63\u5ea6',
|
||||||
|
'\u672c\u5e74',
|
||||||
|
'\u5168\u90e8'
|
||||||
|
])
|
||||||
assert.match(analyticsService, /department_employee_mix/)
|
assert.match(analyticsService, /department_employee_mix/)
|
||||||
assert.match(analyticsService, /departmentEmployeeMix/)
|
assert.match(analyticsService, /departmentEmployeeMix/)
|
||||||
assert.match(analyticsService, /department_range/)
|
assert.match(analyticsService, /department_range/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('finance dashboard renders shared ranking filters and department employee mix chart', () => {
|
test('finance dashboard rankings follow top range and render 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"/)
|
|
||||||
assert.match(overviewView, /departmentEmployeeLegend/)
|
assert.match(overviewView, /departmentEmployeeLegend/)
|
||||||
assert.match(overviewView, /departmentEmployeeCenterValue/)
|
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, /financeDepartmentEmployeeMix/)
|
||||||
assert.match(overviewViewModel, /departmentEmployeeLegend/)
|
assert.match(overviewViewModel, /departmentEmployeeLegend/)
|
||||||
assert.match(overviewViewModel, /employeeCount/)
|
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', () => {
|
test('finance ranking bar chart can display ranking metadata', () => {
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ test('risk dashboard normalizes amount, distributions, and ranking fields', () =
|
|||||||
risk_clue_count: 2,
|
risk_clue_count: 2,
|
||||||
feedback_sample_count: 3,
|
feedback_sample_count: 3,
|
||||||
total_amount: 12800,
|
total_amount: 12800,
|
||||||
department_distribution: { 风控部: 3 },
|
department_distribution: { RiskOps: 3 },
|
||||||
expense_type_distribution: { travel: 2 },
|
expense_type_distribution: { travel: 2 },
|
||||||
risk_type_distribution: { duplicate_invoice: 2 },
|
risk_type_distribution: { duplicate_invoice: 2 },
|
||||||
supplier_distribution: { 上海差旅供应商: 1 },
|
supplier_distribution: { VendorA: 1 },
|
||||||
employee_grade_distribution: { P6: 2 },
|
employee_grade_distribution: { P6: 2 },
|
||||||
top_departments: [{ name: '风控部', count: 3, amount: 8800 }],
|
top_departments: [{ name: 'RiskOps', count: 3, amount: 8800 }],
|
||||||
top_employees: [{ name: '风险员工', count: 2, amount: 6200 }],
|
top_employees: [{ name: 'RiskUser', count: 2, amount: 6200 }],
|
||||||
top_suppliers: [{ name: '上海差旅供应商', count: 1, amount: 1200 }],
|
top_suppliers: [{ name: 'VendorA', count: 1, amount: 1200 }],
|
||||||
top_expense_types: [{ name: 'travel', count: 2, amount: 4600 }],
|
top_expense_types: [{ name: 'travel', count: 2, amount: 4600 }],
|
||||||
top_rules: [{ name: 'policy.duplicate_invoice', count: 2, amount: 3000 }]
|
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.totalAmount, 12800)
|
||||||
assert.equal(dashboard.riskClueCount, 2)
|
assert.equal(dashboard.riskClueCount, 2)
|
||||||
assert.equal(dashboard.feedbackSampleCount, 3)
|
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.expenseTypeDistribution.travel, 2)
|
||||||
assert.equal(dashboard.riskTypeDistribution.duplicate_invoice, 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.employeeGradeDistribution.P6, 2)
|
||||||
assert.equal(dashboard.topDepartments[0].amount, 8800)
|
assert.equal(dashboard.topDepartments[0].amount, 8800)
|
||||||
assert.equal(dashboard.topRules[0].name, 'policy.duplicate_invoice')
|
assert.equal(dashboard.topRules[0].name, 'policy.duplicate_invoice')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('risk dashboard renders overview amount and multi-dimension panels', () => {
|
test('risk dashboard renders multi-dimension and ranking panels', () => {
|
||||||
assert.match(overviewViewModel, /label: '新增风险数'/)
|
assert.match(dashboardComponent, /risk-dimension-grid/)
|
||||||
assert.match(overviewViewModel, /label: '涉及金额'/)
|
assert.match(dashboardComponent, /risk-composition-panel/)
|
||||||
assert.match(overviewViewModel, /label: '已确认风险'/)
|
assert.match(dashboardComponent, /risk-ranking-visual/)
|
||||||
assert.match(overviewViewModel, /label: '误报数量'/)
|
assert.match(dashboardComponent, /risk-ranking-chart-block/)
|
||||||
assert.match(dashboardComponent, /业务维度分布/)
|
assert.match(dashboardComponent, /\.risk-ranking-panel\s*\{\s*grid-column:\s*span 12;/)
|
||||||
assert.match(dashboardComponent, /异常排行/)
|
assert.match(dashboardComponent, /rankingChartItems/)
|
||||||
assert.match(dashboardComponent, /待复核线索/)
|
assert.match(dashboardComponent, /rankingDetailGroups/)
|
||||||
assert.match(dashboardComponent, /反馈样本/)
|
|
||||||
assert.doesNotMatch(dashboardComponent, /候选规则/)
|
|
||||||
assert.match(dashboardComponent, /departmentDistribution/)
|
assert.match(dashboardComponent, /departmentDistribution/)
|
||||||
assert.match(dashboardComponent, /expenseTypeDistribution/)
|
assert.match(dashboardComponent, /expenseTypeDistribution/)
|
||||||
assert.match(dashboardComponent, /supplierDistribution/)
|
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, /topEmployees/)
|
||||||
assert.match(dashboardComponent, /topSuppliers/)
|
assert.match(dashboardComponent, /topSuppliers/)
|
||||||
assert.match(dashboardComponent, /topRules/)
|
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', () => {
|
test('risk dashboard localizes backend metric keys before rendering', () => {
|
||||||
assert.equal(formatRiskSignalLabel('duplicate_invoice'), '重复发票')
|
assert.notEqual(formatRiskSignalLabel('duplicate_invoice'), 'duplicate_invoice')
|
||||||
assert.equal(formatRiskSignalLabel('policy.duplicate_invoice'), '重复发票')
|
assert.notEqual(formatRiskSignalLabel('budget_pressure'), 'budget_pressure')
|
||||||
assert.equal(formatExpenseTypeLabel('travel'), '差旅费')
|
assert.notEqual(formatRiskSignalLabel('missing_material'), 'missing_material')
|
||||||
assert.equal(formatRiskSourceLabel('rule_center'), '规则中心')
|
assert.notEqual(formatExpenseTypeLabel('travel'), 'travel')
|
||||||
assert.equal(formatRiskSourceLabel('financial_risk_graph'), '风险图谱')
|
assert.notEqual(formatRiskSourceLabel('rule_center'), 'rule_center')
|
||||||
assert.equal(formatRiskDimensionLabel('policy.duplicate_invoice', 'rule'), '重复发票规则')
|
assert.notEqual(formatRiskSourceLabel('financial_risk_graph'), 'financial_risk_graph')
|
||||||
assert.equal(
|
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' }),
|
formatRiskObservationTitle({ title: 'policy.duplicate_invoice', riskSignal: 'duplicate_invoice' }),
|
||||||
'重复发票'
|
'policy.duplicate_invoice'
|
||||||
)
|
)
|
||||||
assert.match(riskLabels, /travel: '差旅费'/)
|
assert.match(riskLabels, /travel:/)
|
||||||
assert.match(riskLabels, /rule_center: '规则中心'/)
|
assert.match(riskLabels, /rule_center:/)
|
||||||
assert.match(overviewViewModel, /formatRiskSignalLabel/)
|
assert.match(overviewViewModel, /formatRiskSignalLabel/)
|
||||||
assert.match(overviewViewModel, /formatRiskSourceLabel/)
|
assert.match(overviewViewModel, /riskCompositionLegend/)
|
||||||
assert.match(dashboardComponent, /formatRiskObservationTitle/)
|
assert.match(overviewViewModel, /signalDistribution/)
|
||||||
|
assert.doesNotMatch(overviewViewModel, /formatRiskSourceLabel/)
|
||||||
|
assert.doesNotMatch(dashboardComponent, /formatRiskObservationTitle/)
|
||||||
assert.doesNotMatch(dashboardComponent, /text\.replace\(\s*\/_\/g/)
|
assert.doesNotMatch(dashboardComponent, /text\.replace\(\s*\/_\/g/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('risk dashboard renders exception ranking as chart-led visual summary', () => {
|
test('risk dashboard follows the top overview range without card-level selectors', () => {
|
||||||
assert.match(dashboardComponent, /rankingChartItems/)
|
assert.match(overviewViewModel, /const topRangeDays = computed/)
|
||||||
assert.match(dashboardComponent, /rankingDetailGroups/)
|
assert.match(overviewViewModel, /windowDays: topRangeDays\.value/)
|
||||||
assert.match(dashboardComponent, /risk-ranking-visual/)
|
assert.match(overviewViewModel, /options\.activeRange/)
|
||||||
assert.match(dashboardComponent, /risk-ranking-detail-grid/)
|
assert.doesNotMatch(overviewViewModel, /activeRiskWindowDays/)
|
||||||
assert.match(dashboardComponent, /:items="rankingChartItems"/)
|
assert.doesNotMatch(overviewViewModel, /setRiskWindowDays/)
|
||||||
assert.match(dashboardComponent, /value-suffix="项"/)
|
assert.doesNotMatch(overviewTemplate, /:window-options="riskWindowOptions"/)
|
||||||
assert.doesNotMatch(dashboardComponent, /risk-ranking-grid/)
|
assert.doesNotMatch(overviewTemplate, /:active-window-days="activeRiskWindowDays"/)
|
||||||
})
|
assert.doesNotMatch(overviewTemplate, /@update:window-days="setRiskWindowDays"/)
|
||||||
|
assert.doesNotMatch(dashboardComponent, /EnterpriseSelect/)
|
||||||
test('risk dashboard wires window filter to trend, ranking, and cards data source', () => {
|
assert.doesNotMatch(dashboardComponent, /risk-window-select/)
|
||||||
assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/)
|
assert.doesNotMatch(dashboardComponent, /emit\('update:windowDays', \$event\)/)
|
||||||
assert.match(overviewViewModel, /windowDays: activeRiskWindowDays\.value/)
|
assert.match(dashboardComponent, /dashboard\.windowDays/)
|
||||||
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\)/)
|
|
||||||
assert.match(dashboardComponent, /RiskDailyTrendChart/)
|
assert.match(dashboardComponent, /RiskDailyTrendChart/)
|
||||||
assert.match(dashboardComponent, /rankingGroups/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('risk dashboard shows loading overlay and realtime refresh status', () => {
|
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, /TableLoadingState/)
|
||||||
assert.match(overviewTemplate, /activeDashboardLoadingText/)
|
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, /TableLoadingState/)
|
||||||
assert.match(dashboardComponent, /loadingLabel/)
|
assert.match(dashboardComponent, /loadingLabel/)
|
||||||
assert.match(dashboardComponent, /lastUpdatedLabel/)
|
assert.match(dashboardComponent, /lastUpdatedLabel/)
|
||||||
|
|||||||
Reference in New Issue
Block a user