feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -29,7 +29,10 @@ import { resolveCssColor, useThemeColors } from '../../composables/useThemeColor
|
||||
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true }
|
||||
items: { type: Array, required: true },
|
||||
valuePrefix: { type: String, default: '¥' },
|
||||
valueSuffix: { type: String, default: '' },
|
||||
compact: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
@@ -51,7 +54,13 @@ const ariaLabel = computed(() =>
|
||||
)
|
||||
|
||||
const chartMaxValue = computed(() => Math.max(...resolvedItems.value.map((item) => item.value), 1))
|
||||
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / 10000) * 10000)
|
||||
const axisStep = computed(() => {
|
||||
if (chartMaxValue.value <= 100) return 10
|
||||
if (chartMaxValue.value <= 1000) return 100
|
||||
if (chartMaxValue.value <= 10000) return 1000
|
||||
return 10000
|
||||
})
|
||||
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / axisStep.value) * axisStep.value)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
@@ -115,7 +124,7 @@ const chartOptions = computed(() => ({
|
||||
value: item.value,
|
||||
itemStyle: { color: item.resolvedColor }
|
||||
})),
|
||||
barWidth: 14,
|
||||
barWidth: 18,
|
||||
showBackground: true,
|
||||
backgroundStyle: {
|
||||
color: 'rgba(226, 232, 240, 0.42)',
|
||||
@@ -155,9 +164,11 @@ const medalFill = (idx) => {
|
||||
|
||||
const formatValue = (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 `¥${number}`
|
||||
const prefix = props.valuePrefix
|
||||
const suffix = props.valueSuffix
|
||||
if (props.compact && number >= 1_000_000) return `${prefix}${(number / 1_000_000).toFixed(1)}M${suffix}`
|
||||
if (props.compact && number >= 1_000) return `${prefix}${(number / 1_000).toFixed(1)}K${suffix}`
|
||||
return `${prefix}${number}${suffix}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -180,7 +191,7 @@ const formatValue = (value) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
height: 38px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div class="gauge-chart">
|
||||
<div class="gauge-body">
|
||||
<div ref="chartElement" class="gauge-canvas" role="img" :aria-label="ariaLabel"></div>
|
||||
<div class="gauge-center-value" aria-hidden="true">
|
||||
<strong>{{ normalizedRatio }}%</strong>
|
||||
<span>已执行</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gauge-summary">
|
||||
<div>
|
||||
@@ -82,22 +86,8 @@ const chartOptions = computed(() => {
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
anchor: { show: false },
|
||||
detail: {
|
||||
show: true,
|
||||
valueAnimation: true,
|
||||
offsetCenter: [0, '22%'],
|
||||
formatter: '{value}%',
|
||||
color: primary,
|
||||
fontSize: 24,
|
||||
fontWeight: 850
|
||||
},
|
||||
title: {
|
||||
show: true,
|
||||
offsetCenter: [0, '46%'],
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
detail: { show: false },
|
||||
title: { show: false },
|
||||
data: [{ value: normalizedRatio.value, name: '已执行' }]
|
||||
}
|
||||
]
|
||||
@@ -127,6 +117,36 @@ useEcharts(chartElement, chartOptions)
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gauge-center-value {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 60%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gauge-center-value strong {
|
||||
color: var(--theme-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.gauge-center-value span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gauge-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
143
web/src/components/charts/RiskDailyTrendChart.vue
Normal file
143
web/src/components/charts/RiskDailyTrendChart.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div ref="chartElement" class="risk-daily-trend-chart" role="img" :aria-label="ariaLabel"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
EChartsBarChart,
|
||||
EChartsLineChart,
|
||||
CanvasRenderer
|
||||
])
|
||||
|
||||
const props = defineProps({
|
||||
rows: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const labels = computed(() => props.rows.map((item) => item.date))
|
||||
const totals = computed(() => props.rows.map((item) => Number(item.total || 0)))
|
||||
const highValues = computed(() => props.rows.map((item) => Number(item.highOrAbove || 0)))
|
||||
const maxValue = computed(() => Math.max(...totals.value, ...highValues.value, 1))
|
||||
const axisMax = computed(() => Math.max(5, Math.ceil(maxValue.value * 1.2)))
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.rows.map((item) => (
|
||||
`${item.date}风险观察${item.total || 0}项,高风险${item.highOrAbove || 0}项`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 900,
|
||||
animationDurationUpdate: 700,
|
||||
grid: {
|
||||
top: 34,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
left: 28,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 4,
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255,255,255,.98)',
|
||||
borderColor: 'rgba(148,163,184,.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels.value,
|
||||
boundaryGap: true,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148,163,184,.26)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: axisMax.value,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226,232,240,.74)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '风险观察',
|
||||
type: 'bar',
|
||||
data: totals.value,
|
||||
barWidth: 14,
|
||||
itemStyle: {
|
||||
color: themeColors.value.chartPrimary,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '高风险',
|
||||
type: 'line',
|
||||
data: highValues.value,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: '#ef4444'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: '#ef4444',
|
||||
borderWidth: 2.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-daily-trend-chart {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
</style>
|
||||
146
web/src/components/charts/SystemAccuracyCompareBar.vue
Normal file
146
web/src/components/charts/SystemAccuracyCompareBar.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="system-accuracy-compare-bar">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
categories: { type: Array, required: true },
|
||||
correct: { type: Array, required: true },
|
||||
wrong: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const ariaLabel = computed(() =>
|
||||
props.categories.map((name, index) => (
|
||||
`${name}正确${props.correct[index] || 0}次,错误${props.wrong[index] || 0}次`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 900,
|
||||
animationEasing: 'cubicOut',
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
itemWidth: 9,
|
||||
itemHeight: 9,
|
||||
itemGap: 18,
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 38,
|
||||
right: 42,
|
||||
bottom: 18,
|
||||
left: 88,
|
||||
containLabel: false
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
valueFormatter: (value) => `${value} 次`
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.74)' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.categories,
|
||||
inverse: true,
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 750
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '正确',
|
||||
type: 'bar',
|
||||
data: props.correct,
|
||||
barWidth: 18,
|
||||
itemStyle: {
|
||||
color: themeColors.value.success,
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
fontWeight: 800
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '错误',
|
||||
type: 'bar',
|
||||
data: props.wrong,
|
||||
barWidth: 18,
|
||||
itemStyle: {
|
||||
color: themeColors.value.danger,
|
||||
borderRadius: [0, 4, 4, 0]
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#ef4444',
|
||||
fontSize: 11,
|
||||
fontWeight: 800
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-accuracy-compare-bar {
|
||||
height: 292px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
135
web/src/components/charts/SystemAgentRatioBar.vue
Normal file
135
web/src/components/charts/SystemAgentRatioBar.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="system-agent-ratio-bar">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
agents: { type: Array, required: true },
|
||||
series: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const resolvedAgents = computed(() =>
|
||||
props.agents.map((agent) => ({
|
||||
...agent,
|
||||
resolvedColor: resolveCssColor(agent.color, themeColors.value.chartPrimary)
|
||||
}))
|
||||
)
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, dayIndex) => {
|
||||
const parts = resolvedAgents.value.map((agent) => (
|
||||
`${agent.name}${props.series[agent.key]?.[dayIndex] || 0}%`
|
||||
))
|
||||
return `${label}${parts.join(',')}`
|
||||
}).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 900,
|
||||
animationEasing: 'cubicOut',
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 14,
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 38,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
left: 34,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
valueFormatter: (value) => `${value}%`
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitNumber: 5,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
formatter: '{value}%'
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
},
|
||||
series: resolvedAgents.value.map((agent, index) => ({
|
||||
name: agent.name,
|
||||
type: 'bar',
|
||||
stack: 'agentRatio',
|
||||
data: props.series[agent.key] || [],
|
||||
barWidth: 34,
|
||||
emphasis: { focus: 'series' },
|
||||
itemStyle: {
|
||||
color: agent.resolvedColor,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: index === resolvedAgents.value.length - 1 ? 0 : 1,
|
||||
borderRadius: index === resolvedAgents.value.length - 1 ? [4, 4, 0, 0] : 0
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-agent-ratio-bar {
|
||||
height: 292px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
141
web/src/components/charts/SystemLoadHeatmap.vue
Normal file
141
web/src/components/charts/SystemLoadHeatmap.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="system-load-heatmap">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { HeatmapChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, TooltipComponent, VisualMapComponent, HeatmapChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
hours: { type: Array, required: true },
|
||||
tools: { type: Array, required: true },
|
||||
values: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const maxValue = computed(() => Math.max(...props.values.map((item) => Number(item[2] || 0)), 1))
|
||||
const ariaLabel = computed(() =>
|
||||
props.values.map(([hourIndex, toolIndex, value]) => (
|
||||
`${props.hours[hourIndex] || ''}${props.tools[toolIndex] || ''}${value || 0}次`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 760,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 18,
|
||||
bottom: 18,
|
||||
left: 78,
|
||||
containLabel: false
|
||||
},
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
formatter: (params) => {
|
||||
const [hourIndex, toolIndex, value] = params.value || []
|
||||
return `${params.marker}${props.tools[toolIndex] || ''}<br/>${props.hours[hourIndex] || ''}: ${value || 0} 次`
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: maxValue.value,
|
||||
inRange: {
|
||||
color: [
|
||||
'#eef6fb',
|
||||
'#d7e9f3',
|
||||
themeColors.value.chartPrimary,
|
||||
themeColors.value.primaryActive
|
||||
]
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.hours,
|
||||
splitArea: { show: true },
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.tools,
|
||||
splitArea: { show: true },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 750
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '调用热力',
|
||||
type: 'heatmap',
|
||||
data: props.values,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#ffffff',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
formatter: ({ value }) => value?.[2] || 0
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
borderRadius: 4
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: themeColors.value.primaryActive,
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-load-heatmap {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
197
web/src/components/charts/SystemLoginWaveChart.vue
Normal file
197
web/src/components/charts/SystemLoginWaveChart.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="system-login-wave-chart" :class="{ 'is-compact': compact }">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { LineChart as EChartsLineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, LegendComponent, TooltipComponent, EChartsLineChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
loginUsers: { type: Array, required: true },
|
||||
interactions: { type: Array, required: true },
|
||||
compact: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const chartColors = computed(() => ({
|
||||
primary: themeColors.value.chartPrimary,
|
||||
blue: themeColors.value.chartBlue
|
||||
}))
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}次`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 950,
|
||||
animationEasing: 'cubicOut',
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
itemWidth: 18,
|
||||
itemHeight: 8,
|
||||
itemGap: 16,
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 38,
|
||||
right: 36,
|
||||
bottom: 24,
|
||||
left: 32,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
boundaryGap: false,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '登录',
|
||||
min: 0,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 11, fontWeight: 700 },
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.72)' } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '互动',
|
||||
min: 0,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 11, fontWeight: 700 },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '登录人数',
|
||||
type: 'line',
|
||||
smooth: 0.42,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
data: props.loginUsers,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: chartColors.value.primary
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.primary,
|
||||
borderWidth: 2.5
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: toRgba(chartColors.value.primary, 0.18) },
|
||||
{ offset: 1, color: toRgba(chartColors.value.primary, 0.02) }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '互动次数',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: 0.5,
|
||||
symbol: 'emptyCircle',
|
||||
symbolSize: 6,
|
||||
data: props.interactions,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: chartColors.value.blue,
|
||||
type: 'dashed'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.blue,
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function toRgba(color, alpha) {
|
||||
const normalized = String(color || '').trim()
|
||||
const hex = normalized.replace('#', '')
|
||||
if (/^[\da-f]{6}$/i.test(hex)) {
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
return `rgba(58, 124, 165, ${alpha})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-login-wave-chart {
|
||||
height: 292px;
|
||||
}
|
||||
|
||||
.system-login-wave-chart.is-compact {
|
||||
height: 188px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
165
web/src/components/charts/SystemTokenDailyWaveChart.vue
Normal file
165
web/src/components/charts/SystemTokenDailyWaveChart.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="system-token-daily-wave-chart">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
inputTokens: { type: Array, required: true },
|
||||
outputTokens: { type: Array, required: true },
|
||||
totalTokens: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const chartColors = computed(() => ({
|
||||
amber: themeColors.value.chartAmber,
|
||||
purple: themeColors.value.chartPurple,
|
||||
danger: themeColors.value.chartDanger
|
||||
}))
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
`${label}输入${formatTokens(props.inputTokens[index])},输出${formatTokens(props.outputTokens[index])},合计${formatTokens(props.totalTokens[index])}`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 920,
|
||||
animationEasing: 'cubicOut',
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
itemWidth: 9,
|
||||
itemHeight: 9,
|
||||
itemGap: 16,
|
||||
textStyle: {
|
||||
color: '#475569',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 38,
|
||||
right: 22,
|
||||
bottom: 24,
|
||||
left: 34,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
valueFormatter: (value) => `${formatTokens(value)} tokens`
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
formatter: (value) => `${Math.round(value / 1000)}K`
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.74)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '输入 Tokens',
|
||||
type: 'bar',
|
||||
stack: 'tokens',
|
||||
data: props.inputTokens,
|
||||
barWidth: 24,
|
||||
itemStyle: {
|
||||
color: chartColors.value.amber,
|
||||
borderRadius: [0, 0, 3, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '输出 Tokens',
|
||||
type: 'bar',
|
||||
stack: 'tokens',
|
||||
data: props.outputTokens,
|
||||
barWidth: 24,
|
||||
itemStyle: {
|
||||
color: chartColors.value.purple,
|
||||
borderRadius: [3, 3, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '合计波动',
|
||||
type: 'line',
|
||||
data: props.totalTokens,
|
||||
smooth: 0.45,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.8,
|
||||
color: chartColors.value.danger
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.danger,
|
||||
borderWidth: 2.4
|
||||
},
|
||||
emphasis: { focus: 'series' }
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function formatTokens(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)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-token-daily-wave-chart {
|
||||
height: 292px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
113
web/src/components/charts/SystemTokenTreemap.vue
Normal file
113
web/src/components/charts/SystemTokenTreemap.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="system-token-treemap">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { TreemapChart } from 'echarts/charts'
|
||||
import { TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([TooltipComponent, TreemapChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const normalizedItems = computed(() =>
|
||||
props.items.map((item) => ({
|
||||
...item,
|
||||
value: Number(item.tokens || item.value || 0),
|
||||
resolvedColor: resolveCssColor(item.color, themeColors.value.chartPrimary)
|
||||
}))
|
||||
)
|
||||
const ariaLabel = computed(() =>
|
||||
normalizedItems.value.map((item) => `${item.name}${formatTokens(item.value)},占比${item.share}%`).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 760,
|
||||
animationEasing: 'cubicOut',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
formatter: (params) => `${params.marker}${params.name}<br/>${formatTokens(params.value)} · ${params.data?.share || 0}%`
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'treemap',
|
||||
roam: false,
|
||||
nodeClick: false,
|
||||
breadcrumb: { show: false },
|
||||
top: 4,
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
visibleMin: 300,
|
||||
leafDepth: 1,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
lineHeight: 16,
|
||||
formatter: (params) => `${params.name}\n${formatTokens(params.value)}`
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
borderRadius: 4,
|
||||
gapWidth: 3
|
||||
},
|
||||
upperLabel: { show: false },
|
||||
data: normalizedItems.value.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
share: item.share,
|
||||
itemStyle: { color: item.resolvedColor }
|
||||
}))
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function formatTokens(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M tokens`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K tokens`
|
||||
return `${Math.round(number)} tokens`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-token-treemap {
|
||||
height: 268px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
245
web/src/components/charts/SystemTrendChart.vue
Normal file
245
web/src/components/charts/SystemTrendChart.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="system-trend-chart">
|
||||
<div class="chart-legend">
|
||||
<span><i :style="{ background: chartColors.primary }"></i>工具调用(次)</span>
|
||||
<span><i :style="{ background: chartColors.blue }"></i>Token 消耗(K)</span>
|
||||
<span><i :style="{ background: chartColors.purple }"></i>在线人数</span>
|
||||
<span><i :style="{ background: chartColors.amber }"></i>平均在线时长(分钟)</span>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
toolCalls: { type: Array, required: true },
|
||||
tokens: { type: Array, required: true },
|
||||
onlineUsers: { type: Array, required: true },
|
||||
onlineMinutes: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const chartColors = computed(() => ({
|
||||
primary: themeColors.value.chartPrimary,
|
||||
blue: themeColors.value.chartBlue,
|
||||
purple: themeColors.value.chartPurple,
|
||||
amber: themeColors.value.chartAmber
|
||||
}))
|
||||
|
||||
const tokenInK = computed(() => props.tokens.map((value) => Math.round(Number(value || 0) / 100) / 10))
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
`${label}工具调用${props.toolCalls[index] || 0}次,Token消耗${tokenInK.value[index] || 0}K,在线${props.onlineUsers[index] || 0}人,平均在线${props.onlineMinutes[index] || 0}分钟`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 1200,
|
||||
animationDurationUpdate: 1200,
|
||||
animationEasing: 'linear',
|
||||
animationEasingUpdate: 'linear',
|
||||
grid: {
|
||||
top: 18,
|
||||
right: 42,
|
||||
bottom: 22,
|
||||
left: 38,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.labels,
|
||||
boundaryGap: true,
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 360,
|
||||
splitNumber: 6,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 90,
|
||||
splitNumber: 6,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { show: false }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '工具调用(次)',
|
||||
type: 'bar',
|
||||
data: props.toolCalls,
|
||||
barWidth: 14,
|
||||
itemStyle: {
|
||||
color: chartColors.value.primary,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Token 消耗(K)',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: tokenInK.value,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: chartColors.value.blue
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.blue,
|
||||
borderWidth: 2.5
|
||||
},
|
||||
areaStyle: {
|
||||
color: buildAreaColor(chartColors.value.blue, 0.11)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '在线人数',
|
||||
type: 'line',
|
||||
data: props.onlineUsers,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: chartColors.value.purple
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.purple,
|
||||
borderWidth: 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '平均在线时长(分钟)',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: props.onlineMinutes,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: chartColors.value.amber
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.amber,
|
||||
borderWidth: 2.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function buildAreaColor(color, alpha) {
|
||||
return {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: toRgba(color, alpha) },
|
||||
{ offset: 1, color: toRgba(color, 0.02) }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function toRgba(color, alpha) {
|
||||
const normalized = String(color || '').trim()
|
||||
const hex = normalized.replace('#', '')
|
||||
if (/^[\da-f]{6}$/i.test(hex)) {
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
return `rgba(58, 124, 165, ${alpha})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-trend-chart {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-legend i {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
199
web/src/components/charts/SystemUserTokenPie.vue
Normal file
199
web/src/components/charts/SystemUserTokenPie.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="system-user-token-pie">
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
<div class="token-user-list">
|
||||
<div v-for="item in resolvedItems" :key="item.name" class="token-user-row">
|
||||
<i :style="{ background: item.resolvedColor }"></i>
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.role }}</span>
|
||||
</div>
|
||||
<em>{{ formatTokens(item.tokens) }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
import { useEcharts } from '../../composables/useEcharts.js'
|
||||
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
|
||||
|
||||
use([TooltipComponent, PieChart, CanvasRenderer])
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const totalTokens = computed(() =>
|
||||
props.items.reduce((sum, item) => sum + Number(item.tokens || 0), 0)
|
||||
)
|
||||
const resolvedItems = computed(() =>
|
||||
props.items.map((item) => ({
|
||||
...item,
|
||||
tokens: Number(item.tokens || 0),
|
||||
resolvedColor: resolveCssColor(item.color, themeColors.value.chartPrimary)
|
||||
}))
|
||||
)
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
resolvedItems.value.map((item) => (
|
||||
`${item.name}${formatTokens(item.tokens)},占比${getShare(item.tokens)}%`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 900,
|
||||
animationEasing: 'cubicOut',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
appendToBody: true,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
borderColor: 'rgba(148, 163, 184, 0.24)',
|
||||
borderWidth: 1,
|
||||
padding: [9, 10],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
formatter: (params) => `${params.marker}${params.name}<br/>${formatTokens(params.value)} · ${params.percent}%`
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: [0, '82%'],
|
||||
center: ['47%', '50%'],
|
||||
roseType: 'radius',
|
||||
minAngle: 8,
|
||||
avoidLabelOverlap: true,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#475569',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
formatter: '{b}\n{d}%'
|
||||
},
|
||||
labelLine: {
|
||||
length: 10,
|
||||
length2: 6,
|
||||
lineStyle: { color: 'rgba(148, 163, 184, 0.55)' }
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
borderRadius: 4
|
||||
},
|
||||
emphasis: {
|
||||
scale: true,
|
||||
scaleSize: 4
|
||||
},
|
||||
data: resolvedItems.value.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.tokens,
|
||||
itemStyle: { color: item.resolvedColor }
|
||||
}))
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
useEcharts(chartElement, chartOptions)
|
||||
|
||||
function getShare(value) {
|
||||
if (!totalTokens.value) return 0
|
||||
return Math.round((Number(value || 0) / totalTokens.value) * 1000) / 10
|
||||
}
|
||||
|
||||
function formatTokens(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)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-user-token-pie {
|
||||
min-height: 292px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 188px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 292px;
|
||||
}
|
||||
|
||||
.token-user-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.token-user-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.token-user-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.token-user-row i {
|
||||
width: 8px;
|
||||
height: 22px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.token-user-row strong,
|
||||
.token-user-row span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-user-row strong {
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.token-user-row span {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.token-user-row em {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.system-user-token-pie {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user