每日工作摘要
+
-
+
-
+
技能类型分布
+
+
工作模块排行
+
+
当前周期暂无工作记录
@@ -70,7 +75,7 @@
-
业务产出
+
@@ -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;
diff --git a/web/src/components/dashboard/RiskObservationDashboard.vue b/web/src/components/dashboard/RiskObservationDashboard.vue
index 00a5740..b4f582e 100644
--- a/web/src/components/dashboard/RiskObservationDashboard.vue
+++ b/web/src/components/dashboard/RiskObservationDashboard.vue
@@ -1,6 +1,6 @@
-
-
+
+
-
{{ loadingLabel }}
@@ -96,25 +96,36 @@
异常排行
-
- {{ group.label }}
-
+
+
+
{
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 '正在加载财务看板数据'
+})
diff --git a/web/tests/digital-employee-dashboard.test.mjs b/web/tests/digital-employee-dashboard.test.mjs
index 9397b06..33bdf84 100644
--- a/web/tests/digital-employee-dashboard.test.mjs
+++ b/web/tests/digital-employee-dashboard.test.mjs
@@ -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/)
+})
diff --git a/web/tests/risk-observation-dashboard.test.mjs b/web/tests/risk-observation-dashboard.test.mjs
index 126a8fd..3cb858d 100644
--- a/web/tests/risk-observation-dashboard.test.mjs
+++ b/web/tests/risk-observation-dashboard.test.mjs
@@ -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\(\)/)
+})
-
-
-
- {{ index + 1 }}
- - {{ row.name }} - {{ row.amountLabel }} -- {{ row.count }}项 -
-
暂无数据
-
+
+
+
+
+ {{ group.label }}
+
+
+ -
+
-
+ {{ index + 1 }}
+ + {{ row.name }} + {{ row.amountLabel }} ++ {{ row.count }}项 +
+
+
+ 当前周期暂无异常排行
@@ -147,7 +158,7 @@
{{ formatRiskLevel(item.riskLevel) }}
- {{ item.title || formatSignal(item.riskSignal) }}
+ {{ formatObservationTitle(item) }}
{{ item.claimNo || item.claimId || '未关联单据' }}
{{ item.riskScore }}
@@ -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);
}
}
diff --git a/web/src/composables/useOverviewView.js b/web/src/composables/useOverviewView.js
index 9677e15..311f746 100644
--- a/web/src/composables/useOverviewView.js
+++ b/web/src/composables/useOverviewView.js
@@ -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(() => {
+ const loadActiveDashboard = () => {
+ if (activeDashboardKey.value === 'system') {
+ void loadSystemDashboard()
+ stopRiskDashboardRealtimeRefresh()
+ return
+ }
+ if (activeDashboardKey.value === 'risk') {
+ void loadRiskDashboard()
+ startRiskDashboardRealtimeRefresh()
+ return
+ }
+ if (activeDashboardKey.value === 'digitalEmployee') {
+ void loadDigitalEmployeeDashboard()
+ stopRiskDashboardRealtimeRefresh()
+ return
+ }
+
void loadFinanceDashboard()
- void loadSystemDashboard()
- void loadRiskDashboard()
- void loadDigitalEmployeeDashboard()
- startRiskDashboardRealtimeRefresh()
+ stopRiskDashboardRealtimeRefresh()
+ }
+
+ onMounted(() => {
+ loadActiveDashboard()
})
onBeforeUnmount(() => {
@@ -272,12 +304,20 @@ export function useOverviewView(options = {}) {
activeDepartmentRange.value
],
() => {
- void loadFinanceDashboard()
+ if (activeDashboardKey.value === 'finance') {
+ void loadFinanceDashboard()
+ }
}
)
watch(activeRiskWindowDays, () => {
- void loadRiskDashboard()
+ 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,
diff --git a/web/src/utils/riskLabels.js b/web/src/utils/riskLabels.js
new file mode 100644
index 0000000..4f9aed1
--- /dev/null
+++ b/web/src/utils/riskLabels.js
@@ -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)
+}
diff --git a/web/src/views/OverviewView.vue b/web/src/views/OverviewView.vue
index ea9be3f..6bafc76 100644
--- a/web/src/views/OverviewView.vue
+++ b/web/src/views/OverviewView.vue
@@ -1,5 +1,9 @@
-
+
+ {{ activeDashboardLoadingText }}
+