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

1037 lines
33 KiB
JavaScript
Raw Normal View History

import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
fetchDigitalEmployeeDashboard,
fetchFinanceDashboard,
fetchSystemDashboard
} from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
import {
buildDigitalEmployeeCategoryRows,
buildDigitalEmployeeDailyRows,
buildDigitalEmployeeKpiMetrics,
buildDigitalEmployeeTaskRanking,
emptyDigitalEmployeeDashboard
} from '../views/scripts/overviewDigitalEmployeeDashboardModel.js'
import {
metricBlueprints,
systemMetricBlueprints,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
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'
const DEFAULT_OVERVIEW_RANGE = '近10日'
const DAY_MS = 24 * 60 * 60 * 1000
const RISK_DAILY_TREND_MAX_BUCKETS = 14
const emptyFinanceTotals = {
reimbursementAmount: 0,
reimbursementCount: 0,
pendingPaymentAmount: 0,
avgClaimAmount: 0,
budgetUsageRate: 0,
paymentClearanceRate: 0
}
const emptyFinanceTrend = {
labels: [],
claimCount: [],
claimAmount: [],
categoryAmountSeries: [],
applications: [],
approved: [],
avgHours: []
}
const emptyFinanceDonut = [
{ name: '暂无数据', value: 0, color: '#cbd5e1' }
]
const emptyFinanceBudgetSummary = {
ratio: 0,
total: '¥0',
used: '¥0',
left: '¥0'
}
const emptyFinanceBudgetMetrics = [
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
]
const emptySystemDashboardTotals = {
toolCalls: 0,
modelTokens: 0,
onlineUsers: 0,
avgOnlineMinutes: 0,
executionSuccessRate: 0,
positiveFeedback: 0,
negativeFeedback: 0,
failedRuns: 0,
toolCallsChange: 0,
modelTokensChange: 0
}
const emptySystemLoginWave = {
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
loginUsers: Array.from({ length: 24 }, () => 0),
interactions: Array.from({ length: 24 }, () => 0)
}
function parseLocalDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
if (!match) {
return null
}
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
return Number.isNaN(date.getTime()) ? null : date
}
function clampWindowDays(value) {
const days = Number(value || 0)
if (!Number.isFinite(days) || days <= 0) {
return 10
}
return Math.max(1, Math.min(Math.round(days), 90))
}
function resolveCustomRangeDays(customRange = {}) {
const start = parseLocalDate(customRange.start)
const end = parseLocalDate(customRange.end)
if (!start || !end) {
return 10
}
return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
}
function resolveTopRangeDays(range, customRange = {}) {
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
if (key === 'custom') {
return resolveCustomRangeDays(customRange)
}
if (key === '\u4eca\u65e5') {
return 1
}
if (key === '\u672c\u5468') {
const today = new Date()
const weekday = today.getDay() || 7
return clampWindowDays(weekday)
}
if (key === '\u672c\u6708') {
return clampWindowDays(new Date().getDate())
}
const match = key.match(/\d+/)
return clampWindowDays(match ? Number(match[0]) : 10)
}
function resolveTopRangeKey(range, customRange = {}) {
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
if (key === 'custom') {
return 'custom'
}
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
return `recent-${resolveTopRangeDays(key, customRange)}-days`
}
if (/\d+/.test(key)) {
return `recent-${resolveTopRangeDays(key, customRange)}-days`
}
return key || DEFAULT_OVERVIEW_RANGE
}
function formatRiskTrendDateLabel(value) {
const date = parseLocalDate(value)
if (!date) {
return String(value || '-').trim() || '-'
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day}`
}
function buildRiskTrendBucketLabel(first, last) {
const start = String(first?.date || '').trim()
const end = String(last?.date || '').trim()
if (!start || start === end) {
return formatRiskTrendDateLabel(start)
}
return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
}
function normalizeRiskTrendRow(item) {
return {
date: String(item.date || '').trim() || '-',
total: Number(item.total || 0),
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
}
}
function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
const normalizedRows = rows
.map(normalizeRiskTrendRow)
.filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
if (normalizedRows.length <= maxBuckets) {
return normalizedRows.map((item) => ({
...item,
date: formatRiskTrendDateLabel(item.date),
sourceStartDate: item.date,
sourceEndDate: item.date
}))
}
const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
const buckets = []
for (let index = 0; index < normalizedRows.length; index += bucketSize) {
const bucketRows = normalizedRows.slice(index, index + bucketSize)
const first = bucketRows[0]
const last = bucketRows[bucketRows.length - 1]
buckets.push({
date: buildRiskTrendBucketLabel(first, last),
sourceStartDate: first?.date || '',
sourceEndDate: last?.date || '',
total: bucketRows.reduce((sum, item) => sum + item.total, 0),
highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
})
}
return buckets
}
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 activeRangeValue = computed(() => String(options.activeRange || DEFAULT_OVERVIEW_RANGE).trim() || DEFAULT_OVERVIEW_RANGE)
const customRangeValue = computed(() => options.customRange || {})
const activeRangeLabel = computed(() => {
if (activeRangeValue.value !== 'custom') {
return activeRangeValue.value
}
const start = String(customRangeValue.value.start || '').trim()
const end = String(customRangeValue.value.end || '').trim()
return start && end ? `${start} ~ ${end}` : '\u81ea\u5b9a\u4e49'
})
const topRangeDays = computed(() => resolveTopRangeDays(activeRangeValue.value, customRangeValue.value))
const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null)
const financeDashboardRenderKey = ref(0)
const financeDashboardLoaded = computed(() => Boolean(financeDashboardPayload.value))
let financeDashboardRequestSeq = 0
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`
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 (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
return formatCurrency(Math.round(value))
}
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 = activeRangeValue.value
const customRange = customRangeValue.value
const isCustomRange = activeRange === 'custom'
return {
rangeKey: activeRange,
startDate: isCustomRange ? customRange.start : '',
endDate: isCustomRange ? customRange.end : '',
trendRange: resolveTopRangeKey(activeRange, customRange),
departmentRange: resolveTopRangeKey(activeRange, customRange)
}
}
const loadFinanceDashboard = async () => {
const requestSeq = ++financeDashboardRequestSeq
financeDashboardLoading.value = true
financeDashboardError.value = null
try {
const payload = await fetchFinanceDashboard(getFinanceRangeParams())
if (requestSeq !== financeDashboardRequestSeq) {
return
}
financeDashboardPayload.value = payload
financeDashboardRenderKey.value += 1
} catch (error) {
if (requestSeq !== financeDashboardRequestSeq) {
return
}
financeDashboardPayload.value = null
financeDashboardError.value = error
} finally {
if (requestSeq === financeDashboardRequestSeq) {
financeDashboardLoading.value = false
}
}
}
const loadSystemDashboard = async () => {
systemDashboardLoading.value = true
systemDashboardError.value = null
try {
systemDashboardPayload.value = await fetchSystemDashboard({ days: topRangeDays.value })
} catch (error) {
systemDashboardPayload.value = null
systemDashboardError.value = error
} finally {
systemDashboardLoading.value = false
}
}
const loadRiskDashboard = async () => {
const requestSeq = ++riskDashboardRequestSeq
riskDashboardLoading.value = true
riskDashboardError.value = null
try {
const payload = await fetchRiskObservationDashboard({
windowDays: topRangeDays.value,
limit: 500
})
if (requestSeq !== riskDashboardRequestSeq) {
return
}
riskDashboardPayload.value = payload
riskDashboardLastUpdatedAt.value = new Date().toISOString()
} catch (error) {
if (requestSeq !== riskDashboardRequestSeq) {
return
}
riskDashboardPayload.value = null
riskDashboardError.value = error
} finally {
if (requestSeq === riskDashboardRequestSeq) {
riskDashboardLoading.value = false
}
}
}
const startRiskDashboardRealtimeRefresh = () => {
if (riskDashboardRefreshTimer) {
window.clearInterval(riskDashboardRefreshTimer)
}
riskDashboardRefreshTimer = window.setInterval(() => {
if (document.visibilityState === 'hidden' || riskDashboardLoading.value) {
return
}
void loadRiskDashboard()
}, 30_000)
}
const stopRiskDashboardRealtimeRefresh = () => {
if (!riskDashboardRefreshTimer) {
return
}
window.clearInterval(riskDashboardRefreshTimer)
riskDashboardRefreshTimer = 0
}
const loadDigitalEmployeeDashboard = async () => {
digitalEmployeeDashboardLoading.value = true
digitalEmployeeDashboardError.value = null
try {
digitalEmployeeDashboardPayload.value = await fetchDigitalEmployeeDashboard({
days: topRangeDays.value,
limit: 300
})
} catch (error) {
digitalEmployeeDashboardPayload.value = null
digitalEmployeeDashboardError.value = error
} finally {
digitalEmployeeDashboardLoading.value = false
}
}
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()
stopRiskDashboardRealtimeRefresh()
}
onMounted(() => {
loadActiveDashboard()
})
onBeforeUnmount(() => {
stopRiskDashboardRealtimeRefresh()
})
watch(
() => [
options.activeRange,
options.customRange?.start,
options.customRange?.end
],
() => {
loadActiveDashboard()
}
)
watch(activeDashboardKey, () => {
loadActiveDashboard()
})
const systemDashboardTotals = computed(() => (
systemDashboardPayload.value?.totals || emptySystemDashboardTotals
))
const systemAgentDailyRatio = computed(() => (
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
))
const systemLoginWave = computed(() => (
systemDashboardPayload.value?.loginWave || emptySystemLoginWave
))
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: topRangeDays.value,
totalObservations: 0,
pendingCount: 0,
riskClueCount: 0,
highOrAboveCount: 0,
confirmedCount: 0,
falsePositiveCount: 0,
feedbackSampleCount: 0,
totalAmount: 0,
averageScore: 0,
confirmationRate: 0,
falsePositiveRate: 0,
levelDistribution: {},
statusDistribution: {},
signalDistribution: {},
sourceDistribution: {},
automationDistribution: {},
departmentDistribution: {},
expenseTypeDistribution: {},
riskTypeDistribution: {},
supplierDistribution: {},
employeeGradeDistribution: {},
dailyTrend: [],
topRiskSignals: [],
topDepartments: [],
topEmployees: [],
topSuppliers: [],
topExpenseTypes: [],
topRules: [],
recentHighObservations: []
}
))
const digitalEmployeeDashboard = computed(() => (
digitalEmployeeDashboardPayload.value || emptyDigitalEmployeeDashboard
))
const financeDashboardTotals = computed(() => (
financeDashboardPayload.value?.totals || emptyFinanceTotals
))
const financeMetricMeta = computed(() => (
financeDashboardPayload.value?.metricMeta || {}
))
const financeTrend = computed(() => (
financeDashboardPayload.value?.trend || emptyFinanceTrend
))
const financeSpendByCategory = computed(() => (
financeDashboardPayload.value?.spendByCategory || emptyFinanceDonut
))
const financeExceptionMix = computed(() => (
financeDashboardPayload.value?.exceptionMix || emptyFinanceDonut
))
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || []
))
const financeDepartmentEmployeeMix = computed(() => (
financeDashboardPayload.value?.departmentEmployeeMix || emptyFinanceDonut
))
const financeEmployeeRanking = computed(() => (
financeDashboardPayload.value?.employeeRanking || []
))
const financeTopClaims = computed(() => (
financeDashboardPayload.value?.topClaims || []
))
const financeBottlenecks = computed(() => (
financeDashboardPayload.value?.bottlenecks || []
))
const financeBudgetSummary = computed(() => (
financeDashboardPayload.value?.budgetSummary || emptyFinanceBudgetSummary
))
const financeBudgetMetrics = computed(() => (
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
))
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: financeDashboardLoading.value ? '加载中' : '实时',
delta: financeDashboardError.value ? '真实数据加载失败' : '等待真实数据',
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 digitalEmployeeKpiMetrics = computed(() => (
buildDigitalEmployeeKpiMetrics(digitalEmployeeDashboard.value, formatNumberCompact)
))
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 departmentEmployeeTotal = computed(() => (
financeDepartmentEmployeeMix.value.reduce((sum, item) => sum + Number(item.value || item.amount || 0), 0)
))
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
const departmentEmployeeCenterValue = computed(() => formatCurrency(Math.round(departmentEmployeeTotal.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 departmentEmployeeLegend = computed(() => financeDepartmentEmployeeMix.value.map((item) => ({
...item,
value: Number(item.value || item.amount || 0),
display: departmentEmployeeTotal.value
? `${Math.round((Number(item.value || item.amount || 0) / departmentEmployeeTotal.value) * 100)}%`
: '0%'
})))
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
.filter((item) => !isMissingDimension(item.name))
.map((item) => ({
...item,
amount: Number(item.amount || item.value || 0)
}))
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 6).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
meta: `${Number(item.employeeCount || 0)} 人 / ${Number(item.count || 0)}`,
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
const rankedEmployees = computed(() => {
const rows = financeEmployeeRanking.value
.filter((item) => !isMissingDimension(item.name))
.map((item) => ({
...item,
amount: Number(item.amount || item.value || 0)
}))
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 6).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
meta: `${item.department || '未归属部门'} / ${Number(item.count || 0)}`,
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
const topClaims = computed(() => (
financeTopClaims.value.map((item) => ({
...item,
amountLabel: item.amountLabel || formatCurrency(Number(item.amount || 0))
}))
))
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 riskCompositionLegend = computed(() => {
const signalDistribution = riskDashboard.value.signalDistribution || {}
const fallbackDistribution = Object.fromEntries(
(Array.isArray(riskDashboard.value.topRiskSignals) ? riskDashboard.value.topRiskSignals : [])
.map((item) => [item.name, Number(item.count || 0)])
)
return buildRiskDistributionLegend(
Object.keys(signalDistribution).length ? signalDistribution : fallbackDistribution,
{},
{
duplicate_invoice: '#ef4444',
budget_pressure: '#f59e0b',
amount_outlier: '#8b5cf6',
weekend_or_holiday: '#2563eb',
supplier_anomaly: '#0f766e',
split_billing: '#dc2626',
missing_material: '#64748b'
},
formatRiskSignalName
)
})
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 = aggregateRiskDailyTrendRows(rows)
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)}%`
}))
})
const digitalEmployeeDailyRows = computed(() => buildDigitalEmployeeDailyRows(digitalEmployeeDashboard.value))
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value))
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
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], index) => ({
name: labels[key] || formatter(key),
value: Number(value || 0),
display: `${Number(value || 0)}`,
color: colors[key] || fallbackColors[index % fallbackColors.length]
}))
}
function formatRiskSignalName(value) {
return formatRiskSignalLabel(value)
}
function isMissingDimension(value) {
const text = String(value || '').trim()
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
}
const bottlenecks = financeBottlenecks
const budgetSummary = financeBudgetSummary
const budgetMetrics = financeBudgetMetrics
const spendByCategory = financeSpendByCategory
const exceptionMix = financeExceptionMix
return {
activeRangeLabel,
activeTrend,
bottlenecks,
budgetMetrics,
budgetSummary,
departmentEmployeeCenterValue,
departmentEmployeeLegend,
digitalEmployeeCategoryRows,
digitalEmployeeDashboard,
digitalEmployeeDashboardError,
digitalEmployeeDashboardLoaded,
digitalEmployeeDashboardLoading,
digitalEmployeeDailyRows,
digitalEmployeeKpiMetrics,
digitalEmployeeTaskRanking,
exceptionMix,
financeDashboardError,
financeDashboardLoaded,
financeDashboardLoading,
financeDashboardRenderKey,
formatCompact,
formatCurrency,
formatMetricValue,
formatNumberCompact,
formatSystemMetricValue,
kpiMetrics,
metricBlueprints,
rankedDepartments,
rankedEmployees,
riskDashboard,
riskDashboardError,
riskDashboardLoaded,
riskDashboardLastUpdatedAt,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskCompositionLegend,
riskTotal,
spendByCategory,
spendCenterValue,
spendLegend,
spendTotal,
systemDashboardTotals,
systemDashboardError,
systemDashboardLoaded,
systemDashboardLoading,
systemAgentDailyRatio,
systemLoginWave,
systemTokenDailyWave,
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
systemAccuracyComparison,
systemExecutionLegend,
systemExecutionMix,
systemExecutionTotal,
systemFeedbackSummary,
systemKpiMetrics,
systemLoadHeatmap,
systemExecutionRows,
systemMetricBlueprints,
systemModelUsageRows,
systemToolDetailItems,
systemToolCallLegend,
systemToolCallMix,
systemToolRankingItems,
systemToolRankings,
systemToolTotal,
systemTrendSeries,
topClaims
}
}