feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -1,19 +1,53 @@
import { computed, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { fetchFinanceDashboard, fetchSystemDashboard } from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import {
metricBlueprints,
systemMetricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
spendByCategory as fallbackSpendByCategory,
exceptionMix as fallbackExceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
bottlenecks as fallbackBottlenecks,
budgetSummary as fallbackBudgetSummary,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
systemTokenDailyWave as fallbackSystemTokenDailyWave,
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
systemUserTokenUsage as fallbackSystemUserTokenUsage,
systemAccuracyComparison as fallbackSystemAccuracyComparison,
systemTrendSeries,
systemToolCallMix,
systemExecutionMix,
systemToolRankings,
systemModelUsage,
systemFeedbackSummary as fallbackSystemFeedbackSummary,
systemLoadHeatmap,
systemToolDetailRows as fallbackSystemToolDetailRows
} from '../data/metrics.js'
export function useOverviewView() {
export function useOverviewView(options = {}) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const riskWindowOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
{ label: '近 90 天', value: 90 }
]
const activeRiskWindowDays = ref(30)
const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null)
const systemDashboardPayload = ref(null)
const systemDashboardLoading = ref(false)
const systemDashboardError = ref(null)
const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null)
const demoTotals = {
pendingCount: 128,
@@ -40,6 +74,15 @@ export function useOverviewView() {
const formatCurrency = (value) => formatCompact(value)
const formatNumberCompact = (value) => {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
@@ -48,34 +91,382 @@ export function useOverviewView() {
return `${Math.round(value)}`
}
const formatSystemMetricValue = (metric, value) => {
const numericValue = Number(value || 0)
if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
if (metric.key === 'positiveFeedback') {
const negativeFeedback = Math.round(Number(systemDashboardTotals.value.negativeFeedback || 0))
return `${Math.round(numericValue)} / ${negativeFeedback}`
}
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
return formatNumberCompact(numericValue)
}
const getFinanceRangeParams = () => {
const activeRange = String(options.activeRange || '近10日')
const customRange = options.customRange || {}
const isCustomRange = activeRange === 'custom'
return {
rangeKey: activeRange,
startDate: isCustomRange ? customRange.start : '',
endDate: isCustomRange ? customRange.end : '',
trendRange: activeTrendRange.value,
departmentRange: activeDepartmentRange.value
}
}
const loadFinanceDashboard = async () => {
financeDashboardLoading.value = true
financeDashboardError.value = null
try {
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams())
} catch (error) {
financeDashboardPayload.value = null
financeDashboardError.value = error
} finally {
financeDashboardLoading.value = false
}
}
const loadSystemDashboard = async () => {
systemDashboardLoading.value = true
systemDashboardError.value = null
try {
systemDashboardPayload.value = await fetchSystemDashboard({ days: 7 })
} catch (error) {
systemDashboardPayload.value = null
systemDashboardError.value = error
} finally {
systemDashboardLoading.value = false
}
}
const loadRiskDashboard = async () => {
riskDashboardLoading.value = true
riskDashboardError.value = null
try {
riskDashboardPayload.value = await fetchRiskObservationDashboard({
windowDays: activeRiskWindowDays.value,
limit: 500
})
} catch (error) {
riskDashboardPayload.value = null
riskDashboardError.value = error
} finally {
riskDashboardLoading.value = false
}
}
const setRiskWindowDays = (value) => {
const days = Number(value || 30)
const matched = riskWindowOptions.some((item) => Number(item.value) === days)
activeRiskWindowDays.value = matched ? days : 30
}
onMounted(() => {
void loadFinanceDashboard()
void loadSystemDashboard()
void loadRiskDashboard()
})
watch(
() => [
options.activeRange,
options.customRange?.start,
options.customRange?.end,
activeTrendRange.value,
activeDepartmentRange.value
],
() => {
void loadFinanceDashboard()
}
)
watch(activeRiskWindowDays, () => {
void loadRiskDashboard()
})
const systemDashboardTotals = computed(() => (
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals
))
const systemAgentDailyRatio = computed(() => (
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
))
const systemLoginWave = computed(() => (
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave
))
const systemTokenDailyWave = computed(() => (
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave
))
const systemUsageDurationSummary = computed(() => (
systemDashboardPayload.value?.usageDurationSummary || fallbackSystemUsageDurationSummary
))
const systemUserTokenUsage = computed(() => (
systemDashboardPayload.value?.userTokenUsage || fallbackSystemUserTokenUsage
))
const systemAccuracyComparison = computed(() => (
systemDashboardPayload.value?.accuracyComparison || fallbackSystemAccuracyComparison
))
const systemFeedbackSummary = computed(() => (
systemDashboardPayload.value?.feedbackSummary || fallbackSystemFeedbackSummary
))
const systemToolDetailRows = computed(() => (
systemDashboardPayload.value?.toolDetailRows || fallbackSystemToolDetailRows
))
const riskDashboard = computed(() => (
riskDashboardPayload.value || {
windowDays: activeRiskWindowDays.value,
totalObservations: 0,
pendingCount: 0,
highOrAboveCount: 0,
confirmedCount: 0,
falsePositiveCount: 0,
totalAmount: 0,
averageScore: 0,
confirmationRate: 0,
falsePositiveRate: 0,
candidateRuleCount: 0,
levelDistribution: {},
statusDistribution: {},
signalDistribution: {},
sourceDistribution: {},
automationDistribution: {},
departmentDistribution: {},
expenseTypeDistribution: {},
riskTypeDistribution: {},
supplierDistribution: {},
employeeGradeDistribution: {},
dailyTrend: [],
topRiskSignals: [],
topDepartments: [],
topEmployees: [],
topSuppliers: [],
topExpenseTypes: [],
topRules: [],
recentHighObservations: []
}
))
const financeDashboardTotals = computed(() => (
financeDashboardPayload.value?.totals || demoTotals
))
const financeMetricMeta = computed(() => (
financeDashboardPayload.value?.metricMeta || {}
))
const financeTrend = computed(() => (
financeDashboardPayload.value?.trend || trendSeries[activeTrendRange.value]
))
const financeSpendByCategory = computed(() => (
financeDashboardPayload.value?.spendByCategory || fallbackSpendByCategory
))
const financeExceptionMix = computed(() => (
financeDashboardPayload.value?.exceptionMix || fallbackExceptionMix
))
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || demoDepartments
))
const financeBottlenecks = computed(() => (
financeDashboardPayload.value?.bottlenecks || fallbackBottlenecks
))
const financeBudgetSummary = computed(() => (
financeDashboardPayload.value?.budgetSummary || fallbackBudgetSummary
))
const resolveSystemMetricMeta = (metric) => {
const totals = systemDashboardTotals.value
const realDashboardLoaded = Boolean(systemDashboardPayload.value)
if (!realDashboardLoaded) {
return {
changeText: metric.change,
delta: metric.delta,
trend: metric.trend
}
}
if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
const changeValue = Number(totals[`${metric.key}Change`] || 0)
return {
changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
delta: '较上一周期',
trend: changeValue < 0 ? 'down' : 'up'
}
}
if (metric.key === 'executionSuccessRate') {
const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
return {
changeText: '实时',
delta: `错误率 ${errorRate.toFixed(1)}%`,
trend: 'up'
}
}
if (metric.key === 'positiveFeedback') {
return {
changeText: '实时',
delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))}`,
trend: 'up'
}
}
return {
changeText: '实时',
delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
trend: metric.trend
}
}
const resolveFinanceMetricMeta = (metric) => {
const meta = financeMetricMeta.value[metric.key]
if (!financeDashboardPayload.value || !meta) {
return {
changeText: metric.change,
delta: metric.delta,
trend: metric.trend
}
}
return {
changeText: meta.changeText || metric.change,
delta: meta.delta || metric.delta,
trend: meta.trend || metric.trend
}
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
const displayValue = formatMetricValue(metric, rawValue)
const metricMeta = resolveFinanceMetricMeta(metric)
return {
...metric,
...metricMeta,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const systemKpiMetrics = computed(() => systemMetricBlueprints.map((metric, index) => {
const rawValue = systemDashboardTotals.value[metric.key]
const displayValue = formatSystemMetricValue(metric, rawValue)
const metricMeta = resolveSystemMetricMeta(metric)
const spendLegend = computed(() => spendByCategory.map((item) => ({
return {
...metric,
...metricMeta,
displayValue,
delay: index * 55
}
}))
const riskKpiMetrics = computed(() => {
const data = riskDashboard.value
const rows = [
{
label: '新增风险数',
value: formatNumberCompact(data.totalObservations),
changeText: `${data.windowDays}`,
delta: '统一观察池',
trend: 'up',
icon: 'mdi mdi-shield-search',
accent: 'var(--theme-primary)'
},
{
label: '高风险待处置',
value: formatNumberCompact(data.highOrAboveCount),
changeText: data.highOrAboveCount > 0 ? '需关注' : '稳定',
delta: '高/重大风险',
trend: data.highOrAboveCount > 0 ? 'down' : 'up',
icon: 'mdi mdi-alert-octagon-outline',
accent: '#ef4444'
},
{
label: '涉及金额',
value: formatCurrency(Number(data.totalAmount || 0)),
changeText: '归集',
delta: '关联单据金额',
trend: 'up',
icon: 'mdi mdi-cash-multiple',
accent: '#0f766e'
},
{
label: '已确认风险',
value: formatNumberCompact(data.confirmedCount),
changeText: formatPercent(data.confirmationRate),
delta: '人工确认',
trend: 'up',
icon: 'mdi mdi-check-decagram-outline',
accent: 'var(--success)'
},
{
label: '误报数量',
value: formatNumberCompact(data.falsePositiveCount),
changeText: formatPercent(data.falsePositiveRate),
delta: '反馈校准',
trend: data.falsePositiveRate > 0.2 ? 'down' : 'up',
icon: 'mdi mdi-tune-variant',
accent: '#8b5cf6'
},
{
label: '待复核',
value: formatNumberCompact(data.pendingCount),
changeText: '待处理',
delta: '人工闭环',
trend: data.pendingCount > 0 ? 'down' : 'up',
icon: 'mdi mdi-account-clock-outline',
accent: '#f59e0b'
}
]
return rows.map((item, index) => ({
...item,
displayValue: item.value,
delay: index * 55
}))
})
const activeTrend = computed(() => financeTrend.value)
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
display: spendTotal.value ? `${Math.round((Number(item.value || 0) / spendTotal.value) * 100)}%` : '0%'
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
const riskLegend = computed(() => financeExceptionMix.value.map((item) => ({
...item,
display: `${item.value}`
})))
const systemToolTotal = computed(() =>
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
)
const systemExecutionTotal = computed(() =>
systemExecutionMix.reduce((sum, item) => sum + item.value, 0)
)
const systemToolCallLegend = computed(() => systemToolCallMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemToolTotal.value) * 100)}%`
})))
const systemExecutionLegend = computed(() => systemExecutionMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const rows = financeDepartmentRanking.value.map((item) => ({
...item,
amount: Number(item.amount || item.value || 0)
}))
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
@@ -88,25 +479,215 @@ export function useOverviewView() {
}))
})
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amount: item.value
})))
const systemModelUsageRows = computed(() => systemModelUsage.map((item) => ({
...item,
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
width: `${Math.max(Math.min(item.share, 100), 8)}%`
})))
const systemExecutionRows = computed(() => systemExecutionMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`,
width: `${Math.max((item.value / systemExecutionTotal.value) * 100, 1)}%`
})))
const systemToolDetailItems = computed(() => {
const maxCalls = Math.max(...systemToolDetailRows.value.map((item) => item.calls), 1)
return systemToolDetailRows.value.map((item) => ({
...item,
callLabel: `${formatNumberCompact(item.calls)}`,
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
width: `${Math.max((item.calls / maxCalls) * 100, 10)}%`
}))
})
const systemUsageDurationRows = computed(() => {
const rows = systemUsageDurationSummary.value.rows || []
const maxValue = Math.max(...rows.map((item) => item.value), 1)
return rows.map((item) => ({
...item,
width: `${Math.max((item.value / maxValue) * 100, 12)}%`
}))
})
const riskLevelLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.levelDistribution,
{
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
},
{
critical: '#b91c1c',
high: '#ef4444',
medium: '#f59e0b',
low: '#3b82f6'
}
))
const riskSourceLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.sourceDistribution,
{
financial_risk_graph: '风险图谱',
rule_center: '规则中心',
unknown: '未知来源'
},
{
financial_risk_graph: 'var(--theme-primary)',
rule_center: '#0f766e',
unknown: '#94a3b8'
}
))
const riskSignalRanking = computed(() => {
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
? riskDashboard.value.topRiskSignals
: []
const fallbackRows = Object.entries(riskDashboard.value.signalDistribution || {})
.map(([name, count]) => ({ name, count }))
return (rows.length ? rows : fallbackRows)
.slice(0, 6)
.map((item, index) => ({
name: formatRiskSignalName(item.name),
shortName: formatRiskSignalName(item.name),
value: Number(item.count || 0),
color: [
'#ef4444',
'#f59e0b',
'var(--theme-primary)',
'#3b82f6',
'#8b5cf6',
'#0f766e'
][index] || '#64748b'
}))
})
const riskDailyTrendRows = computed(() => {
const rows = Array.isArray(riskDashboard.value.dailyTrend) ? riskDashboard.value.dailyTrend : []
const normalizedRows = rows.slice(-7).map((item) => ({
date: String(item.date || '').trim() || '-',
total: Number(item.total || 0),
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
}))
const maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
return normalizedRows.map((item) => ({
...item,
width: `${Math.max((item.total / maxValue) * 100, 4)}%`,
highWidth: `${Math.max((item.highOrAbove / maxValue) * 100, item.highOrAbove ? 4 : 0)}%`
}))
})
function buildRiskDistributionLegend(distribution, labels, colors) {
const entries = Object.entries(distribution || {})
.filter(([, value]) => Number(value || 0) > 0)
if (!entries.length) {
return [
{
name: '暂无数据',
value: 1,
display: '0项',
color: '#cbd5e1'
}
]
}
return entries.map(([key, value]) => ({
name: labels[key] || formatRiskSignalName(key),
value: Number(value || 0),
display: `${Number(value || 0)}`,
color: colors[key] || 'var(--theme-primary)'
}))
}
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, ' ') || '未知风险'
}
const bottlenecks = financeBottlenecks
const budgetSummary = financeBudgetSummary
const spendByCategory = financeSpendByCategory
const exceptionMix = financeExceptionMix
return {
activeDepartmentRange,
activeRiskWindowDays,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
exceptionMix,
financeDashboardError,
financeDashboardLoading,
formatCompact,
formatCurrency,
formatMetricValue,
formatNumberCompact,
formatSystemMetricValue,
kpiMetrics,
metricBlueprints,
rankedDepartments,
riskDashboard,
riskDashboardError,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskTotal,
riskWindowOptions,
setRiskWindowDays,
spendByCategory,
spendCenterValue,
spendLegend,
spendTotal,
systemDashboardTotals,
systemDashboardError,
systemDashboardLoading,
systemAgentDailyRatio,
systemLoginWave,
systemTokenDailyWave,
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
systemAccuracyComparison,
systemExecutionLegend,
systemExecutionMix,
systemExecutionTotal,
systemFeedbackSummary,
systemKpiMetrics,
systemLoadHeatmap,
systemExecutionRows,
systemMetricBlueprints,
systemModelUsageRows,
systemToolDetailItems,
systemToolCallLegend,
systemToolCallMix,
systemToolRankingItems,
systemToolRankings,
systemToolTotal,
systemTrendSeries,
trendRanges,
trendSeries
}