feat(dashboard): polish risk and digital employee boards

This commit is contained in:
caoxiaozhu
2026-06-03 09:41:32 +08:00
parent 15006a05a7
commit 0d6327a990
11 changed files with 716 additions and 100 deletions

View File

@@ -0,0 +1,95 @@
# 风险与数字员工看板视觉优化
## 功能一句话
修正分析看板中风险看板的英文指标展示,将异常排行改成图表化表达,并优化数字员工看板的卡片布局和图表填充。
## 背景与问题
当前分析看板已经接入风险观察和数字员工数据,但存在三个影响个人操作体验的问题:
- 风险看板仍会把 `duplicate_invoice``rule_center``unknown` 等后端 key 直接展示给用户。
- 异常排行以多列文字列表呈现,分类多、层级碎,难以快速判断哪个异常维度最突出。
- 数字员工看板部分卡片高度没有被内容充分利用,图表固定高度偏小,视觉上留下较多空白。
## 目标与非目标
目标:
- 风险看板可见指标全部中文化,常见风险信号、来源、状态、规则名和未知占位都不再直接显示英文 key。
- 异常排行聚合成一张图表化总览,保留部门、员工、供应商、规则和费用类型五个维度,并展示数量与金额。
- 数字员工看板减少无效空白,让趋势图、技能分布、模块排行和业务产出更充分占满卡片。
- 保持企业 SaaS 风格,继续复用现有 ECharts 封装组件和直角低饱和视觉体系。
非目标:
- 不新增接口,不改变后端数据契约。
- 不引入新的图表库。
- 不重做分析看板顶部导航、财务看板、系统看板和页面路由。
## 用户与场景
用户:
- 财务人员、风险复核人员、管理员。
场景:
- 用户进入风险看板,快速识别最近周期的风险来源、风险等级和主要异常维度。
- 用户查看异常排行时,优先通过图形长度和金额标签判断高发异常。
- 用户进入数字员工看板,查看后台任务趋势、技能类型、工作模块和产出,不需要在大面积空白里寻找信息。
## 功能能力
风险看板:
- 对风险信号 key、风险来源 key、状态 key、英文规则名和未知值做前端中文化。
- 异常排行从五列小列表改为组合图表:
- 每个维度取排名第一项作为主条形图。
- 展示维度名称、异常项名称、数量和金额。
- 保留各维度的次级排行,作为图表下方的紧凑明细。
数字员工看板:
- 主趋势卡片与每日摘要组成同一行,趋势图高度随卡片拉伸。
- 技能分布、工作模块排行和业务产出统一为等高卡片。
- 最近工作记录独占整行,减少右侧空白和表格压缩。
## 前端方案
- `RiskObservationDashboard.vue`
- 扩展 `formatSignal``formatDimensionName``formatRiskLevel` 等映射。
- 新增异常排行图表数据 `rankingChartItems`,复用 `BarChart` 展示五个维度的头部异常。
- 将原 `risk-ranking-grid` 改成图表 + 紧凑明细布局。
- `DigitalEmployeeDashboard.vue`
- 给卡片设置 flex 纵向结构,让图表区和列表区可拉伸。
- 调整栅格跨度:趋势 7、每日摘要 5技能分布、模块排行、业务产出各 4最近记录 12。
- 为图表容器增加可填充高度,减少固定高度导致的空白。
- `DigitalEmployeeDailyWorkChart.vue`
- 将固定高度改为跟随父容器的 `100%`,用最小高度保证可读性。
## 测试方案
- 前端源码测试:
- 风险看板不再暴露常见英文风险 key。
- 异常排行包含 `rankingChartItems` 并复用 `BarChart`
- 数字员工看板包含布局填充类名和可拉伸图表区域。
- 构建验证:
- `node web/tests/risk-observation-dashboard.test.mjs`
- `node web/tests/digital-employee-dashboard.test.mjs`
- `npm.cmd --prefix web run build`
## 验收标准
- 风险看板常见英文 key 在用户可见位置被中文文案替代。
- 异常排行以图表作为主视觉,不再只是五列文字列表。
- 数字员工看板主要图表能够跟随卡片高度填充,卡片间高度更均衡。
- 定向测试和前端构建通过。
## 风险与开放问题
- 当前工作区有大量既有未提交和未跟踪文件,本次提交需要严格隔离目标文件。
- 若现有测试文件中保留了旧版乱码断言,需要同步更新为 UTF-8 中文断言。
- 本次不改后端,如果后端后续新增新的风险 key需要前端映射表继续补充。

View File

