Files
X-Financial/web/src/composables/useOverviewView.js

695 lines
22 KiB
JavaScript
Raw Normal View History

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 as fallbackSpendByCategory,
exceptionMix as fallbackExceptionMix,
departmentRangeOptions,
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(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,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: 'var(--theme-primary)' },
{ name: '研发中心', amount: 146000, color: 'var(--chart-blue)' },
{ name: '市场部', amount: 96000, color: 'var(--chart-amber)' },
{ name: '运营部', amount: 68600, color: 'var(--chart-purple)' },
{ name: '行政部', amount: 48300, color: 'var(--chart-blue)' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
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}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
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 = Number(financeDashboardTotals.value[metric.key] || 0)
const displayValue = formatMetricValue(metric, rawValue)
const metricMeta = resolveFinanceMetricMeta(metric)
return {
...metric,
...metricMeta,
displayValue,
delay: index * 55
}
}))
const systemKpiMetrics = computed(() => systemMetricBlueprints.map((metric, index) => {
const rawValue = systemDashboardTotals.value[metric.key]
const displayValue = formatSystemMetricValue(metric, rawValue)
const metricMeta = resolveSystemMetricMeta(metric)
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: spendTotal.value ? `${Math.round((Number(item.value || 0) / spendTotal.value) * 100)}%` : '0%'
})))
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 = 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) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
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
}
}