feat(dashboard): polish risk and digital employee boards
This commit is contained in:
95
document/development/风险与数字员工看板视觉优化/CONCEPT.md
Normal file
95
document/development/风险与数字员工看板视觉优化/CONCEPT.md
Normal 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,需要前端映射表继续补充。
|
||||
31
document/development/风险与数字员工看板视觉优化/TODO.md
Normal file
31
document/development/风险与数字员工看板视觉优化/TODO.md
Normal 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`、日志、临时截图等无关文件。
|
||||
@@ -1,4 +1,5 @@
|
||||
.dashboard {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -6,6 +7,33 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
|
||||
@@ -149,6 +149,7 @@ useEcharts(chartElement, chartOptions)
|
||||
<style scoped>
|
||||
.digital-employee-daily-work-chart {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,14 +16,16 @@
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
<DigitalEmployeeDailyWorkChart v-else :rows="dailyRows" />
|
||||
<div v-else class="digital-chart-fill digital-trend-fill">
|
||||
<DigitalEmployeeDailyWorkChart :rows="dailyRows" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-work-day-panel">
|
||||
<div class="card-head">
|
||||
<h3>每日工作摘要 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<div class="digital-day-list">
|
||||
<div class="digital-day-list digital-card-fill">
|
||||
<div
|
||||
v-for="row in dailyRows"
|
||||
:key="row.date"
|
||||
@@ -43,23 +45,26 @@
|
||||
<div class="card-head">
|
||||
<h3>技能类型分布 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<div class="digital-chart-fill digital-donut-fill">
|
||||
<DonutChart
|
||||
:items="categoryLegend"
|
||||
:center-value="String(dashboard.totals.totalRuns || 0)"
|
||||
center-label="工作次数"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card digital-task-panel">
|
||||
<div class="card-head">
|
||||
<h3>工作模块排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<div v-if="taskRanking.length" class="digital-chart-fill digital-bar-fill">
|
||||
<BarChart
|
||||
v-if="taskRanking.length"
|
||||
:items="taskRanking"
|
||||
value-prefix=""
|
||||
value-suffix="次"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="digital-dashboard-empty">
|
||||
<i class="mdi mdi-clipboard-check-outline"></i>
|
||||
<span>当前周期暂无工作记录</span>
|
||||
@@ -70,7 +75,7 @@
|
||||
<div class="card-head">
|
||||
<h3>业务产出 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</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">
|
||||
<span :style="{ color: item.color }"><i :class="item.icon"></i></span>
|
||||
<div>
|
||||
@@ -216,12 +221,17 @@ function formatRunMetrics(metrics = {}) {
|
||||
.digital-employee-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(300px, auto);
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
min-width: 0;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
border: 1px solid #edf2f7;
|
||||
background: #fff;
|
||||
@@ -270,23 +280,72 @@ function formatRunMetrics(metrics = {}) {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.digital-work-trend-panel,
|
||||
.digital-recent-panel {
|
||||
grid-column: span 8;
|
||||
.digital-work-trend-panel {
|
||||
grid-column: span 7;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.digital-work-day-panel {
|
||||
grid-column: span 4;
|
||||
grid-column: span 5;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.digital-category-panel,
|
||||
.digital-task-panel,
|
||||
.digital-output-panel {
|
||||
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-empty {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
@@ -314,7 +373,9 @@ function formatRunMetrics(metrics = {}) {
|
||||
|
||||
.digital-day-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 9px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.digital-day-row {
|
||||
@@ -372,12 +433,13 @@ function formatRunMetrics(metrics = {}) {
|
||||
.digital-output-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.digital-output-item {
|
||||
min-width: 0;
|
||||
min-height: 92px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -420,7 +482,10 @@ function formatRunMetrics(metrics = {}) {
|
||||
}
|
||||
|
||||
.digital-recent-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }">
|
||||
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
|
||||
<section class="risk-observation-dashboard" :class="{ 'is-loading': showBlockingLoading }">
|
||||
<div v-if="showBlockingLoading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ loadingLabel }}</span>
|
||||
</div>
|
||||
@@ -96,14 +96,21 @@
|
||||
<div class="card-head">
|
||||
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</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
|
||||
v-for="group in rankingGroups"
|
||||
v-for="group in rankingDetailGroups"
|
||||
:key="group.label"
|
||||
class="risk-ranking-group"
|
||||
>
|
||||
<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}`">
|
||||
<em>{{ index + 1 }}</em>
|
||||
<div>
|
||||
@@ -113,9 +120,13 @@
|
||||
<span>{{ row.count }}项</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="risk-dashboard-empty">
|
||||
<i class="mdi mdi-chart-bar"></i>
|
||||
<span>当前周期暂无异常排行</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card risk-effect-panel">
|
||||
@@ -147,7 +158,7 @@
|
||||
{{ formatRiskLevel(item.riskLevel) }}
|
||||
</span>
|
||||
<span class="risk-recent-main">
|
||||
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
|
||||
<strong>{{ formatObservationTitle(item) }}</strong>
|
||||
<small>{{ item.claimNo || item.claimId || '未关联单据' }}</small>
|
||||
</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 RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import {
|
||||
formatRiskDimensionLabel,
|
||||
formatRiskLevelLabel,
|
||||
formatRiskObservationTitle,
|
||||
formatRiskSignalLabel
|
||||
} from '../../utils/riskLabels.js'
|
||||
|
||||
const props = defineProps({
|
||||
dashboard: { type: Object, required: true },
|
||||
@@ -188,6 +205,7 @@ const router = useRouter()
|
||||
const loadingLabel = computed(() => (
|
||||
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
|
||||
))
|
||||
const showBlockingLoading = computed(() => props.loading && !props.lastUpdatedAt)
|
||||
const lastUpdatedLabel = computed(() => {
|
||||
if (!props.lastUpdatedAt) {
|
||||
return ''
|
||||
@@ -218,6 +236,33 @@ const rankingGroups = computed(() => [
|
||||
buildRankingGroup('规则', props.dashboard.topRules, 'rule'),
|
||||
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 sourceDistribution = props.dashboard.sourceDistribution || {}
|
||||
const total = Number(props.dashboard.totalObservations || 0)
|
||||
@@ -282,46 +327,19 @@ function formatPercent(value) {
|
||||
}
|
||||
|
||||
function formatRiskLevel(value) {
|
||||
const labels = {
|
||||
critical: '重大',
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低'
|
||||
}
|
||||
return labels[String(value || '').trim()] || '未知'
|
||||
return formatRiskLevelLabel(value)
|
||||
}
|
||||
|
||||
function formatDimensionName(value, kind = '') {
|
||||
const text = String(value || '').trim()
|
||||
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, ' ')
|
||||
return formatRiskDimensionLabel(value, kind)
|
||||
}
|
||||
|
||||
function formatSignal(value) {
|
||||
const text = String(value || '').trim()
|
||||
const labels = {
|
||||
duplicate_invoice: '重复发票',
|
||||
split_billing: '拆分报销',
|
||||
frequent_small_claims: '高频小额',
|
||||
location_mismatch: '地点不一致',
|
||||
amount_outlier: '金额异常',
|
||||
preapproval_absent: '缺少事前申请'
|
||||
}
|
||||
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
||||
return formatRiskSignalLabel(value)
|
||||
}
|
||||
|
||||
function formatObservationTitle(item) {
|
||||
return formatRiskObservationTitle(item)
|
||||
}
|
||||
|
||||
function openClaim(item) {
|
||||
@@ -472,7 +490,7 @@ function openClaim(item) {
|
||||
}
|
||||
|
||||
.risk-dimension-grid,
|
||||
.risk-ranking-grid {
|
||||
.risk-ranking-detail-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -481,7 +499,20 @@ function openClaim(item) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -759,7 +790,7 @@ function openClaim(item) {
|
||||
}
|
||||
|
||||
.risk-dimension-grid,
|
||||
.risk-ranking-grid {
|
||||
.risk-ranking-detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
fetchSystemDashboard
|
||||
} from '../services/analytics.js'
|
||||
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
|
||||
import {
|
||||
formatRiskSignalLabel,
|
||||
formatRiskSourceLabel
|
||||
} from '../utils/riskLabels.js'
|
||||
import {
|
||||
buildDigitalEmployeeCategoryRows,
|
||||
buildDigitalEmployeeDailyRows,
|
||||
@@ -75,6 +79,13 @@ const emptyFinanceBudgetMetrics = [
|
||||
]
|
||||
|
||||
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 activeDepartmentRange = ref(departmentRangeOptions[0])
|
||||
const riskWindowOptions = [
|
||||
@@ -86,18 +97,22 @@ export function useOverviewView(options = {}) {
|
||||
const financeDashboardPayload = ref(null)
|
||||
const financeDashboardLoading = ref(false)
|
||||
const financeDashboardError = ref(null)
|
||||
const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value))
|
||||
const systemDashboardPayload = ref(null)
|
||||
const systemDashboardLoading = ref(false)
|
||||
const systemDashboardError = ref(null)
|
||||
const systemDashboardLoaded = computed(() => Boolean(systemDashboardPayload.value))
|
||||
const riskDashboardPayload = ref(null)
|
||||
const riskDashboardLoading = ref(false)
|
||||
const riskDashboardError = ref(null)
|
||||
const riskDashboardLoaded = computed(() => Boolean(riskDashboardPayload.value))
|
||||
const riskDashboardLastUpdatedAt = ref('')
|
||||
let riskDashboardRefreshTimer = 0
|
||||
let riskDashboardRequestSeq = 0
|
||||
const digitalEmployeeDashboardPayload = ref(null)
|
||||
const digitalEmployeeDashboardLoading = ref(false)
|
||||
const digitalEmployeeDashboardError = ref(null)
|
||||
const digitalEmployeeDashboardLoaded = computed(() => Boolean(digitalEmployeeDashboardPayload.value))
|
||||
|
||||
const formatCompact = (value) => {
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFinanceDashboard()
|
||||
const loadActiveDashboard = () => {
|
||||
if (activeDashboardKey.value === 'system') {
|
||||
void loadSystemDashboard()
|
||||
stopRiskDashboardRealtimeRefresh()
|
||||
return
|
||||
}
|
||||
if (activeDashboardKey.value === 'risk') {
|
||||
void loadRiskDashboard()
|
||||
void loadDigitalEmployeeDashboard()
|
||||
startRiskDashboardRealtimeRefresh()
|
||||
return
|
||||
}
|
||||
if (activeDashboardKey.value === 'digitalEmployee') {
|
||||
void loadDigitalEmployeeDashboard()
|
||||
stopRiskDashboardRealtimeRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
void loadFinanceDashboard()
|
||||
stopRiskDashboardRealtimeRefresh()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActiveDashboard()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -272,12 +304,20 @@ export function useOverviewView(options = {}) {
|
||||
activeDepartmentRange.value
|
||||
],
|
||||
() => {
|
||||
if (activeDashboardKey.value === 'finance') {
|
||||
void loadFinanceDashboard()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeRiskWindowDays, () => {
|
||||
if (activeDashboardKey.value === 'risk') {
|
||||
void loadRiskDashboard()
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeDashboardKey, () => {
|
||||
loadActiveDashboard()
|
||||
})
|
||||
|
||||
const systemDashboardTotals = computed(() => (
|
||||
@@ -695,7 +735,8 @@ export function useOverviewView(options = {}) {
|
||||
financial_risk_graph: 'var(--theme-primary)',
|
||||
rule_center: '#0f766e',
|
||||
unknown: '#94a3b8'
|
||||
}
|
||||
},
|
||||
formatRiskSourceLabel
|
||||
))
|
||||
const riskSignalRanking = computed(() => {
|
||||
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
|
||||
@@ -739,7 +780,7 @@ export function useOverviewView(options = {}) {
|
||||
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(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 || {})
|
||||
.filter(([, value]) => Number(value || 0) > 0)
|
||||
|
||||
@@ -755,7 +796,7 @@ export function useOverviewView(options = {}) {
|
||||
}
|
||||
|
||||
return entries.map(([key, value]) => ({
|
||||
name: labels[key] || formatRiskSignalName(key),
|
||||
name: labels[key] || formatter(key),
|
||||
value: Number(value || 0),
|
||||
display: `${Number(value || 0)}项`,
|
||||
color: colors[key] || 'var(--theme-primary)'
|
||||
@@ -763,16 +804,7 @@ export function useOverviewView(options = {}) {
|
||||
}
|
||||
|
||||
function formatRiskSignalName(value) {
|
||||
const text = String(value || '').trim()
|
||||
const labels = {
|
||||
duplicate_invoice: '重复发票',
|
||||
split_billing: '拆分报销',
|
||||
frequent_small_claims: '高频小额',
|
||||
location_mismatch: '地点不一致',
|
||||
amount_outlier: '金额异常',
|
||||
preapproval_absent: '缺少事前申请'
|
||||
}
|
||||
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
||||
return formatRiskSignalLabel(value)
|
||||
}
|
||||
|
||||
function isMissingDimension(value) {
|
||||
@@ -800,12 +832,14 @@ export function useOverviewView(options = {}) {
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
digitalEmployeeDashboardError,
|
||||
digitalEmployeeDashboardLoaded,
|
||||
digitalEmployeeDashboardLoading,
|
||||
digitalEmployeeDailyRows,
|
||||
digitalEmployeeKpiMetrics,
|
||||
digitalEmployeeTaskRanking,
|
||||
exceptionMix,
|
||||
financeDashboardError,
|
||||
financeDashboardLoaded,
|
||||
financeDashboardLoading,
|
||||
formatCompact,
|
||||
formatCurrency,
|
||||
@@ -818,6 +852,7 @@ export function useOverviewView(options = {}) {
|
||||
rankedEmployees,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLoaded,
|
||||
riskDashboardLastUpdatedAt,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
@@ -835,6 +870,7 @@ export function useOverviewView(options = {}) {
|
||||
spendTotal,
|
||||
systemDashboardTotals,
|
||||
systemDashboardError,
|
||||
systemDashboardLoaded,
|
||||
systemDashboardLoading,
|
||||
systemAgentDailyRatio,
|
||||
systemLoginWave,
|
||||
|
||||
233
web/src/utils/riskLabels.js
Normal file
233
web/src/utils/riskLabels.js
Normal 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)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<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">
|
||||
<article
|
||||
v-for="metric in activeKpiMetrics"
|
||||
@@ -383,15 +387,19 @@ const {
|
||||
digitalEmployeeCategoryRows,
|
||||
digitalEmployeeDashboard,
|
||||
digitalEmployeeDashboardError,
|
||||
digitalEmployeeDashboardLoaded,
|
||||
digitalEmployeeDashboardLoading,
|
||||
digitalEmployeeDailyRows,
|
||||
digitalEmployeeKpiMetrics,
|
||||
digitalEmployeeTaskRanking,
|
||||
financeDashboardLoading,
|
||||
financeDashboardLoaded,
|
||||
kpiMetrics,
|
||||
rankedDepartments,
|
||||
rankedEmployees,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLoaded,
|
||||
riskDashboardLastUpdatedAt,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
@@ -403,6 +411,8 @@ const {
|
||||
setRiskWindowDays,
|
||||
spendCenterValue,
|
||||
spendLegend,
|
||||
systemDashboardLoading,
|
||||
systemDashboardLoaded,
|
||||
systemAccuracyComparison,
|
||||
systemAgentDailyRatio,
|
||||
systemFeedbackSummary,
|
||||
@@ -429,6 +439,24 @@ const activeKpiMetrics = computed(() => {
|
||||
if (activeDashboard.value === 'risk') return riskKpiMetrics.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>
|
||||
|
||||
<style scoped src="../assets/styles/views/overview-view.css"></style>
|
||||
|
||||
@@ -89,3 +89,16 @@ test('digital employee dashboard renders enterprise dashboard panels with chart
|
||||
assert.match(dailyChartComponent, /name: '业务产出'/)
|
||||
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/)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,13 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeRiskObservationDashboard } from '../src/services/riskObservations.js'
|
||||
import {
|
||||
formatExpenseTypeLabel,
|
||||
formatRiskDimensionLabel,
|
||||
formatRiskObservationTitle,
|
||||
formatRiskSignalLabel,
|
||||
formatRiskSourceLabel
|
||||
} from '../src/utils/riskLabels.js'
|
||||
|
||||
const dashboardComponent = readFileSync(
|
||||
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)),
|
||||
'utf8'
|
||||
)
|
||||
const riskLabels = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('risk dashboard normalizes amount, distributions, and ranking fields', () => {
|
||||
const dashboard = normalizeRiskObservationDashboard({
|
||||
@@ -68,6 +79,35 @@ test('risk dashboard renders overview amount and multi-dimension panels', () =>
|
||||
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', () => {
|
||||
assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/)
|
||||
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', () => {
|
||||
assert.match(overviewTemplate, /dashboard-loading-overlay/)
|
||||
assert.match(overviewTemplate, /activeDashboardLoadingText/)
|
||||
assert.match(dashboardComponent, /risk-dashboard-loading-overlay/)
|
||||
assert.match(dashboardComponent, /loadingLabel/)
|
||||
assert.match(dashboardComponent, /lastUpdatedLabel/)
|
||||
@@ -95,3 +137,16 @@ test('risk dashboard shows loading overlay and realtime refresh status', () => {
|
||||
assert.match(overviewViewModel, /riskDashboardRequestSeq/)
|
||||
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\(\)/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user