@@ -0,0 +1,31 @@
# 风险与数字员工看板视觉优化 TODO
## 调研
- [x] 盘点风险看板英文指标、异常排行和数字员工布局现状。[CONCEPT: 背景与问题] 证据:已检查 `RiskObservationDashboard.vue``DigitalEmployeeDashboard.vue``DigitalEmployeeDailyWorkChart.vue``BarChart.vue` 和相关测试。
## 契约
- [x] 确认本次不改后端接口,只做前端展示归一化和布局优化。[CONCEPT: 目标与非目标] 证据:风险看板和数字员工看板已有所需数据字段。
## 前端
- [x] 扩展风险看板中文化映射,覆盖风险信号、来源、状态、未知值和规则名。[CONCEPT: 功能能力] 证据:新增 `riskLabels.js``RiskObservationDashboard.vue``useOverviewView.js` 已接入统一中文化函数。
- [x] 将异常排行改为图表化主视觉,并保留紧凑明细。[CONCEPT: 前端方案] 证据:`RiskObservationDashboard.vue` 新增 `rankingChartItems``rankingDetailGroups``risk-ranking-visual`
- [x] 优化数字员工看板卡片跨度、等高布局和图表填充。[CONCEPT: 前端方案] 证据:`DigitalEmployeeDashboard.vue` 调整趋势/摘要/最近记录栅格,并新增 `digital-chart-fill``digital-card-fill`
- [x] 调整数字员工趋势图高度,使其跟随父容器填充。[CONCEPT: 前端方案] 证据:`DigitalEmployeeDailyWorkChart.vue` 高度改为 `100%` 并保留 `min-height`
## 测试
- [x] 更新风险看板源码测试,覆盖中文化和图表化异常排行。[CONCEPT: 测试方案] 证据:`risk-observation-dashboard.test.mjs` 新增中文化 helper 和排行图表断言。
- [x] 更新数字员工看板源码测试,覆盖布局填充类名和图表高度策略。[CONCEPT: 测试方案] 证据:`digital-employee-dashboard.test.mjs` 新增填充布局断言。
- [x] 运行风险看板定向测试。[CONCEPT: 测试方案] 证据:`node web/tests/risk-observation-dashboard.test.mjs`7 passed。
- [x] 运行数字员工看板定向测试。[CONCEPT: 测试方案] 证据:`node web/tests/digital-employee-dashboard.test.mjs`4 passed。
- [x] 运行前端构建验证。[CONCEPT: 测试方案] 证据:`npm.cmd --prefix web run build` 通过,仍有既有 Rollup 注释和大 chunk 警告。
## 验收
- [x] 确认风险看板可见文案不再暴露常见英文 key。[CONCEPT: 验收标准] 证据:测试覆盖 `duplicate_invoice``policy.duplicate_invoice``travel``rule_center``financial_risk_graph` 中文化。
- [x] 确认异常排行主视觉为图表形式。[CONCEPT: 验收标准] 证据:组件中异常排行由 `BarChart``rankingChartItems` 驱动。
- [x] 确认数字员工看板主要图表和卡片减少无效空白。[CONCEPT: 验收标准] 证据:趋势图、饼图、条形图和业务产出卡片均接入可填充容器。
- [x] 评估提交和推送范围,避免纳入无关脏工作区变更。[CONCEPT: 风险与开放问题] 证据:暂存区限定为分析看板前端、风险标签工具、定向测试和本开发文档,未纳入 `server/storage`、日志、临时截图等无关文件。

View File

