From 474f86af5418af4cc18a8e731447f296d38dd8fe Mon Sep 17 00:00:00 2001 From: minecraft1024a Date: Sun, 30 Nov 2025 13:53:53 +0800 Subject: [PATCH] feat(report): migrate charting library from Chart.js to ECharts This commit replaces the Chart.js library with Apache ECharts for all charts in the statistics report page. The migration aims to significantly enhance chart interactivity, visual appeal, and performance, especially with larger datasets. Key improvements include: - **Enhanced Interactivity**: Implemented data zooming and panning (`dataZoom`) for all time-series and bar charts, allowing for detailed data exploration. - **Improved Visuals**: Leveraged ECharts' capabilities to introduce smoother lines, gradient area fills, better animations, and a more modern aesthetic. - **Smart Legends**: Legends now automatically become scrollable when there are too many items, resolving UI clutter and improving readability. - **Performance Optimization**: Optimized charts like the model cost bar chart and response time scatter plot to handle large numbers of data points smoothly. - **Responsive Resizing**: Reworked the chart resizing logic to ensure all charts correctly and smoothly redraw when the window size changes. --- src/chat/utils/templates/charts_tab.html | 16 +- src/chat/utils/templates/report.css | 7 + src/chat/utils/templates/report.html | 2 +- src/chat/utils/templates/report.js | 1773 ++++++++++--------- src/chat/utils/templates/static_charts.html | 32 +- 5 files changed, 916 insertions(+), 914 deletions(-) diff --git a/src/chat/utils/templates/charts_tab.html b/src/chat/utils/templates/charts_tab.html index d1b2ac686..e078f37a2 100644 --- a/src/chat/utils/templates/charts_tab.html +++ b/src/chat/utils/templates/charts_tab.html @@ -17,17 +17,17 @@
-
- +
+
-
- +
+
-
- +
+
-
- +
+
\ No newline at end of file diff --git a/src/chat/utils/templates/report.css b/src/chat/utils/templates/report.css index 7cd676416..b9fd0220f 100644 --- a/src/chat/utils/templates/report.css +++ b/src/chat/utils/templates/report.css @@ -216,6 +216,13 @@ h1 { position: relative; height: auto; min-height: 350px; + max-height: 600px; + overflow: hidden; +} + +.chart-container > div, .chart-wrapper > div { + max-width: 100%; + max-height: 100%; } .chart-container h3, .chart-wrapper h3 { diff --git a/src/chat/utils/templates/report.html b/src/chat/utils/templates/report.html index 67279d6ed..6fa4cacda 100644 --- a/src/chat/utils/templates/report.html +++ b/src/chat/utils/templates/report.html @@ -8,7 +8,7 @@ - + diff --git a/src/chat/utils/templates/report.js b/src/chat/utils/templates/report.js index e517e5513..6481bf258 100644 --- a/src/chat/utils/templates/report.js +++ b/src/chat/utils/templates/report.js @@ -6,6 +6,8 @@ if (tab_links.length > 0) tab_links[0].classList.add("active"); // 跟踪哪些tab的图表已经初始化 const initializedTabs = new Set(); +// 存储ECharts实例以便销毁和resize +const chartInstances = {}; // 存储初始化函数的引用,以便在showTab中调用 let initializeStaticChartsForPeriod = null; @@ -30,9 +32,30 @@ function showTab(evt, tabName) { } initializedTabs.add(tabName); } + + // Resize当前tab的图表以确保正确显示 + setTimeout(() => { + Object.values(chartInstances).forEach(chart => { + if (chart && chart.resize) chart.resize(); + }); + }, 100); } +// 窗口resize时调整所有图表 +window.addEventListener('resize', function() { + Object.values(chartInstances).forEach(chart => { + if (chart && chart.resize) chart.resize(); + }); +}); + document.addEventListener('DOMContentLoaded', function () { + // ECharts 通用配色 + const colors = [ + '#2563eb', '#3b82f6', '#60a5fa', '#0891b2', '#06b6d4', + '#059669', '#10b981', '#7c3aed', '#8b5cf6', '#ec4899', + '#f97316', '#eab308', '#84cc16', '#14b8a6', '#6366f1' + ]; + // Chart data is injected by python via the HTML template. let allChartData = null; function getAllChartData() { @@ -47,12 +70,11 @@ document.addEventListener('DOMContentLoaded', function () { return allChartData || {}; } - let currentCharts = {}; const chartConfigs = { - totalCost: { id: 'totalCostChart', title: '总花费趋势', yAxisLabel: '花费 (¥)', dataKey: 'total_cost_data', fill: true }, - costByModule: { id: 'costByModuleChart', title: '各模块花费对比', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_module', fill: false }, - costByModel: { id: 'costByModelChart', title: '各模型花费对比', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_model', fill: false }, - messageByChat: { id: 'messageByChatChart', title: '各聊天流消息统计', yAxisLabel: '消息数', dataKey: 'message_by_chat', fill: false } + totalCost: { id: 'totalCostChart', title: '总花费趋势', yAxisLabel: '花费 (¥)', dataKey: 'total_cost_data' }, + costByModule: { id: 'costByModuleChart', title: '各模块花费对比', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_module' }, + costByModel: { id: 'costByModelChart', title: '各模型花费对比', yAxisLabel: '花费 (¥)', dataKey: 'cost_by_model' }, + messageByChat: { id: 'messageByChatChart', title: '各聊天流消息统计', yAxisLabel: '消息数', dataKey: 'message_by_chat' } }; window.switchTimeRange = function(timeRange) { @@ -65,8 +87,6 @@ document.addEventListener('DOMContentLoaded', function () { } function updateAllCharts(data, timeRange) { - Object.values(currentCharts).forEach(chart => chart && chart.destroy()); - currentCharts = {}; Object.keys(chartConfigs).forEach(type => createChart(type, data, timeRange)); } @@ -74,138 +94,159 @@ document.addEventListener('DOMContentLoaded', function () { const config = chartConfigs[chartType]; if (!data || !data[config.dataKey]) return; - // Modern Theme Colors - const colors = [ - '#2563eb', '#3b82f6', '#60a5fa', '#93c5fd', // Blue - '#0891b2', '#06b6d4', '#22d3ee', '#67e8f9', // Cyan - '#059669', '#10b981', '#34d399', '#6ee7b7', // Emerald - '#7c3aed', '#8b5cf6', '#a78bfa', '#c4b5fd' // Violet - ]; + const container = document.getElementById(config.id); + if (!container) return; + + // 销毁已存在的实例 + if (chartInstances[config.id]) { + chartInstances[config.id].dispose(); + } + + const chart = echarts.init(container); + chartInstances[config.id] = chart; + + let series = []; + let legendData = []; - let datasets = []; if (chartType === 'totalCost') { - datasets = [{ - label: config.title, - data: data[config.dataKey], - borderColor: '#2563eb', - backgroundColor: 'rgba(37, 99, 235, 0.1)', - tension: 0.4, - fill: config.fill, - borderWidth: 2, - pointRadius: 0, - pointHoverRadius: 6, - pointBackgroundColor: '#2563eb', - pointBorderColor: '#fff', - pointBorderWidth: 2 + series = [{ + name: config.title, + type: 'line', + data: data[config.dataKey], + smooth: 0.4, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(37, 99, 235, 0.3)' }, + { offset: 1, color: 'rgba(37, 99, 235, 0.05)' } + ]) + }, + lineStyle: { width: 2, color: '#2563eb' }, + itemStyle: { color: '#2563eb' }, + showSymbol: false, + emphasis: { focus: 'series' } }]; } else { let i = 0; Object.entries(data[config.dataKey]).forEach(([name, chartData]) => { - datasets.push({ - label: name, - data: chartData, - borderColor: colors[i % colors.length], - backgroundColor: colors[i % colors.length] + '20', - tension: 0.4, - fill: config.fill, - borderWidth: 2, - pointRadius: 0, - pointHoverRadius: 6, - pointBackgroundColor: colors[i % colors.length], - pointBorderColor: '#fff', - pointBorderWidth: 2 + legendData.push(name); + series.push({ + name: name, + type: 'line', + data: chartData, + smooth: 0.4, + lineStyle: { width: 2, color: colors[i % colors.length] }, + itemStyle: { color: colors[i % colors.length] }, + showSymbol: false, + emphasis: { focus: 'series' } }); i++; }); } - const canvas = document.getElementById(config.id); - if (!canvas) return; - // Destroy existing chart if any - if (currentCharts[chartType]) { - currentCharts[chartType].destroy(); - } - - currentCharts[chartType] = new Chart(canvas, { - type: 'line', - data: { labels: data.time_labels, datasets: datasets }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: true, - text: `${config.title}`, - font: { size: 16, weight: '600', family: "'Inter', sans-serif" }, - color: '#0f172a', - padding: { top: 10, bottom: 20 }, - align: 'start' - }, - legend: { - display: chartType !== 'totalCost', - position: 'top', - align: 'end', - labels: { - usePointStyle: true, - padding: 20, - font: { size: 12, family: "'Inter', sans-serif" }, - boxWidth: 8, - boxHeight: 8 - } - }, - tooltip: { - backgroundColor: '#ffffff', - titleColor: '#0f172a', - bodyColor: '#475569', - borderColor: '#e2e8f0', - borderWidth: 1, - padding: 12, - titleFont: { size: 13, weight: '600' }, - bodyFont: { size: 12 }, - cornerRadius: 8, - displayColors: true, - boxPadding: 4 - } - }, - scales: { - x: { - grid: { display: false }, - ticks: { - maxTicksLimit: 8, - color: '#94a3b8', - font: { size: 11 } - }, - border: { display: false } - }, - y: { - title: { - display: true, - text: config.yAxisLabel, - color: '#94a3b8', - font: { size: 11, weight: '500' } - }, - beginAtZero: true, - grid: { - color: '#f1f5f9', - borderDash: [4, 4] - }, - ticks: { - color: '#94a3b8', - font: { size: 11 } - }, - border: { display: false } - } - }, - interaction: { - intersect: false, - mode: 'index' - }, - animation: { - duration: 800, - easing: 'easeOutQuart' + // 动态计算图例和布局 + const hasLegend = chartType !== 'totalCost'; + const legendItemCount = legendData.length; + const needsScrollLegend = legendItemCount > 5; + + const option = { + title: { + text: config.title, + left: 'left', + textStyle: { + fontSize: 16, + fontWeight: 600, + fontFamily: "'Inter', sans-serif", + color: '#0f172a' } - } - }); + }, + tooltip: { + trigger: 'axis', + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + borderWidth: 1, + padding: 12, + textStyle: { color: '#475569', fontSize: 12 }, + axisPointer: { type: 'cross', crossStyle: { color: '#999' } }, + confine: true // 防止tooltip溢出容器 + }, + legend: { + show: hasLegend, + data: legendData, + type: 'scroll', + orient: needsScrollLegend ? 'vertical' : 'horizontal', + right: needsScrollLegend ? 10 : 'center', + top: needsScrollLegend ? 50 : 35, + left: needsScrollLegend ? 'auto' : 'center', + width: needsScrollLegend ? '20%' : 'auto', + icon: 'circle', + itemWidth: 8, + itemHeight: 8, + textStyle: { + fontSize: 11, + width: needsScrollLegend ? 80 : 'auto', + overflow: 'truncate', + ellipsis: '...' + }, + pageButtonItemGap: 5, + pageButtonGap: 5, + pageIconColor: '#2563eb', + pageIconInactiveColor: '#aaa', + pageTextStyle: { fontSize: 10 }, + formatter: function(name) { + return name.length > 15 ? name.substring(0, 15) + '...' : name; + }, + tooltip: { show: true } // 鼠标悬停显示完整名称 + }, + grid: { + left: '3%', + right: needsScrollLegend && hasLegend ? '22%' : '4%', + bottom: '12%', + top: chartType === 'totalCost' ? 60 : (needsScrollLegend ? 60 : 80), + containLabel: true + }, + dataZoom: [ + { + type: 'inside', + xAxisIndex: 0, + filterMode: 'none', + zoomOnMouseWheel: 'shift', // 按shift滚轮缩放 + moveOnMouseMove: true + }, + { + type: 'slider', + xAxisIndex: 0, + height: 20, + bottom: 5, + handleSize: '100%', + showDetail: false, + brushSelect: false + } + ], + xAxis: { + type: 'category', + data: data.time_labels, + boundaryGap: false, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: '#94a3b8', fontSize: 11 }, + splitLine: { show: false } + }, + yAxis: { + type: 'value', + name: config.yAxisLabel, + nameTextStyle: { color: '#94a3b8', fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: '#94a3b8', fontSize: 11 }, + splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } } + }, + series: series, + animation: true, + animationDuration: 800, + animationEasing: 'cubicOut' + }; + + chart.setOption(option); } // Function to initialize charts tab @@ -213,7 +254,6 @@ document.addEventListener('DOMContentLoaded', function () { const data = getAllChartData(); if (data['24h']) { updateAllCharts(data['24h'], '24h'); - // Activate the 24h button by default document.querySelectorAll('.time-range-btn').forEach(btn => { if (btn.textContent.includes('24小时')) { btn.classList.add('active'); @@ -238,810 +278,781 @@ document.addEventListener('DOMContentLoaded', function () { return staticChartData || {}; } + // ECharts 扩展调色板 + const extendedColors = [ + '#1976D2', '#42A5F5', '#2196F3', '#64B5F6', '#90CAF9', + '#00BCD4', '#26C6DA', '#4DD0E1', '#009688', '#26A69A', + '#4CAF50', '#66BB6A', '#81C784', '#FF9800', '#FFA726', + '#FF5722', '#FF7043', '#9C27B0', '#AB47BC', '#E91E63', + '#EC407A', '#607D8B', '#78909C' + ]; + // 懒加载函数:只初始化指定tab的静态图表 - // 将函数赋值给外部变量,使得showTab可以调用 initializeStaticChartsForPeriod = function(period_id) { const data = getStaticChartData(); if (!data[period_id]) { console.warn(`No static chart data for period: ${period_id}`); return; } + const providerCostData = data[period_id].provider_cost_data; const moduleCostData = data[period_id].module_cost_data; const modelCostData = data[period_id].model_cost_data; - // 扩展的Material Design调色板 - 包含多种蓝色系和其他配色 - const colors = [ - '#1976D2', '#42A5F5', '#2196F3', '#64B5F6', '#90CAF9', '#BBDEFB', // 蓝色系 - '#1565C0', '#0D47A1', '#82B1FF', '#448AFF', // 深蓝色系 - '#00BCD4', '#26C6DA', '#4DD0E1', '#80DEEA', // 青色系 - '#009688', '#26A69A', '#4DB6AC', '#80CBC4', // 青绿色系 - '#4CAF50', '#66BB6A', '#81C784', '#A5D6A7', // 绿色系 - '#FF9800', '#FFA726', '#FFB74D', '#FFCC80', // 橙色系 - '#FF5722', '#FF7043', '#FF8A65', '#FFAB91', // 深橙色系 - '#9C27B0', '#AB47BC', '#BA68C8', '#CE93D8', // 紫色系 - '#E91E63', '#EC407A', '#F06292', '#F48FB1', // 粉色系 - '#607D8B', '#78909C', '#90A4AE', '#B0BEC5' // 蓝灰色系 - ]; - // Provider Cost Pie Chart - const providerCtx = document.getElementById(`providerCostPieChart_${period_id}`); - if (providerCtx && providerCostData && providerCostData.data && providerCostData.data.length > 0) { - new Chart(providerCtx, { - type: 'doughnut', - data: { - labels: providerCostData.labels, - datasets: [{ - label: '按供应商花费', - data: providerCostData.data, - backgroundColor: colors, - borderColor: '#FFFFFF', - borderWidth: 2, - hoverOffset: 8 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - position: 'right', - align: 'center', - labels: { - usePointStyle: true, - padding: 8, - font: { size: 10 }, - boxWidth: 12, - boxHeight: 12, - color: function(context) { - const chart = context.chart; - const meta = chart.getDatasetMeta(0); - const index = context.index; - return meta.data[index] && meta.data[index].hidden ? '#CCCCCC' : '#666666'; - }, - generateLabels: function(chart) { - const data = chart.data; - if (data.labels.length && data.datasets.length) { - const dataset = data.datasets[0]; - const labels = data.labels.slice(0, 10); - return labels.map((label, i) => { - const meta = chart.getDatasetMeta(0); - const style = meta.controller.getStyle(i); - const isHidden = meta.data[i] && meta.data[i].hidden; - return { - text: label.length > 15 ? label.substring(0, 15) + '...' : label, - fillStyle: isHidden ? '#E0E0E0' : style.backgroundColor, - strokeStyle: isHidden ? '#E0E0E0' : style.borderColor, - lineWidth: style.borderWidth, - fontColor: isHidden ? '#CCCCCC' : '#666666', - hidden: isNaN(dataset.data[i]) || isHidden, - index: i - }; - }); - } - return []; - } - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.index; - const chart = legend.chart; - const meta = chart.getDatasetMeta(0); - - // 切换该扇区的可见性 - meta.data[index].hidden = !meta.data[index].hidden; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - titleFont: { size: 13 }, - bodyFont: { size: 12 }, - cornerRadius: 8, - callbacks: { - label: function(context) { - let label = context.label || ''; - if (label) { - label += ': '; - } - label += context.parsed.toFixed(4) + ' ¥'; - const total = context.dataset.data.reduce((a, b) => a + b, 0); - const percentage = ((context.parsed / total) * 100).toFixed(2); - label += ` (${percentage}%)`; - return label; - } - } - } - }, - animation: { - animateRotate: true, - animateScale: true, - duration: 1000, - easing: 'easeInOutQuart' - } - } - }); - } - - // Module Cost Pie Chart - const moduleCtx = document.getElementById(`moduleCostPieChart_${period_id}`); - if (moduleCtx && moduleCostData && moduleCostData.data && moduleCostData.data.length > 0) { - new Chart(moduleCtx, { - type: 'doughnut', - data: { - labels: moduleCostData.labels, - datasets: [{ - label: '按模块花费', - data: moduleCostData.data, - backgroundColor: colors, - borderColor: '#FFFFFF', - borderWidth: 2, - hoverOffset: 8 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - position: 'right', - align: 'center', - labels: { - usePointStyle: true, - padding: 8, - font: { size: 10 }, - boxWidth: 12, - boxHeight: 12, - color: function(context) { - const chart = context.chart; - const meta = chart.getDatasetMeta(0); - const index = context.index; - return meta.data[index] && meta.data[index].hidden ? '#CCCCCC' : '#666666'; - }, - generateLabels: function(chart) { - const data = chart.data; - if (data.labels.length && data.datasets.length) { - const dataset = data.datasets[0]; - return data.labels.map((label, i) => { - const meta = chart.getDatasetMeta(0); - const style = meta.controller.getStyle(i); - const isHidden = meta.data[i] && meta.data[i].hidden; - return { - text: label.length > 15 ? label.substring(0, 15) + '...' : label, - fillStyle: isHidden ? '#E0E0E0' : style.backgroundColor, - strokeStyle: isHidden ? '#E0E0E0' : style.borderColor, - lineWidth: style.borderWidth, - fontColor: isHidden ? '#CCCCCC' : '#666666', - hidden: isNaN(dataset.data[i]) || isHidden, - index: i - }; - }); - } - return []; - } - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.index; - const chart = legend.chart; - const meta = chart.getDatasetMeta(0); - - meta.data[index].hidden = !meta.data[index].hidden; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - titleFont: { size: 13 }, - bodyFont: { size: 12 }, - cornerRadius: 8, - callbacks: { - label: function(context) { - let label = context.label || ''; - if (label) { - label += ': '; - } - label += context.parsed.toFixed(4) + ' ¥'; - const total = context.dataset.data.reduce((a, b) => a + b, 0); - const percentage = ((context.parsed / total) * 100).toFixed(2); - label += ` (${percentage}%)`; - return label; - } - } - } - }, - animation: { - animateRotate: true, - animateScale: true, - duration: 1000, - easing: 'easeInOutQuart' - } - } - }); - } - - // Model Cost Bar Chart - const modelCtx = document.getElementById(`modelCostBarChart_${period_id}`); - if (modelCtx && modelCostData && modelCostData.data && modelCostData.data.length > 0) { - // 动态计算高度:每个条目至少25px - const minHeight = Math.max(250, modelCostData.labels.length * 25); - modelCtx.parentElement.style.minHeight = minHeight + 'px'; + // 1. Provider Cost Pie Chart + const providerContainer = document.getElementById(`providerCostPieChart_${period_id}`); + if (providerContainer && providerCostData && providerCostData.data && providerCostData.data.length > 0) { + if (chartInstances[`providerCostPieChart_${period_id}`]) { + chartInstances[`providerCostPieChart_${period_id}`].dispose(); + } + const chart = echarts.init(providerContainer); + chartInstances[`providerCostPieChart_${period_id}`] = chart; - // 为每个柱子创建单独的数据集以支持单独隐藏 - const datasets = modelCostData.labels.map((label, idx) => ({ - label: label, - data: modelCostData.labels.map((_, i) => i === idx ? modelCostData.data[idx] : null), - backgroundColor: colors[idx % colors.length], - borderColor: colors[idx % colors.length], - borderWidth: 2, - borderRadius: 6, - hoverBackgroundColor: colors[idx % colors.length] + 'dd' + const pieData = providerCostData.labels.map((label, idx) => ({ + name: label, + value: providerCostData.data[idx] })); - new Chart(modelCtx, { - type: 'bar', - data: { - labels: modelCostData.labels, - datasets: datasets - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - display: true, - position: 'top', - align: 'start', - labels: { - usePointStyle: true, - padding: 6, - font: { size: 9 }, - boxWidth: 10, - boxHeight: 10 - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.datasetIndex; - const chart = legend.chart; - const meta = chart.getDatasetMeta(index); - meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - titleFont: { size: 13 }, - bodyFont: { size: 12 }, - cornerRadius: 8, - callbacks: { - label: function(context) { - if (context.parsed.y !== null) { - return context.dataset.label + ': ' + context.parsed.y.toFixed(4) + ' ¥'; - } - return ''; - } - }, - filter: function(tooltipItem) { - return tooltipItem.parsed.y !== null; - } - } - }, - scales: { - x: { - stacked: false, - grid: { display: false }, - ticks: { - font: { size: 9 }, - maxRotation: 45, - minRotation: 0, - callback: function(value, index, ticks) { - const chart = this.chart; - // 检查该索引位置是否有可见的数据 - let hasVisibleData = false; - for (let i = 0; i < chart.data.datasets.length; i++) { - const meta = chart.getDatasetMeta(i); - if (!meta.hidden && chart.data.datasets[i].data[index] !== null) { - hasVisibleData = true; - break; - } - } - // 只显示有可见数据的标签 - return hasVisibleData ? chart.data.labels[index] : ''; - } - } - }, - y: { - beginAtZero: true, - title: { - display: true, - text: '💰 花费 (¥)', - font: { size: 11, weight: 'bold' } - }, - grid: { color: 'rgba(0, 0, 0, 0.05)' }, - ticks: { - font: { size: 10 } - } - } - }, - animation: { - duration: 1000, - easing: 'easeInOutQuart' + const itemCount = pieData.length; + const useBottomLegend = itemCount > 8; + + chart.setOption({ + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + confine: true, + formatter: function(params) { + return `${params.name}
花费: ${params.value.toFixed(4)} ¥
占比: ${params.percent.toFixed(2)}%`; } - } + }, + legend: { + type: 'scroll', + orient: useBottomLegend ? 'horizontal' : 'vertical', + right: useBottomLegend ? 'center' : 10, + top: useBottomLegend ? 'auto' : 'middle', + bottom: useBottomLegend ? 10 : 'auto', + left: useBottomLegend ? 'center' : 'auto', + width: useBottomLegend ? '90%' : '30%', + height: useBottomLegend ? 'auto' : '80%', + icon: 'circle', + itemWidth: 8, + itemHeight: 8, + itemGap: useBottomLegend ? 12 : 8, + textStyle: { + fontSize: 10, + width: useBottomLegend ? 70 : 80, + overflow: 'truncate', + ellipsis: '...' + }, + pageButtonItemGap: 5, + pageButtonGap: 5, + pageIconColor: '#2563eb', + pageIconInactiveColor: '#aaa', + pageTextStyle: { fontSize: 10 }, + tooltip: { show: true } + }, + series: [{ + type: 'pie', + radius: ['35%', '60%'], + center: useBottomLegend ? ['50%', '40%'] : ['35%', '50%'], + avoidLabelOverlap: true, + itemStyle: { + borderColor: '#fff', + borderWidth: 2, + borderRadius: 4 + }, + label: { show: false }, + emphasis: { + label: { show: true, fontSize: 12, fontWeight: 'bold' }, + itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } + }, + data: pieData, + color: extendedColors + }], + animation: true, + animationDuration: 1000 + }); + } + + // 2. Module Cost Pie Chart + const moduleContainer = document.getElementById(`moduleCostPieChart_${period_id}`); + if (moduleContainer && moduleCostData && moduleCostData.data && moduleCostData.data.length > 0) { + if (chartInstances[`moduleCostPieChart_${period_id}`]) { + chartInstances[`moduleCostPieChart_${period_id}`].dispose(); + } + const chart = echarts.init(moduleContainer); + chartInstances[`moduleCostPieChart_${period_id}`] = chart; + + const pieData = moduleCostData.labels.map((label, idx) => ({ + name: label, + value: moduleCostData.data[idx] + })); + + const itemCount = pieData.length; + const useBottomLegend = itemCount > 8; + + chart.setOption({ + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + confine: true, + formatter: function(params) { + return `${params.name}
花费: ${params.value.toFixed(4)} ¥
占比: ${params.percent.toFixed(2)}%`; + } + }, + legend: { + type: 'scroll', + orient: useBottomLegend ? 'horizontal' : 'vertical', + right: useBottomLegend ? 'center' : 10, + top: useBottomLegend ? 'auto' : 'middle', + bottom: useBottomLegend ? 10 : 'auto', + left: useBottomLegend ? 'center' : 'auto', + width: useBottomLegend ? '90%' : '30%', + height: useBottomLegend ? 'auto' : '80%', + icon: 'circle', + itemWidth: 8, + itemHeight: 8, + itemGap: useBottomLegend ? 12 : 8, + textStyle: { + fontSize: 10, + width: useBottomLegend ? 70 : 80, + overflow: 'truncate', + ellipsis: '...' + }, + pageButtonItemGap: 5, + pageButtonGap: 5, + pageIconColor: '#2563eb', + pageIconInactiveColor: '#aaa', + pageTextStyle: { fontSize: 10 }, + tooltip: { show: true } + }, + series: [{ + type: 'pie', + radius: ['35%', '60%'], + center: useBottomLegend ? ['50%', '40%'] : ['35%', '50%'], + avoidLabelOverlap: true, + itemStyle: { + borderColor: '#fff', + borderWidth: 2, + borderRadius: 4 + }, + label: { show: false }, + emphasis: { + label: { show: true, fontSize: 12, fontWeight: 'bold' }, + itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } + }, + data: pieData, + color: extendedColors + }], + animation: true, + animationDuration: 1000 + }); + } + + // 3. Model Cost Bar Chart + const modelContainer = document.getElementById(`modelCostBarChart_${period_id}`); + if (modelContainer && modelCostData && modelCostData.data && modelCostData.data.length > 0) { + if (chartInstances[`modelCostBarChart_${period_id}`]) { + chartInstances[`modelCostBarChart_${period_id}`].dispose(); + } + + // 动态调整高度,限制最大高度并使用滚动 + const itemCount = modelCostData.labels.length; + const needsZoom = itemCount > 15; + const minHeight = needsZoom ? 450 : Math.max(350, itemCount * 25); + modelContainer.style.height = minHeight + 'px'; + + const chart = echarts.init(modelContainer); + chartInstances[`modelCostBarChart_${period_id}`] = chart; + + // 计算显示范围(如果数据太多只显示前15个,其余通过滚动查看) + const displayEnd = needsZoom ? Math.min(100, Math.round(15 / itemCount * 100)) : 100; + + chart.setOption({ + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + axisPointer: { type: 'shadow' }, + confine: true, + formatter: function(params) { + if (params[0]) { + return `${params[0].name}
花费: ${params[0].value.toFixed(4)} ¥`; + } + return ''; + } + }, + grid: { + left: '3%', + right: needsZoom ? '8%' : '4%', + bottom: '3%', + top: 30, + containLabel: true + }, + dataZoom: needsZoom ? [ + { + type: 'slider', + yAxisIndex: 0, + right: 5, + width: 20, + start: 0, + end: displayEnd, + handleSize: '100%', + showDetail: false, + brushSelect: false + }, + { + type: 'inside', + yAxisIndex: 0, + zoomOnMouseWheel: false, + moveOnMouseMove: true, + moveOnMouseWheel: true + } + ] : [], + xAxis: { + type: 'value', + name: '💰 花费 (¥)', + nameTextStyle: { fontSize: 11, fontWeight: 'bold' }, + axisLabel: { fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.05)' } } + }, + yAxis: { + type: 'category', + data: modelCostData.labels, + axisLabel: { + fontSize: 9, + formatter: function(value) { + return value.length > 25 ? value.substring(0, 25) + '...' : value; + } + }, + axisTick: { show: false }, + axisLine: { show: false } + }, + series: [{ + type: 'bar', + data: modelCostData.data.map((value, idx) => ({ + value: value, + itemStyle: { + color: extendedColors[idx % extendedColors.length], + borderRadius: [0, 6, 6, 0] + } + })), + barMaxWidth: 20, + emphasis: { + itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.3)' } + } + }], + animation: true, + animationDuration: 1000, + // 大数据优化 + large: true, + largeThreshold: 100 }); } // === 新增图表 === - // 1. Token使用对比条形图 - const tokenCompData = staticChartData[period_id].token_comparison_data; - const tokenCompCtx = document.getElementById(`tokenComparisonChart_${period_id}`); - if (tokenCompCtx && tokenCompData && tokenCompData.labels && tokenCompData.labels.length > 0) { - // 动态计算高度 - const minHeight = Math.max(270, tokenCompData.labels.length * 30); - tokenCompCtx.parentElement.style.minHeight = minHeight + 'px'; + // 4. Token使用对比条形图 + const tokenCompData = data[period_id].token_comparison_data; + const tokenCompContainer = document.getElementById(`tokenComparisonChart_${period_id}`); + if (tokenCompContainer && tokenCompData && tokenCompData.labels && tokenCompData.labels.length > 0) { + if (chartInstances[`tokenComparisonChart_${period_id}`]) { + chartInstances[`tokenComparisonChart_${period_id}`].dispose(); + } - new Chart(tokenCompCtx, { - type: 'bar', - data: { - labels: tokenCompData.labels, - datasets: [ - { - label: '输入Token', - data: tokenCompData.input_tokens, - backgroundColor: '#FF9800', - borderColor: '#F57C00', - borderWidth: 2, - borderRadius: 6 - }, - { - label: '输出Token', - data: tokenCompData.output_tokens, - backgroundColor: '#4CAF50', - borderColor: '#388E3C', - borderWidth: 2, - borderRadius: 6 - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - position: 'top', - labels: { - usePointStyle: true, - padding: 10, - font: { size: 11 }, - boxWidth: 12, - boxHeight: 12 - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.datasetIndex; - const chart = legend.chart; - const meta = chart.getDatasetMeta(index); - meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - cornerRadius: 8, - callbacks: { - label: function(context) { - const value = context.parsed.y.toLocaleString(); - const total = context.dataset.data.reduce((a, b) => a + b, 0); - const percentage = ((context.parsed.y / total) * 100).toFixed(1); - return context.dataset.label + ': ' + value + ' tokens (' + percentage + '%)'; - } - } - } - }, - scales: { - x: { - grid: { display: false }, - ticks: { - font: { size: 9 }, - maxRotation: 45, - minRotation: 0 - } - }, - y: { - beginAtZero: true, - title: { - display: true, - text: 'Token数量', - font: { size: 11, weight: 'bold' } - }, - grid: { color: 'rgba(0, 0, 0, 0.05)' }, - ticks: { font: { size: 10 } } - } - }, - animation: { duration: 1000, easing: 'easeInOutQuart' }, - interaction: { - mode: 'index', - intersect: false + const itemCount = tokenCompData.labels.length; + const needsZoom = itemCount > 10; + const minHeight = needsZoom ? 400 : Math.max(350, itemCount * 30); + tokenCompContainer.style.height = minHeight + 'px'; + + const chart = echarts.init(tokenCompContainer); + chartInstances[`tokenComparisonChart_${period_id}`] = chart; + + chart.setOption({ + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + axisPointer: { type: 'shadow' }, + confine: true, + formatter: function(params) { + let result = params[0].name + '
'; + params.forEach(p => { + const total = tokenCompData.input_tokens.reduce((a, b) => a + b, 0) + + tokenCompData.output_tokens.reduce((a, b) => a + b, 0); + const pct = total > 0 ? ((p.value / total) * 100).toFixed(1) : '0.0'; + result += `${p.marker} ${p.seriesName}: ${p.value.toLocaleString()} tokens (${pct}%)
`; + }); + return result; } - } + }, + legend: { + data: ['输入Token', '输出Token'], + top: 10, + icon: 'circle', + itemWidth: 10, + itemHeight: 10 + }, + grid: { + left: '3%', + right: '4%', + bottom: needsZoom ? '15%' : '3%', + top: 50, + containLabel: true + }, + dataZoom: needsZoom ? [ + { + type: 'slider', + xAxisIndex: 0, + height: 20, + bottom: 5, + start: 0, + end: Math.min(100, Math.round(10 / itemCount * 100)), + handleSize: '100%', + showDetail: false, + brushSelect: false + }, + { + type: 'inside', + xAxisIndex: 0, + zoomOnMouseWheel: 'shift', + moveOnMouseMove: true + } + ] : [], + xAxis: { + type: 'category', + data: tokenCompData.labels.map(l => l.length > 20 ? l.substring(0, 20) + '...' : l), + axisLabel: { + fontSize: 9, + rotate: itemCount > 6 ? 30 : 0, + interval: 0 + }, + axisTick: { show: false } + }, + yAxis: { + type: 'value', + name: 'Token数量', + nameTextStyle: { fontSize: 11, fontWeight: 'bold' }, + axisLabel: { fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.05)' } } + }, + series: [ + { + name: '输入Token', + type: 'bar', + data: tokenCompData.input_tokens, + itemStyle: { color: '#FF9800', borderRadius: [6, 6, 0, 0] }, + barMaxWidth: 25 + }, + { + name: '输出Token', + type: 'bar', + data: tokenCompData.output_tokens, + itemStyle: { color: '#4CAF50', borderRadius: [6, 6, 0, 0] }, + barMaxWidth: 25 + } + ], + animation: true, + animationDuration: 1000, + large: true, + largeThreshold: 100 }); } - // 2. 供应商请求占比环形图 - const providerReqData = staticChartData[period_id].provider_requests_data; - const providerReqCtx = document.getElementById(`providerRequestsDoughnutChart_${period_id}`); - if (providerReqCtx && providerReqData && providerReqData.data && providerReqData.data.length > 0) { - new Chart(providerReqCtx, { - type: 'doughnut', - data: { - labels: providerReqData.labels, - datasets: [{ - label: '请求数', - data: providerReqData.data, - backgroundColor: ['#9C27B0', '#E91E63', '#F44336', '#FF9800', '#FFC107', '#FFEB3B', '#CDDC39', '#8BC34A', '#4CAF50', '#009688'], - borderColor: '#FFFFFF', + // 5. 供应商请求占比环形图 + const providerReqData = data[period_id].provider_requests_data; + const providerReqContainer = document.getElementById(`providerRequestsDoughnutChart_${period_id}`); + if (providerReqContainer && providerReqData && providerReqData.data && providerReqData.data.length > 0) { + if (chartInstances[`providerRequestsDoughnutChart_${period_id}`]) { + chartInstances[`providerRequestsDoughnutChart_${period_id}`].dispose(); + } + const chart = echarts.init(providerReqContainer); + chartInstances[`providerRequestsDoughnutChart_${period_id}`] = chart; + + const pieData = providerReqData.labels.map((label, idx) => ({ + name: label, + value: providerReqData.data[idx] + })); + + const itemCount = pieData.length; + const useBottomLegend = itemCount > 8; + const reqColors = ['#9C27B0', '#E91E63', '#F44336', '#FF9800', '#FFC107', '#FFEB3B', '#CDDC39', '#8BC34A', '#4CAF50', '#009688']; + + chart.setOption({ + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + confine: true, + formatter: function(params) { + return `${params.name}
请求数: ${params.value} 次
占比: ${params.percent.toFixed(2)}%`; + } + }, + legend: { + type: 'scroll', + orient: useBottomLegend ? 'horizontal' : 'vertical', + right: useBottomLegend ? 'center' : 10, + top: useBottomLegend ? 'auto' : 'middle', + bottom: useBottomLegend ? 10 : 'auto', + left: useBottomLegend ? 'center' : 'auto', + width: useBottomLegend ? '90%' : '30%', + height: useBottomLegend ? 'auto' : '80%', + icon: 'circle', + itemWidth: 8, + itemHeight: 8, + itemGap: useBottomLegend ? 12 : 8, + textStyle: { + fontSize: 10, + width: useBottomLegend ? 70 : 80, + overflow: 'truncate', + ellipsis: '...' + }, + pageButtonItemGap: 5, + pageButtonGap: 5, + pageIconColor: '#9C27B0', + pageIconInactiveColor: '#aaa', + pageTextStyle: { fontSize: 10 }, + tooltip: { show: true } + }, + series: [{ + type: 'pie', + radius: ['35%', '60%'], + center: useBottomLegend ? ['50%', '40%'] : ['35%', '50%'], + avoidLabelOverlap: true, + itemStyle: { + borderColor: '#fff', borderWidth: 2, - hoverOffset: 10 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - position: 'right', - align: 'center', - labels: { - usePointStyle: true, - padding: 8, - font: { size: 10 }, - boxWidth: 12, - boxHeight: 12, - generateLabels: function(chart) { - const data = chart.data; - if (data.labels.length && data.datasets.length) { - const dataset = data.datasets[0]; - return data.labels.map((label, i) => { - const meta = chart.getDatasetMeta(0); - const style = meta.controller.getStyle(i); - return { - text: label.length > 15 ? label.substring(0, 15) + '...' : label, - fillStyle: style.backgroundColor, - strokeStyle: style.borderColor, - lineWidth: style.borderWidth, - hidden: isNaN(dataset.data[i]) || meta.data[i].hidden, - index: i - }; - }); - } - return []; - } - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.index; - const chart = legend.chart; - const meta = chart.getDatasetMeta(0); - - meta.data[index].hidden = !meta.data[index].hidden; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - cornerRadius: 8, - callbacks: { - label: function(context) { - const total = context.dataset.data.reduce((a, b) => a + b, 0); - const percentage = ((context.parsed / total) * 100).toFixed(2); - return context.label + ': ' + context.parsed + ' 次 (' + percentage + '%)'; - } - } - } + borderRadius: 4 }, - animation: { animateRotate: true, animateScale: true, duration: 1000 } - } + label: { show: false }, + emphasis: { + label: { show: true, fontSize: 12, fontWeight: 'bold' }, + itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } + }, + data: pieData, + color: reqColors + }], + animation: true, + animationDuration: 1000 }); } - // 3. 平均响应时间条形图 - const avgRespTimeData = staticChartData[period_id].avg_response_time_data; - const avgRespTimeCtx = document.getElementById(`avgResponseTimeChart_${period_id}`); - if (avgRespTimeCtx && avgRespTimeData && avgRespTimeData.data && avgRespTimeData.data.length > 0) { - // 动态计算高度:横向条形图每个条目至少30px - const minHeight = Math.max(270, avgRespTimeData.labels.length * 30); - avgRespTimeCtx.parentElement.style.minHeight = minHeight + 'px'; + // 6. 平均响应时间条形图 (横向) + const avgRespTimeData = data[period_id].avg_response_time_data; + const avgRespTimeContainer = document.getElementById(`avgResponseTimeChart_${period_id}`); + if (avgRespTimeContainer && avgRespTimeData && avgRespTimeData.data && avgRespTimeData.data.length > 0) { + if (chartInstances[`avgResponseTimeChart_${period_id}`]) { + chartInstances[`avgResponseTimeChart_${period_id}`].dispose(); + } - // 为每个柱子创建渐变色 - const barColors = avgRespTimeData.labels.map((_, idx) => { - const colorPalette = ['#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688', '#4CAF50']; - return colorPalette[idx % colorPalette.length]; - }); + const itemCount = avgRespTimeData.labels.length; + const needsZoom = itemCount > 12; + const minHeight = needsZoom ? 400 : Math.max(350, itemCount * 28); + avgRespTimeContainer.style.height = minHeight + 'px'; - // 为每个柱子创建单独的数据集 - const datasets = avgRespTimeData.labels.map((label, idx) => ({ - label: label.length > 25 ? label.substring(0, 25) + '...' : label, - data: avgRespTimeData.labels.map((_, i) => i === idx ? avgRespTimeData.data[idx] : null), - backgroundColor: barColors[idx], - borderColor: barColors[idx], - borderWidth: 2, - borderRadius: 6 - })); + const chart = echarts.init(avgRespTimeContainer); + chartInstances[`avgResponseTimeChart_${period_id}`] = chart; - new Chart(avgRespTimeCtx, { - type: 'bar', - data: { - labels: avgRespTimeData.labels.map(label => label.length > 25 ? label.substring(0, 25) + '...' : label), - datasets: datasets - }, - options: { - indexAxis: 'y', - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - display: true, - position: 'top', - align: 'start', - labels: { - usePointStyle: true, - padding: 6, - font: { size: 9 }, - boxWidth: 10, - boxHeight: 10 - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.datasetIndex; - const chart = legend.chart; - const meta = chart.getDatasetMeta(index); - meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - cornerRadius: 8, - callbacks: { - label: function(context) { - if (context.parsed.x !== null) { - return context.dataset.label + ': ' + context.parsed.x.toFixed(3) + ' 秒'; - } - return ''; - } - }, - filter: function(tooltipItem) { - return tooltipItem.parsed.x !== null; - } - } - }, - scales: { - x: { - beginAtZero: true, - title: { - display: true, - text: '时间 (秒)', - font: { size: 11, weight: 'bold' } - }, - grid: { color: 'rgba(0, 0, 0, 0.05)' }, - ticks: { font: { size: 10 } } - }, - y: { - grid: { display: false }, - ticks: { font: { size: 9 } } - } - }, - animation: { duration: 1000, easing: 'easeInOutQuart' } - } - }); - } - - // 4. 模型效率雷达图 - const radarData = staticChartData[period_id].model_efficiency_radar_data; - const radarCtx = document.getElementById(`modelEfficiencyRadarChart_${period_id}`); - if (radarCtx && radarData && radarData.datasets && radarData.datasets.length > 0) { - const radarColors = ['#00BCD4', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0']; - const datasets = radarData.datasets.map((dataset, idx) => ({ - label: dataset.model.length > 20 ? dataset.model.substring(0, 20) + '...' : dataset.model, - data: dataset.metrics, - backgroundColor: radarColors[idx % radarColors.length] + '40', - borderColor: radarColors[idx % radarColors.length], - borderWidth: 2, - pointBackgroundColor: radarColors[idx % radarColors.length], - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', - pointHoverBorderColor: radarColors[idx % radarColors.length], - pointRadius: 4, - pointHoverRadius: 6 - })); + const barColors = ['#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688', '#4CAF50']; + const displayEnd = needsZoom ? Math.min(100, Math.round(12 / itemCount * 100)) : 100; - new Chart(radarCtx, { - type: 'radar', - data: { - labels: radarData.labels, - datasets: datasets - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - position: 'bottom', - labels: { - usePointStyle: true, - padding: 8, - font: { size: 10 }, - boxWidth: 12, - boxHeight: 12 - }, - onClick: function(e, legendItem, legend) { - const index = legendItem.datasetIndex; - const chart = legend.chart; - const meta = chart.getDatasetMeta(index); - meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - cornerRadius: 8, - callbacks: { - label: function(context) { - const label = context.dataset.label || ''; - const metric = context.label || ''; - const value = context.parsed.r.toFixed(1); - return label + ' - ' + metric + ': ' + value + '/100'; - } - } + chart.setOption({ + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + axisPointer: { type: 'shadow' }, + confine: true, + formatter: function(params) { + if (params[0]) { + return `${params[0].name}
响应时间: ${params[0].value.toFixed(3)} 秒`; } - }, - scales: { - r: { - beginAtZero: true, - max: 100, - ticks: { - stepSize: 20, - font: { size: 9 }, - backdropColor: 'transparent' - }, - pointLabels: { - font: { size: 10, weight: 'bold' } - }, - grid: { color: 'rgba(0, 0, 0, 0.1)' }, - angleLines: { color: 'rgba(0, 0, 0, 0.1)' } - } - }, - animation: { duration: 1200, easing: 'easeInOutQuart' }, - interaction: { - mode: 'point', - intersect: true + return ''; } - } + }, + grid: { + left: '3%', + right: needsZoom ? '8%' : '4%', + bottom: '3%', + top: 30, + containLabel: true + }, + dataZoom: needsZoom ? [ + { + type: 'slider', + yAxisIndex: 0, + right: 5, + width: 20, + start: 0, + end: displayEnd, + handleSize: '100%', + showDetail: false, + brushSelect: false + }, + { + type: 'inside', + yAxisIndex: 0, + zoomOnMouseWheel: false, + moveOnMouseMove: true, + moveOnMouseWheel: true + } + ] : [], + xAxis: { + type: 'value', + name: '时间 (秒)', + nameTextStyle: { fontSize: 11, fontWeight: 'bold' }, + axisLabel: { fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.05)' } } + }, + yAxis: { + type: 'category', + data: avgRespTimeData.labels.map(l => l.length > 22 ? l.substring(0, 22) + '...' : l), + axisLabel: { fontSize: 9 }, + axisTick: { show: false }, + axisLine: { show: false } + }, + series: [{ + type: 'bar', + data: avgRespTimeData.data.map((value, idx) => ({ + value: value, + itemStyle: { + color: barColors[idx % barColors.length], + borderRadius: [0, 6, 6, 0] + } + })), + barMaxWidth: 18, + emphasis: { + itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.3)' } + } + }], + animation: true, + animationDuration: 1000, + large: true, + largeThreshold: 100 }); } - // 5. 响应时间分布散点图 - const scatterData = staticChartData[period_id].response_time_scatter_data; - const scatterCtx = document.getElementById(`responseTimeScatterChart_${period_id}`); - if (scatterCtx && scatterData && scatterData.length > 0) { - // 按模型分组数据,限制每个模型最多显示100个点 + // 7. 模型效率雷达图 + const radarData = data[period_id].model_efficiency_radar_data; + const radarContainer = document.getElementById(`modelEfficiencyRadarChart_${period_id}`); + if (radarContainer && radarData && radarData.datasets && radarData.datasets.length > 0) { + if (chartInstances[`modelEfficiencyRadarChart_${period_id}`]) { + chartInstances[`modelEfficiencyRadarChart_${period_id}`].dispose(); + } + const chart = echarts.init(radarContainer); + chartInstances[`modelEfficiencyRadarChart_${period_id}`] = chart; + + const radarColors = ['#00BCD4', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#673AB7', '#2196F3', '#FF5722']; + + // 限制显示的模型数量,避免图表过于拥挤 + const maxModels = 5; + const limitedDatasets = radarData.datasets.slice(0, maxModels); + + const indicator = radarData.labels.map(label => ({ + name: label.length > 12 ? label.substring(0, 12) + '...' : label, + max: 100 + })); + + const seriesData = limitedDatasets.map((dataset, idx) => ({ + name: dataset.model.length > 18 ? dataset.model.substring(0, 18) + '...' : dataset.model, + value: dataset.metrics, + lineStyle: { color: radarColors[idx % radarColors.length], width: 2 }, + areaStyle: { color: radarColors[idx % radarColors.length] + '30' }, + itemStyle: { color: radarColors[idx % radarColors.length] } + })); + + const legendCount = seriesData.length; + const useSideLegend = legendCount > 3; + + chart.setOption({ + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + confine: true + }, + legend: { + data: seriesData.map(s => s.name), + type: 'scroll', + orient: useSideLegend ? 'vertical' : 'horizontal', + right: useSideLegend ? 10 : 'center', + top: useSideLegend ? 'middle' : 10, + bottom: useSideLegend ? 'auto' : 'auto', + width: useSideLegend ? '20%' : 'auto', + icon: 'circle', + itemWidth: 8, + itemHeight: 8, + textStyle: { + fontSize: 10, + width: useSideLegend ? 70 : 'auto', + overflow: 'truncate' + }, + pageButtonItemGap: 5, + pageIconColor: '#00BCD4', + pageTextStyle: { fontSize: 9 }, + tooltip: { show: true } + }, + radar: { + indicator: indicator, + center: useSideLegend ? ['40%', '50%'] : ['50%', '55%'], + radius: useSideLegend ? '65%' : '55%', + nameGap: 6, + name: { + textStyle: { fontSize: 9, fontWeight: 'bold' } + }, + splitLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.1)' } }, + splitArea: { show: false }, + axisLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.1)' } } + }, + series: [{ + type: 'radar', + data: seriesData, + emphasis: { + lineStyle: { width: 3 } + } + }], + animation: true, + animationDuration: 1200 + }); + } + + // 8. 响应时间分布散点图 (大数据优化) + const scatterData = data[period_id].response_time_scatter_data; + const scatterContainer = document.getElementById(`responseTimeScatterChart_${period_id}`); + if (scatterContainer && scatterData && scatterData.length > 0) { + if (chartInstances[`responseTimeScatterChart_${period_id}`]) { + chartInstances[`responseTimeScatterChart_${period_id}`].dispose(); + } + const chart = echarts.init(scatterContainer); + chartInstances[`responseTimeScatterChart_${period_id}`] = chart; + + // 按模型分组数据,使用数据采样优化性能 const groupedData = {}; + const maxPointsPerModel = 150; // 每个模型最多150个点 scatterData.forEach(point => { if (!groupedData[point.model]) { groupedData[point.model] = []; } - if (groupedData[point.model].length < 100) { - groupedData[point.model].push({x: point.x, y: point.y}); + if (groupedData[point.model].length < maxPointsPerModel) { + groupedData[point.model].push([point.x, point.y]); } }); const scatterColors = ['#4CAF50', '#2196F3', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4', '#FFC107', '#607D8B']; - const datasets = Object.keys(groupedData).slice(0, 8).map((model, idx) => ({ - label: model.length > 20 ? model.substring(0, 20) + '...' : model, + const models = Object.keys(groupedData).slice(0, 6); // 限制最多6个模型 + const modelCount = models.length; + const useSideLegend = modelCount > 4; + + const series = models.map((model, idx) => ({ + name: model.length > 18 ? model.substring(0, 18) + '...' : model, + type: 'scatter', data: groupedData[model], - backgroundColor: scatterColors[idx % scatterColors.length] + '80', - borderColor: scatterColors[idx % scatterColors.length], - borderWidth: 2, - pointRadius: 4, - pointHoverRadius: 6, - pointStyle: 'circle' + symbolSize: 5, + itemStyle: { + color: scatterColors[idx % scatterColors.length], + opacity: 0.7 + }, + emphasis: { + itemStyle: { opacity: 1, shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.3)' } + }, + // 大数据优化 + large: true, + largeThreshold: 100 })); - new Chart(scatterCtx, { - type: 'scatter', - data: { datasets: datasets }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { - display: false - }, - legend: { - position: 'top', - labels: { - usePointStyle: true, - padding: 8, - font: { size: 10 }, - boxWidth: 12, - boxHeight: 12 - }, - onClick: function(e, legendItem, legend) { - // 默认行为:切换数据集的可见性 - const index = legendItem.datasetIndex; - const chart = legend.chart; - const meta = chart.getDatasetMeta(index); - - // 切换可见性 - meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null; - - // 更新图表 - chart.update(); - } - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, - cornerRadius: 8, - callbacks: { - label: function(context) { - return context.dataset.label + ': ' + context.parsed.y.toFixed(3) + ' 秒'; - }, - afterLabel: function(context) { - return '请求 #' + context.parsed.x; - } - } - } - }, - scales: { - x: { - title: { - display: true, - text: '请求序号', - font: { size: 11, weight: 'bold' } - }, - grid: { color: 'rgba(0, 0, 0, 0.05)' }, - ticks: { font: { size: 10 } } - }, - y: { - beginAtZero: true, - title: { - display: true, - text: '响应时间 (秒)', - font: { size: 11, weight: 'bold' } - }, - grid: { color: 'rgba(0, 0, 0, 0.05)' }, - ticks: { font: { size: 10 } } - } - }, - animation: { duration: 1000, easing: 'easeInOutQuart' }, - interaction: { - mode: 'point', - intersect: true + chart.setOption({ + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 12, + borderRadius: 8, + textStyle: { color: '#fff' }, + confine: true, + formatter: function(params) { + return `${params.seriesName}
请求 #${params.data[0]}
响应时间: ${params.data[1].toFixed(3)} 秒`; } - } + }, + legend: { + data: series.map(s => s.name), + type: 'scroll', + orient: useSideLegend ? 'vertical' : 'horizontal', + right: useSideLegend ? 10 : 'center', + top: useSideLegend ? 50 : 10, + width: useSideLegend ? '18%' : 'auto', + icon: 'circle', + itemWidth: 8, + itemHeight: 8, + textStyle: { + fontSize: 10, + width: useSideLegend ? 65 : 'auto', + overflow: 'truncate' + }, + pageButtonItemGap: 5, + pageIconColor: '#4CAF50', + pageTextStyle: { fontSize: 9 }, + tooltip: { show: true } + }, + grid: { + left: '3%', + right: useSideLegend ? '22%' : '4%', + bottom: '15%', + top: useSideLegend ? 50 : 50, + containLabel: true + }, + xAxis: { + type: 'value', + name: '请求序号', + nameTextStyle: { fontSize: 11, fontWeight: 'bold' }, + axisLabel: { fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.05)' } } + }, + yAxis: { + type: 'value', + name: '响应时间 (秒)', + nameTextStyle: { fontSize: 11, fontWeight: 'bold' }, + axisLabel: { fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0, 0, 0, 0.05)' } } + }, + series: series, + animation: true, + animationDuration: 1000, + // 数据缩放支持 - 内置缩放 + dataZoom: [ + { + type: 'inside', + xAxisIndex: 0, + filterMode: 'empty' + }, + { + type: 'inside', + yAxisIndex: 0, + filterMode: 'empty' + }, + { + type: 'slider', + xAxisIndex: 0, + height: 20, + bottom: 5, + handleSize: '100%', + showDetail: false + } + ] }); } }; @@ -1052,4 +1063,4 @@ document.addEventListener('DOMContentLoaded', function () { initializeStaticChartsForPeriod(firstTab); initializedTabs.add(firstTab); } -}); \ No newline at end of file +}); diff --git a/src/chat/utils/templates/static_charts.html b/src/chat/utils/templates/static_charts.html index 04152e7c5..307ec7614 100644 --- a/src/chat/utils/templates/static_charts.html +++ b/src/chat/utils/templates/static_charts.html @@ -12,58 +12,42 @@

💰 供应商成本分布

-
- -
+

📦 模块成本分布

-
- -
+

🤖 模型成本对比

-
- -
+

🔄 Token使用对比

-
- -
+

📞 供应商请求占比

-
- -
+

⚡ 平均响应时间

-
- -
+

🎯 模型效率雷达

-
- -
+

⏱️ 响应时间分布

-
- -
+
\ No newline at end of file