@@ -1,4 +1,5 @@
.dashboard { .dashboard {
position: relative;
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -6,6 +7,33 @@
animation: fadeUp 260ms var(--ease) both; 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;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: rgba(248, 250, 252, .88);
color: #334155;
font-size: 13px;
font-weight: 800;
backdrop-filter: blur(2px);
}
.dashboard-loading-overlay i {
color: var(--theme-primary);
font-size: 28px;
}
.dashboard.is-loading > :not(.dashboard-loading-overlay) {
pointer-events: none;
}
.kpi-grid { .kpi-grid {
display: grid; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));

View File

@@ -149,6 +149,7 @@ useEcharts(chartElement, chartOptions)
<style scoped> <style scoped>
.digital-employee-daily-work-chart { .digital-employee-daily-work-chart {
width: 100%; width: 100%;
height: 250px; height: 100%;
min-height: 280px;
} }
</style> </style>

View File

@@ -16,14 +16,16 @@
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
</div> </div>
<DigitalEmployeeDailyWorkChart v-else :rows="dailyRows" /> <div v-else class="digital-chart-fill digital-trend-fill">
<DigitalEmployeeDailyWorkChart :rows="dailyRows" />
</div>
</article> </article>
<article class="panel dashboard-card digital-work-day-panel"> <article class="panel dashboard-card digital-work-day-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>
<div class="digital-day-list"> <div class="digital-day-list digital-card-fill">
<div <div
v-for="row in dailyRows" v-for="row in dailyRows"
:key="row.date" :key="row.date"
@@ -43,23 +45,26 @@
<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>
<div class="digital-chart-fill digital-donut-fill">
<DonutChart <DonutChart
:items="categoryLegend" :items="categoryLegend"
:center-value="String(dashboard.totals.totalRuns || 0)" :center-value="String(dashboard.totals.totalRuns || 0)"
center-label="工作次数" center-label="工作次数"
/> />
</div>
</article> </article>
<article class="panel dashboard-card digital-task-panel"> <article class="panel dashboard-card digital-task-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>
<div v-if="taskRanking.length" class="digital-chart-fill digital-bar-fill">
<BarChart <BarChart
v-if="taskRanking.length"
:items="taskRanking" :items="taskRanking"
value-prefix="" value-prefix=""
value-suffix="" value-suffix=""
/> />
</div>
<div v-else class="digital-dashboard-empty"> <div v-else class="digital-dashboard-empty">
<i class="mdi mdi-clipboard-check-outline"></i> <i class="mdi mdi-clipboard-check-outline"></i>
<span>当前周期暂无工作记录</span> <span>当前周期暂无工作记录</span>
@@ -70,7 +75,7 @@
<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>
<div class="digital-output-grid"> <div class="digital-output-grid digital-card-fill">
<div v-for="item in outputItems" :key="item.label" class="digital-output-item"> <div v-for="item in outputItems" :key="item.label" class="digital-output-item">
<span :style="{ color: item.color }"><i :class="item.icon"></i></span> <span :style="{ color: item.color }"><i :class="item.icon"></i></span>
<div> <div>
@@ -216,12 +221,17 @@ function formatRunMetrics(metrics = {}) {
.digital-employee-dashboard { .digital-employee-dashboard {
display: grid; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
grid-auto-rows: minmax(300px, auto);
align-items: stretch;
gap: 18px; gap: 18px;
min-width: 0; min-width: 0;
} }
.dashboard-card { .dashboard-card {
min-width: 0; min-width: 0;
min-height: 300px;
display: flex;
flex-direction: column;
padding: 18px; padding: 18px;
border: 1px solid #edf2f7; border: 1px solid #edf2f7;
background: #fff; background: #fff;
@@ -270,23 +280,72 @@ function formatRunMetrics(metrics = {}) {
font-weight: 800; font-weight: 800;
} }
.digital-work-trend-panel, .digital-work-trend-panel {
.digital-recent-panel { grid-column: span 7;
grid-column: span 8; min-height: 380px;
} }
.digital-work-day-panel { .digital-work-day-panel {
grid-column: span 4; grid-column: span 5;
min-height: 380px;
} }
.digital-category-panel, .digital-category-panel,
.digital-task-panel, .digital-task-panel,
.digital-output-panel { .digital-output-panel {
grid-column: span 4; grid-column: span 4;
min-height: 320px;
}
.digital-recent-panel {
grid-column: span 12;
min-height: 330px;
}
.digital-card-fill {
flex: 1;
min-height: 0;
}
.digital-chart-fill {
flex: 1;
min-height: 250px;
display: flex;
min-width: 0;
}
.digital-chart-fill > :deep(*) {
flex: 1;
min-width: 0;
}
.digital-trend-fill {
min-height: 290px;
}
.digital-donut-fill :deep(.donut-chart) {
min-height: 100%;
}
.digital-donut-fill :deep(.donut-body) {
flex: 1;
height: auto;
min-height: 168px;
}
.digital-bar-fill :deep(.bar-chart) {
min-height: 100%;
align-items: stretch;
}
.digital-bar-fill :deep(.chart-area) {
height: auto;
min-height: 240px;
} }
.digital-dashboard-state, .digital-dashboard-state,
.digital-dashboard-empty { .digital-dashboard-empty {
flex: 1;
min-height: 220px; min-height: 220px;
display: grid; display: grid;
place-content: center; place-content: center;
@@ -314,7 +373,9 @@ function formatRunMetrics(metrics = {}) {
.digital-day-list { .digital-day-list {
display: grid; display: grid;
align-content: start;
gap: 9px; gap: 9px;
overflow: auto;
} }
.digital-day-row { .digital-day-row {
@@ -372,12 +433,13 @@ function formatRunMetrics(metrics = {}) {
.digital-output-grid { .digital-output-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
.digital-output-item { .digital-output-item {
min-width: 0; min-width: 0;
min-height: 92px; min-height: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -420,7 +482,10 @@ function formatRunMetrics(metrics = {}) {
} }
.digital-recent-table { .digital-recent-table {
flex: 1;
min-height: 0;
display: grid; display: grid;
align-content: start;
border: 1px solid #edf2f7; border: 1px solid #edf2f7;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;

View File

@@ -1,6 +1,6 @@
<template> <template>
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }"> <section class="risk-observation-dashboard" :class="{ 'is-loading': showBlockingLoading }">
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite"> <div v-if="showBlockingLoading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin"></i> <i class="mdi mdi-loading mdi-spin"></i>
<span>{{ loadingLabel }}</span> <span>{{ loadingLabel }}</span>
</div> </div>
@@ -96,14 +96,21 @@
<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>
<div class="risk-ranking-grid"> <div v-if="rankingChartItems.length" class="risk-ranking-visual">
<BarChart
:items="rankingChartItems"
value-prefix=""
value-suffix=""
:compact="false"
/>
<div class="risk-ranking-detail-grid">
<section <section
v-for="group in rankingGroups" v-for="group in rankingDetailGroups"
:key="group.label" :key="group.label"
class="risk-ranking-group" class="risk-ranking-group"
> >
<span class="risk-ranking-title">{{ group.label }}</span> <span class="risk-ranking-title">{{ group.label }}</span>
<ol v-if="group.rows.length" class="risk-ranking-list"> <ol class="risk-ranking-list">
<li v-for="(row, index) in group.rows" :key="`${group.label}-${row.name}`"> <li v-for="(row, index) in group.rows" :key="`${group.label}-${row.name}`">
<em>{{ index + 1 }}</em> <em>{{ index + 1 }}</em>
<div> <div>
@@ -113,9 +120,13 @@
<span>{{ row.count }}</span> <span>{{ row.count }}</span>
</li> </li>
</ol> </ol>
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section> </section>
</div> </div>
</div>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-chart-bar"></i>
<span>当前周期暂无异常排行</span>
</div>
</article> </article>
<article class="panel dashboard-card risk-effect-panel"> <article class="panel dashboard-card risk-effect-panel">
@@ -147,7 +158,7 @@
{{ formatRiskLevel(item.riskLevel) }} {{ formatRiskLevel(item.riskLevel) }}
</span> </span>
<span class="risk-recent-main"> <span class="risk-recent-main">
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong> <strong>{{ formatObservationTitle(item) }}</strong>
<small>{{ item.claimNo || item.claimId || '未关联单据' }}</small> <small>{{ item.claimNo || item.claimId || '未关联单据' }}</small>
</span> </span>
<span class="risk-recent-score">{{ item.riskScore }}</span> <span class="risk-recent-score">{{ item.riskScore }}</span>
@@ -169,6 +180,12 @@ 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 EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import {
formatRiskDimensionLabel,
formatRiskLevelLabel,
formatRiskObservationTitle,
formatRiskSignalLabel
} from '../../utils/riskLabels.js'
const props = defineProps({ const props = defineProps({
dashboard: { type: Object, required: true }, dashboard: { type: Object, required: true },
@@ -188,6 +205,7 @@ const router = useRouter()
const loadingLabel = computed(() => ( const loadingLabel = computed(() => (
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据' props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
)) ))
const showBlockingLoading = computed(() => props.loading && !props.lastUpdatedAt)
const lastUpdatedLabel = computed(() => { const lastUpdatedLabel = computed(() => {
if (!props.lastUpdatedAt) { if (!props.lastUpdatedAt) {
return '' return ''
@@ -218,6 +236,33 @@ const rankingGroups = computed(() => [
buildRankingGroup('规则', props.dashboard.topRules, 'rule'), buildRankingGroup('规则', props.dashboard.topRules, 'rule'),
buildRankingGroup('费用类型', props.dashboard.topExpenseTypes, 'expense_type') buildRankingGroup('费用类型', props.dashboard.topExpenseTypes, 'expense_type')
]) ])
const rankingChartItems = computed(() => rankingGroups.value
.map((group, index) => {
const topRow = group.rows[0]
if (!topRow) {
return null
}
return {
name: group.label,
shortName: group.label,
value: topRow.count,
meta: `${topRow.name} · ${topRow.amountLabel}`,
color: [
'#ef4444',
'#f59e0b',
'var(--theme-primary)',
'#0f766e',
'#2563eb'
][index] || '#64748b'
}
})
.filter(Boolean))
const rankingDetailGroups = computed(() => rankingGroups.value
.filter((group) => group.rows.length)
.map((group) => ({
...group,
rows: group.rows.slice(0, 3)
})))
const effectItems = computed(() => { const effectItems = computed(() => {
const sourceDistribution = props.dashboard.sourceDistribution || {} const sourceDistribution = props.dashboard.sourceDistribution || {}
const total = Number(props.dashboard.totalObservations || 0) const total = Number(props.dashboard.totalObservations || 0)
@@ -282,46 +327,19 @@ function formatPercent(value) {
} }
function formatRiskLevel(value) { function formatRiskLevel(value) {
const labels = { return formatRiskLevelLabel(value)
critical: '重大',
high: '高',
medium: '中',
low: '低'
}
return labels[String(value || '').trim()] || '未知'
} }
function formatDimensionName(value, kind = '') { function formatDimensionName(value, kind = '') {
const text = String(value || '').trim() return formatRiskDimensionLabel(value, kind)
if (!text || text === 'unknown') {
const unknownLabels = {
department: '未归集部门',
expense_type: '未归集费用',
risk_type: '未知风险类型',
supplier: '未归集供应商',
grade: '未归集职级',
employee: '未归集员工',
rule: '未关联规则'
}
return unknownLabels[kind] || '未知'
}
if (kind === 'risk_type' || kind === 'expense_type' || kind === 'rule') {
return formatSignal(text)
}
return text.replace(/_/g, ' ')
} }
function formatSignal(value) { function formatSignal(value) {
const text = String(value || '').trim() return formatRiskSignalLabel(value)
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
} }
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
function formatObservationTitle(item) {
return formatRiskObservationTitle(item)
} }
function openClaim(item) { function openClaim(item) {
@@ -472,7 +490,7 @@ function openClaim(item) {
} }
.risk-dimension-grid, .risk-dimension-grid,
.risk-ranking-grid { .risk-ranking-detail-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
@@ -481,7 +499,20 @@ function openClaim(item) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.risk-ranking-grid { .risk-ranking-visual {
display: grid;
gap: 14px;
}
.risk-ranking-visual :deep(.bar-chart) {
min-height: 250px;
}
.risk-ranking-visual :deep(.chart-area) {
height: 250px;
}
.risk-ranking-detail-grid {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
@@ -759,7 +790,7 @@ function openClaim(item) {
} }
.risk-dimension-grid, .risk-dimension-grid,
.risk-ranking-grid { .risk-ranking-detail-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
} }

View File

@@ -6,6 +6,10 @@ import {
fetchSystemDashboard fetchSystemDashboard
} from '../services/analytics.js' } from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js' import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import {
formatRiskSignalLabel,
formatRiskSourceLabel
} from '../utils/riskLabels.js'
import { import {
buildDigitalEmployeeCategoryRows, buildDigitalEmployeeCategoryRows,
buildDigitalEmployeeDailyRows, buildDigitalEmployeeDailyRows,
@@ -75,6 +79,13 @@ const emptyFinanceBudgetMetrics = [
] ]
export function useOverviewView(options = {}) { export function useOverviewView(options = {}) {
const activeDashboardKey = computed(() => {
const dashboard = String(options.dashboard || '').trim()
if (dashboard === 'system') return 'system'
if (dashboard === 'risk') return 'risk'
if (dashboard === 'digitalEmployee') return 'digitalEmployee'
return 'finance'
})
const activeTrendRange = ref(trendRanges[0]) const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0]) const activeDepartmentRange = ref(departmentRangeOptions[0])
const riskWindowOptions = [ const riskWindowOptions = [
@@ -86,18 +97,22 @@ export function useOverviewView(options = {}) {
const financeDashboardPayload = ref(null) const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false) const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null) const financeDashboardError = ref(null)
const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value))
const systemDashboardPayload = ref(null) const systemDashboardPayload = ref(null)
const systemDashboardLoading = ref(false) const systemDashboardLoading = ref(false)
const systemDashboardError = ref(null) const systemDashboardError = ref(null)
const systemDashboardLoaded = computed(() => Boolean(systemDashboardPayload.value))
const riskDashboardPayload = ref(null) const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false) const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null) const riskDashboardError = ref(null)
const riskDashboardLoaded = computed(() => Boolean(riskDashboardPayload.value))
const riskDashboardLastUpdatedAt = ref('') const riskDashboardLastUpdatedAt = ref('')
let riskDashboardRefreshTimer = 0 let riskDashboardRefreshTimer = 0
let riskDashboardRequestSeq = 0 let riskDashboardRequestSeq = 0
const digitalEmployeeDashboardPayload = ref(null) const digitalEmployeeDashboardPayload = ref(null)
const digitalEmployeeDashboardLoading = ref(false) const digitalEmployeeDashboardLoading = ref(false)
const digitalEmployeeDashboardError = ref(null) const digitalEmployeeDashboardError = ref(null)
const digitalEmployeeDashboardLoaded = computed(() => Boolean(digitalEmployeeDashboardPayload.value))
const formatCompact = (value) => { const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M` if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
@@ -251,12 +266,29 @@ export function useOverviewView(options = {}) {
activeRiskWindowDays.value = matched ? days : 30 activeRiskWindowDays.value = matched ? days : 30
} }
onMounted(() => { const loadActiveDashboard = () => {
void loadFinanceDashboard() if (activeDashboardKey.value === 'system') {
void loadSystemDashboard() void loadSystemDashboard()
stopRiskDashboardRealtimeRefresh()
return
}
if (activeDashboardKey.value === 'risk') {
void loadRiskDashboard() void loadRiskDashboard()
void loadDigitalEmployeeDashboard()
startRiskDashboardRealtimeRefresh() startRiskDashboardRealtimeRefresh()
return
}
if (activeDashboardKey.value === 'digitalEmployee') {
void loadDigitalEmployeeDashboard()
stopRiskDashboardRealtimeRefresh()
return
}
void loadFinanceDashboard()
stopRiskDashboardRealtimeRefresh()
}
onMounted(() => {
loadActiveDashboard()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -272,12 +304,20 @@ export function useOverviewView(options = {}) {
activeDepartmentRange.value activeDepartmentRange.value
], ],
() => { () => {
if (activeDashboardKey.value === 'finance') {
void loadFinanceDashboard() void loadFinanceDashboard()
} }
}
) )
watch(activeRiskWindowDays, () => { watch(activeRiskWindowDays, () => {
if (activeDashboardKey.value === 'risk') {
void loadRiskDashboard() void loadRiskDashboard()
}
})
watch(activeDashboardKey, () => {
loadActiveDashboard()
}) })
const systemDashboardTotals = computed(() => ( const systemDashboardTotals = computed(() => (
@@ -695,7 +735,8 @@ export function useOverviewView(options = {}) {
financial_risk_graph: 'var(--theme-primary)', financial_risk_graph: 'var(--theme-primary)',
rule_center: '#0f766e', rule_center: '#0f766e',
unknown: '#94a3b8' unknown: '#94a3b8'
} },
formatRiskSourceLabel
)) ))
const riskSignalRanking = computed(() => { const riskSignalRanking = computed(() => {
const rows = Array.isArray(riskDashboard.value.topRiskSignals) const rows = Array.isArray(riskDashboard.value.topRiskSignals)
@@ -739,7 +780,7 @@ export function useOverviewView(options = {}) {
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value)) const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value))
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value)) const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
function buildRiskDistributionLegend(distribution, labels, colors) { function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
const entries = Object.entries(distribution || {}) const entries = Object.entries(distribution || {})
.filter(([, value]) => Number(value || 0) > 0) .filter(([, value]) => Number(value || 0) > 0)
@@ -755,7 +796,7 @@ export function useOverviewView(options = {}) {
} }
return entries.map(([key, value]) => ({ return entries.map(([key, value]) => ({
name: labels[key] || formatRiskSignalName(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] || 'var(--theme-primary)'
@@ -763,16 +804,7 @@ export function useOverviewView(options = {}) {
} }
function formatRiskSignalName(value) { function formatRiskSignalName(value) {
const text = String(value || '').trim() return formatRiskSignalLabel(value)
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
} }
function isMissingDimension(value) { function isMissingDimension(value) {
@@ -800,12 +832,14 @@ export function useOverviewView(options = {}) {
digitalEmployeeCategoryRows, digitalEmployeeCategoryRows,
digitalEmployeeDashboard, digitalEmployeeDashboard,
digitalEmployeeDashboardError, digitalEmployeeDashboardError,
digitalEmployeeDashboardLoaded,
digitalEmployeeDashboardLoading, digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows, digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics, digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking, digitalEmployeeTaskRanking,
exceptionMix, exceptionMix,
financeDashboardError, financeDashboardError,
financeDashboardLoaded,
financeDashboardLoading, financeDashboardLoading,
formatCompact, formatCompact,
formatCurrency, formatCurrency,
@@ -818,6 +852,7 @@ export function useOverviewView(options = {}) {
rankedEmployees, rankedEmployees,
riskDashboard, riskDashboard,
riskDashboardError, riskDashboardError,
riskDashboardLoaded,
riskDashboardLastUpdatedAt, riskDashboardLastUpdatedAt,
riskDashboardLoading, riskDashboardLoading,
riskDailyTrendRows, riskDailyTrendRows,
@@ -835,6 +870,7 @@ export function useOverviewView(options = {}) {
spendTotal, spendTotal,
systemDashboardTotals, systemDashboardTotals,
systemDashboardError, systemDashboardError,
systemDashboardLoaded,
systemDashboardLoading, systemDashboardLoading,
systemAgentDailyRatio, systemAgentDailyRatio,
systemLoginWave, systemLoginWave,

233
web/src/utils/riskLabels.js Normal file
View File

@@ -0,0 +1,233 @@
const RISK_SIGNAL_LABELS = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额报销',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请',
travel_city_consistency: '差旅城市一致性',
travel_route_city_consistency: '差旅路线一致性',
hotel_over_limit: '住宿超标',
hotel_amount_missing: '住宿金额待补充',
travel_meal_amount_missing: '餐饮金额待补充',
travel_meal_allowance_over_limit: '餐补超标',
transport_class_over_limit: '交通舱等超标',
travel_type_uncertain: '差旅类型待确认',
invoice_missing: '票据缺失',
attachment_missing: '附件缺失',
amount_missing: '金额待补充',
policy_missing: '制度依据缺失',
budget_overrun: '预算超支',
suspicious_supplier: '供应商异常',
abnormal_frequency: '频次异常',
abnormal_amount: '金额异常',
manual_review: '人工复核',
unknown: '未知风险'
}
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
transport: '交通费',
hotel: '住宿费',
meal: '餐饮费',
office: '办公费',
entertainment: '招待费',
training: '培训费',
communication: '通讯费',
taxi_receipt: '出租车票据',
parking_toll_receipt: '停车通行票据',
transport_receipt: '交通票据',
train_ticket: '火车票',
flight_itinerary: '机票行程单',
hotel_invoice: '住宿发票',
meal_receipt: '餐饮票据',
travel_ticket: '差旅票据',
travel_allowance: '差旅补贴',
other: '其他费用',
unknown: '未知费用'
}
const RISK_SOURCE_LABELS = {
financial_risk_graph: '风险图谱',
rule_center: '规则中心',
user_feedback: '用户反馈',
manual_review: '人工复核',
system_scan: '系统扫描',
onlyoffice: '文档协同',
onlyoffice_preview: '文档预览',
unknown: '未知来源'
}
const RISK_STATUS_LABELS = {
pending_review: '待复核',
pending: '待处理',
confirmed: '已确认',
false_positive: '误报',
resolved: '已处理',
ignored: '已忽略',
closed: '已关闭',
unknown: '未知状态'
}
const RISK_LEVEL_LABELS = {
critical: '重大',
high: '高',
medium: '中',
low: '低',
none: '无',
unknown: '未知'
}
const RULE_LABELS = {
'policy.duplicate_invoice': '重复发票规则',
'policy.split_billing': '拆分报销规则',
'policy.frequent_small_claims': '高频小额规则',
'policy.location_mismatch': '地点一致性规则',
'policy.amount_outlier': '金额异常规则',
'policy.preapproval_absent': '事前申请规则',
'rule.expense.travel_risk_control_standard': '差旅风险控制规则',
'rule.expense.travel_receipt_requirements': '差旅票据要求',
'rule.expense.company_travel_expense_reimbursement': '公司差旅费报销规则'
}
const UNKNOWN_LABELS = {
department: '未归集部门',
expense_type: '未归集费用',
risk_type: '未知风险类型',
supplier: '未归集供应商',
grade: '未归集职级',
employee: '未归集员工',
rule: '未关联规则',
source: '未知来源',
status: '未知状态'
}
const LATIN_PATTERN = /[A-Za-z]/
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeKey(value) {
return normalizeText(value)
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.replace(/\/+/g, '.')
.toLowerCase()
}
function lastKeySegment(key) {
const parts = String(key || '').split(/[.:]/).filter(Boolean)
return parts[parts.length - 1] || key
}
function isMissingValue(text) {
return !text || ['unknown', '待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
}
function fallbackVisibleText(text, fallback) {
if (!text) {
return fallback
}
return LATIN_PATTERN.test(text) ? fallback : text
}
export function formatRiskSignalLabel(value, fallback = '未知风险') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return fallback
}
const key = normalizeKey(text)
const direct = RISK_SIGNAL_LABELS[key] || RISK_SIGNAL_LABELS[lastKeySegment(key)]
if (direct) {
return direct
}
if (key.startsWith('policy.') || key.startsWith('rule.')) {
return formatRiskRuleLabel(text)
}
return fallbackVisibleText(text, fallback)
}
export function formatExpenseTypeLabel(value, fallback = '未知费用') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return fallback
}
const key = normalizeKey(text)
return EXPENSE_TYPE_LABELS[key] || EXPENSE_TYPE_LABELS[lastKeySegment(key)] || fallbackVisibleText(text, fallback)
}
export function formatRiskSourceLabel(value, fallback = '未知来源') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return fallback
}
const key = normalizeKey(text)
return RISK_SOURCE_LABELS[key] || RISK_SOURCE_LABELS[lastKeySegment(key)] || fallbackVisibleText(text, fallback)
}
export function formatRiskStatusLabel(value, fallback = '未知状态') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return fallback
}
const key = normalizeKey(text)
return RISK_STATUS_LABELS[key] || RISK_STATUS_LABELS[lastKeySegment(key)] || fallbackVisibleText(text, fallback)
}
export function formatRiskLevelLabel(value, fallback = '未知') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return fallback
}
const key = normalizeKey(text)
return RISK_LEVEL_LABELS[key] || fallbackVisibleText(text, fallback)
}
export function formatRiskRuleLabel(value, fallback = '规则指标') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return '未关联规则'
}
const key = normalizeKey(text)
if (RULE_LABELS[key]) {
return RULE_LABELS[key]
}
const suffix = lastKeySegment(key)
const signalLabel = RISK_SIGNAL_LABELS[suffix]
if (signalLabel) {
return `${signalLabel}规则`
}
return fallbackVisibleText(text, fallback)
}
export function formatRiskDimensionLabel(value, kind = '') {
const text = normalizeText(value)
if (isMissingValue(text)) {
return UNKNOWN_LABELS[kind] || '未知'
}
if (kind === 'risk_type') {
return formatRiskSignalLabel(text, '未知风险类型')
}
if (kind === 'expense_type') {
return formatExpenseTypeLabel(text)
}
if (kind === 'rule') {
return formatRiskRuleLabel(text)
}
if (kind === 'source') {
return formatRiskSourceLabel(text)
}
if (kind === 'status') {
return formatRiskStatusLabel(text)
}
return text
}
export function formatRiskObservationTitle(item = {}) {
const title = normalizeText(item.title)
if (title && !LATIN_PATTERN.test(title)) {
return title
}
return formatRiskSignalLabel(item.riskSignal || item.riskType || title)
}

View File

@@ -1,5 +1,9 @@
<template> <template>
<section class="dashboard" :class="`dashboard-${activeDashboard}`"> <section class="dashboard" :class="[`dashboard-${activeDashboard}`, { 'is-loading': activeDashboardLoading }]">
<div v-if="activeDashboardLoading" class="dashboard-loading-overlay" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ activeDashboardLoadingText }}</span>
</div>
<div class="kpi-grid"> <div class="kpi-grid">
<article <article
v-for="metric in activeKpiMetrics" v-for="metric in activeKpiMetrics"
@@ -383,15 +387,19 @@ const {
digitalEmployeeCategoryRows, digitalEmployeeCategoryRows,
digitalEmployeeDashboard, digitalEmployeeDashboard,
digitalEmployeeDashboardError, digitalEmployeeDashboardError,
digitalEmployeeDashboardLoaded,
digitalEmployeeDashboardLoading, digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows, digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics, digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking, digitalEmployeeTaskRanking,
financeDashboardLoading,
financeDashboardLoaded,
kpiMetrics, kpiMetrics,
rankedDepartments, rankedDepartments,
rankedEmployees, rankedEmployees,
riskDashboard, riskDashboard,
riskDashboardError, riskDashboardError,
riskDashboardLoaded,
riskDashboardLastUpdatedAt, riskDashboardLastUpdatedAt,
riskDashboardLoading, riskDashboardLoading,
riskDailyTrendRows, riskDailyTrendRows,
@@ -403,6 +411,8 @@ const {
setRiskWindowDays, setRiskWindowDays,
spendCenterValue, spendCenterValue,
spendLegend, spendLegend,
systemDashboardLoading,
systemDashboardLoaded,
systemAccuracyComparison, systemAccuracyComparison,
systemAgentDailyRatio, systemAgentDailyRatio,
systemFeedbackSummary, systemFeedbackSummary,
@@ -429,6 +439,24 @@ const activeKpiMetrics = computed(() => {
if (activeDashboard.value === 'risk') return riskKpiMetrics.value if (activeDashboard.value === 'risk') return riskKpiMetrics.value
return kpiMetrics.value return kpiMetrics.value
}) })
const activeDashboardLoading = computed(() => {
if (activeDashboard.value === 'system') {
return systemDashboardLoading.value && !systemDashboardLoaded.value
}
if (activeDashboard.value === 'digitalEmployee') {
return digitalEmployeeDashboardLoading.value && !digitalEmployeeDashboardLoaded.value
}
if (activeDashboard.value === 'risk') {
return riskDashboardLoading.value && !riskDashboardLoaded.value
}
return financeDashboardLoading.value && !financeDashboardLoaded.value
})
const activeDashboardLoadingText = computed(() => {
if (activeDashboard.value === 'system') return '正在加载系统看板数据'
if (activeDashboard.value === 'digitalEmployee') return '正在加载数字员工看板数据'
if (activeDashboard.value === 'risk') return '正在加载风险看板数据'
return '正在加载财务看板数据'
})
</script> </script>
<style scoped src="../assets/styles/views/overview-view.css"></style> <style scoped src="../assets/styles/views/overview-view.css"></style>

View File

@@ -89,3 +89,16 @@ test('digital employee dashboard renders enterprise dashboard panels with chart
assert.match(dailyChartComponent, /name: '业务产出'/) assert.match(dailyChartComponent, /name: '业务产出'/)
assert.doesNotMatch(dashboardComponent, /hermes/i) assert.doesNotMatch(dashboardComponent, /hermes/i)
}) })
test('digital employee dashboard uses filled card layout for charts and rows', () => {
assert.match(dashboardComponent, /digital-chart-fill digital-trend-fill/)
assert.match(dashboardComponent, /digital-chart-fill digital-donut-fill/)
assert.match(dashboardComponent, /digital-chart-fill digital-bar-fill/)
assert.match(dashboardComponent, /digital-output-grid digital-card-fill/)
assert.match(dashboardComponent, /grid-auto-rows: minmax\(300px, auto\)/)
assert.match(dashboardComponent, /\.digital-work-trend-panel \{\s*grid-column: span 7/s)
assert.match(dashboardComponent, /\.digital-work-day-panel \{\s*grid-column: span 5/s)
assert.match(dashboardComponent, /\.digital-recent-panel \{\s*grid-column: span 12/s)
assert.match(dailyChartComponent, /height: 100%/)
assert.match(dailyChartComponent, /min-height: 280px/)
})

View File

@@ -4,6 +4,13 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { normalizeRiskObservationDashboard } from '../src/services/riskObservations.js' import { normalizeRiskObservationDashboard } from '../src/services/riskObservations.js'
import {
formatExpenseTypeLabel,
formatRiskDimensionLabel,
formatRiskObservationTitle,
formatRiskSignalLabel,
formatRiskSourceLabel
} from '../src/utils/riskLabels.js'
const dashboardComponent = readFileSync( const dashboardComponent = readFileSync(
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)), fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
@@ -17,6 +24,10 @@ const overviewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)), fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8' 'utf8'
) )
const riskLabels = readFileSync(
fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)),
'utf8'
)
test('risk dashboard normalizes amount, distributions, and ranking fields', () => { test('risk dashboard normalizes amount, distributions, and ranking fields', () => {
const dashboard = normalizeRiskObservationDashboard({ const dashboard = normalizeRiskObservationDashboard({
@@ -68,6 +79,35 @@ test('risk dashboard renders overview amount and multi-dimension panels', () =>
assert.match(dashboardComponent, /topRules/) assert.match(dashboardComponent, /topRules/)
}) })
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(
formatRiskObservationTitle({ title: 'policy.duplicate_invoice', riskSignal: 'duplicate_invoice' }),
'重复发票'
)
assert.match(riskLabels, /travel: '差旅费'/)
assert.match(riskLabels, /rule_center: '规则中心'/)
assert.match(overviewViewModel, /formatRiskSignalLabel/)
assert.match(overviewViewModel, /formatRiskSourceLabel/)
assert.match(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', () => { test('risk dashboard wires window filter to trend, ranking, and cards data source', () => {
assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/) assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/)
assert.match(overviewViewModel, /windowDays: activeRiskWindowDays\.value/) assert.match(overviewViewModel, /windowDays: activeRiskWindowDays\.value/)
@@ -84,6 +124,8 @@ test('risk dashboard wires window filter to trend, ranking, and cards data sourc
}) })
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, /activeDashboardLoadingText/)
assert.match(dashboardComponent, /risk-dashboard-loading-overlay/) assert.match(dashboardComponent, /risk-dashboard-loading-overlay/)
assert.match(dashboardComponent, /loadingLabel/) assert.match(dashboardComponent, /loadingLabel/)
assert.match(dashboardComponent, /lastUpdatedLabel/) assert.match(dashboardComponent, /lastUpdatedLabel/)
@@ -95,3 +137,16 @@ test('risk dashboard shows loading overlay and realtime refresh status', () => {
assert.match(overviewViewModel, /riskDashboardRequestSeq/) assert.match(overviewViewModel, /riskDashboardRequestSeq/)
assert.match(overviewTemplate, /:last-updated-at="riskDashboardLastUpdatedAt"/) assert.match(overviewTemplate, /:last-updated-at="riskDashboardLastUpdatedAt"/)
}) })
test('overview dashboards are loaded on demand instead of all at once', () => {
assert.match(overviewViewModel, /const activeDashboardKey = computed/)
assert.match(overviewViewModel, /const loadActiveDashboard = \(\) =>/)
assert.doesNotMatch(
overviewViewModel,
/onMounted\(\(\) => \{\s*void loadFinanceDashboard\(\)\s*void loadSystemDashboard\(\)\s*void loadRiskDashboard\(\)\s*void loadDigitalEmployeeDashboard\(\)/
)
assert.match(overviewViewModel, /watch\(activeDashboardKey/)
assert.match(overviewViewModel, /activeDashboardKey\.value === 'risk'/)
assert.match(overviewViewModel, /startRiskDashboardRealtimeRefresh\(\)/)
assert.match(overviewViewModel, /stopRiskDashboardRealtimeRefresh\(\)/)
})