feat(report): 丰富统计报告,增加多种高级可视化图表

新增了五种高级数据可视化图表,以提供更深入的模型性能和使用情况分析:

- **Token使用对比图**: 直观展示各模型的输入与输出Token数量。
- **供应商请求占比图**: 显示不同服务供应商的请求分布。
- **平均响应时间图**: 对比各模型的平均响应速度。
- **模型效率雷达图**: 从多个维度(请求量、TPS、速度、成本、容量)综合评估模型性能。
- **响应时间分布散点图**: 展示每次请求的具体响应时间,分析性能稳定性。

此外,对报告的UI/UX进行了全面优化,包括采用卡片式布局、实现可交互的图例以筛选数据、以及改进图表样式和提示信息,提升了报告的可读性和易用性。
This commit is contained in:
minecraft1024a
2025-11-29 10:51:34 +08:00
parent 9dff133146
commit 95a221a41d
5 changed files with 810 additions and 52 deletions

View File

@@ -319,11 +319,21 @@ class HTMLReportGenerator:
"provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}),
"module_cost_data": stat[period_id].get(PIE_CHART_COST_BY_MODULE, {}),
"model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}),
"token_comparison_data": stat[period_id].get(BAR_CHART_TOKEN_COMPARISON, {}),
"response_time_scatter_data": stat[period_id].get(SCATTER_CHART_RESPONSE_TIME, []),
"model_efficiency_radar_data": stat[period_id].get(RADAR_CHART_MODEL_EFFICIENCY, {}),
"provider_requests_data": stat[period_id].get(DOUGHNUT_CHART_PROVIDER_REQUESTS, {}),
"avg_response_time_data": stat[period_id].get(BAR_CHART_AVG_RESPONSE_TIME, {}),
}
static_chart_data["all_time"] = {
"provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}),
"module_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_MODULE, {}),
"model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}),
"token_comparison_data": stat["all_time"].get(BAR_CHART_TOKEN_COMPARISON, {}),
"response_time_scatter_data": stat["all_time"].get(SCATTER_CHART_RESPONSE_TIME, []),
"model_efficiency_radar_data": stat["all_time"].get(RADAR_CHART_MODEL_EFFICIENCY, {}),
"provider_requests_data": stat["all_time"].get(DOUGHNUT_CHART_PROVIDER_REQUESTS, {}),
"avg_response_time_data": stat["all_time"].get(BAR_CHART_AVG_RESPONSE_TIME, {}),
}
# 渲染模板
@@ -332,15 +342,15 @@ class HTMLReportGenerator:
report_css = await f.read()
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.js"), encoding="utf-8") as f:
report_js = await f.read()
# 渲染模板
# 渲染模板使用紧凑的JSON格式减少文件大小
template = self.jinja_env.get_template("report.html")
rendered_html = template.render(
report_title="MoFox-Bot运行统计报告",
generation_time=now.strftime("%Y-%m-%d %H:%M:%S"),
tab_list="\n".join(tab_list_html),
tab_content="\n".join(tab_content_html_list),
all_chart_data=json.dumps(chart_data),
static_chart_data=json.dumps(static_chart_data),
all_chart_data=json.dumps(chart_data, separators=(',', ':'), ensure_ascii=False),
static_chart_data=json.dumps(static_chart_data, separators=(',', ':'), ensure_ascii=False),
report_css=report_css,
report_js=report_js,
)

View File

@@ -302,6 +302,13 @@ class StatisticOutputTask(AsyncTask):
PIE_CHART_COST_BY_MODULE: {},
BAR_CHART_COST_BY_MODEL: {},
BAR_CHART_REQ_BY_MODEL: {},
BAR_CHART_TOKEN_COMPARISON: {},
SCATTER_CHART_RESPONSE_TIME: {},
RADAR_CHART_MODEL_EFFICIENCY: {},
HEATMAP_CHAT_ACTIVITY: {},
DOUGHNUT_CHART_PROVIDER_REQUESTS: {},
LINE_CHART_COST_TREND: {},
BAR_CHART_AVG_RESPONSE_TIME: {},
}
for period_key, _ in collect_period
}
@@ -475,6 +482,91 @@ class StatisticOutputTask(AsyncTask):
"labels": [item[0] for item in sorted_models],
"data": [round(item[1], 4) for item in sorted_models],
}
# 1. Token输入输出对比条形图
model_names = list(period_stats[REQ_CNT_BY_MODEL].keys())
if model_names:
period_stats[BAR_CHART_TOKEN_COMPARISON] = {
"labels": model_names,
"input_tokens": [period_stats[IN_TOK_BY_MODEL].get(m, 0) for m in model_names],
"output_tokens": [period_stats[OUT_TOK_BY_MODEL].get(m, 0) for m in model_names],
}
# 2. 响应时间分布散点图数据(限制数据点以提高加载速度)
scatter_data = []
max_points_per_model = 50 # 每个模型最多50个点
for model_name, time_costs in period_stats[TIME_COST_BY_MODEL].items():
# 如果数据点太多,进行采样
if len(time_costs) > max_points_per_model:
step = len(time_costs) // max_points_per_model
sampled_costs = time_costs[::step][:max_points_per_model]
else:
sampled_costs = time_costs
for idx, time_cost in enumerate(sampled_costs):
scatter_data.append({
"model": model_name,
"x": idx,
"y": round(time_cost, 3),
"tokens": period_stats[TOTAL_TOK_BY_MODEL].get(model_name, 0) // len(time_costs) if time_costs else 0
})
period_stats[SCATTER_CHART_RESPONSE_TIME] = scatter_data
# 3. 模型效率雷达图
if model_names:
# 取前5个最常用的模型
top_models = sorted(period_stats[REQ_CNT_BY_MODEL].items(), key=lambda x: x[1], reverse=True)[:5]
radar_data = []
for model_name, _ in top_models:
# 归一化各项指标到0-100
req_count = period_stats[REQ_CNT_BY_MODEL].get(model_name, 0)
tps = period_stats[TPS_BY_MODEL].get(model_name, 0)
avg_time = period_stats[AVG_TIME_COST_BY_MODEL].get(model_name, 0)
cost_per_ktok = period_stats[COST_PER_KTOK_BY_MODEL].get(model_name, 0)
avg_tokens = period_stats[AVG_TOK_BY_MODEL].get(model_name, 0)
# 简单的归一化(反向归一化时间和成本,值越小越好)
max_req = max([period_stats[REQ_CNT_BY_MODEL].get(m[0], 1) for m in top_models])
max_tps = max([period_stats[TPS_BY_MODEL].get(m[0], 1) for m in top_models])
max_time = max([period_stats[AVG_TIME_COST_BY_MODEL].get(m[0], 0.1) for m in top_models])
max_cost = max([period_stats[COST_PER_KTOK_BY_MODEL].get(m[0], 0.001) for m in top_models])
max_tokens = max([period_stats[AVG_TOK_BY_MODEL].get(m[0], 1) for m in top_models])
radar_data.append({
"model": model_name,
"metrics": [
round((req_count / max_req) * 100, 2) if max_req > 0 else 0, # 请求量
round((tps / max_tps) * 100, 2) if max_tps > 0 else 0, # TPS
round((1 - avg_time / max_time) * 100, 2) if max_time > 0 else 100, # 速度(反向)
round((1 - cost_per_ktok / max_cost) * 100, 2) if max_cost > 0 else 100, # 成本效益(反向)
round((avg_tokens / max_tokens) * 100, 2) if max_tokens > 0 else 0, # Token容量
]
})
period_stats[RADAR_CHART_MODEL_EFFICIENCY] = {
"labels": ["请求量", "TPS", "响应速度", "成本效益", "Token容量"],
"datasets": radar_data
}
# 4. 供应商请求占比环形图
provider_requests = period_stats[REQ_CNT_BY_PROVIDER]
if provider_requests:
sorted_provider_reqs = sorted(provider_requests.items(), key=lambda item: item[1], reverse=True)
period_stats[DOUGHNUT_CHART_PROVIDER_REQUESTS] = {
"labels": [item[0] for item in sorted_provider_reqs],
"data": [item[1] for item in sorted_provider_reqs],
}
# 5. 平均响应时间条形图
if model_names:
sorted_by_time = sorted(
[(m, period_stats[AVG_TIME_COST_BY_MODEL].get(m, 0)) for m in model_names],
key=lambda x: x[1],
reverse=True
)
period_stats[BAR_CHART_AVG_RESPONSE_TIME] = {
"labels": [item[0] for item in sorted_by_time],
"data": [round(item[1], 3) for item in sorted_by_time],
}
return stats
@staticmethod

View File

@@ -63,6 +63,15 @@ PIE_CHART_COST_BY_MODULE = "pie_chart_cost_by_module"
BAR_CHART_COST_BY_MODEL = "bar_chart_cost_by_model"
BAR_CHART_REQ_BY_MODEL = "bar_chart_req_by_model"
# 新增更多图表数据
BAR_CHART_TOKEN_COMPARISON = "bar_chart_token_comparison" # Token输入输出对比图
SCATTER_CHART_RESPONSE_TIME = "scatter_chart_response_time" # 响应时间分布散点图
RADAR_CHART_MODEL_EFFICIENCY = "radar_chart_model_efficiency" # 模型效率雷达图
HEATMAP_CHAT_ACTIVITY = "heatmap_chat_activity" # 聊天活跃度热力图
DOUGHNUT_CHART_PROVIDER_REQUESTS = "doughnut_chart_provider_requests" # 供应商请求占比环形图
LINE_CHART_COST_TREND = "line_chart_cost_trend" # 成本趋势折线图
BAR_CHART_AVG_RESPONSE_TIME = "bar_chart_avg_response_time" # 平均响应时间条形图
# 新增消息分析指标
MSG_CNT_BY_USER = "messages_by_user" # 按用户的消息数
ACTIVE_CHATS_CNT = "active_chats_count" # 活跃聊天数

View File

@@ -4,6 +4,9 @@ tab_links = document.getElementsByClassName("tab-link");
if (tab_content.length > 0) tab_content[0].classList.add("active");
if (tab_links.length > 0) tab_links[0].classList.add("active");
// 跟踪哪些tab的图表已经初始化
const initializedTabs = new Set();
function showTab(evt, tabName) {
for (i = 0; i < tab_content.length; i++) {
tab_content[i].classList.remove("active");
@@ -15,6 +18,12 @@ function showTab(evt, tabName) {
document.getElementById(tabName).classList.add("active");
document.getElementById(tabName).style.animation = 'slideIn 0.5s ease-out';
evt.currentTarget.classList.add("active");
// 懒加载只在第一次切换到tab时初始化该tab的图表
if (!initializedTabs.has(tabName) && tabName !== 'charts') {
initializeStaticChartsForPeriod(tabName);
initializedTabs.add(tabName);
}
}
document.addEventListener('DOMContentLoaded', function () {
@@ -176,7 +185,12 @@ document.addEventListener('DOMContentLoaded', function () {
console.error("Problematic static_chart_data string:", static_chart_data_json_string);
}
Object.keys(staticChartData).forEach(period_id => {
// 懒加载函数只初始化指定tab的静态图表
function initializeStaticChartsForPeriod(period_id) {
if (!staticChartData[period_id]) {
console.warn(`No static chart data for period: ${period_id}`);
return;
}
const providerCostData = staticChartData[period_id].provider_cost_data;
const moduleCostData = staticChartData[period_id].module_cost_data;
const modelCostData = staticChartData[period_id].model_cost_data;
@@ -212,22 +226,57 @@ document.addEventListener('DOMContentLoaded', function () {
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.3,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '🏢 按供应商花费分布',
font: { size: 13, weight: '500' },
color: '#1C1B1F',
padding: { top: 4, bottom: 12 }
display: false
},
legend: {
position: 'bottom',
position: 'right',
align: 'center',
labels: {
usePointStyle: true,
padding: 10,
font: { size: 11 }
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: {
@@ -279,22 +328,55 @@ document.addEventListener('DOMContentLoaded', function () {
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.3,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '🔧 按模块使用分布',
font: { size: 13, weight: '500' },
color: '#1C1B1F',
padding: { top: 4, bottom: 12 }
display: false
},
legend: {
position: 'bottom',
position: 'right',
align: 'center',
labels: {
usePointStyle: true,
padding: 10,
font: { size: 11 }
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: {
@@ -331,33 +413,53 @@ document.addEventListener('DOMContentLoaded', function () {
// 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';
// 为每个柱子创建单独的数据集以支持单独隐藏
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'
}));
new Chart(modelCtx, {
type: 'bar',
data: {
labels: modelCostData.labels,
datasets: [{
label: '按模型花费',
data: modelCostData.data,
backgroundColor: colors,
borderColor: colors,
borderWidth: 2,
borderRadius: 6,
hoverBackgroundColor: colors.map(c => c + 'dd')
}]
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.2,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '🤖 按模型花费排行',
font: { size: 13, weight: '500' },
color: '#1C1B1F',
padding: { top: 4, bottom: 12 }
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();
}
},
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
@@ -366,16 +468,39 @@ document.addEventListener('DOMContentLoaded', function () {
cornerRadius: 8,
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + context.parsed.y.toFixed(4) + ' ¥';
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: 10 }
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: {
@@ -398,5 +523,478 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
}
});
// === 新增图表 ===
// 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';
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
}
}
});
}
// 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',
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 + '%)';
}
}
}
},
animation: { animateRotate: true, animateScale: true, duration: 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';
// 为每个柱子创建渐变色
const barColors = avgRespTimeData.labels.map((_, idx) => {
const colorPalette = ['#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#00BCD4', '#009688', '#4CAF50'];
return colorPalette[idx % colorPalette.length];
});
// 为每个柱子创建单独的数据集
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
}));
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
}));
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';
}
}
}
},
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
}
}
});
}
// 5. 响应时间分布散点图
const scatterData = staticChartData[period_id].response_time_scatter_data;
const scatterCtx = document.getElementById(`responseTimeScatterChart_${period_id}`);
if (scatterCtx && scatterData && scatterData.length > 0) {
// 按模型分组数据限制每个模型最多显示100个点
const groupedData = {};
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});
}
});
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,
data: groupedData[model],
backgroundColor: scatterColors[idx % scatterColors.length] + '80',
borderColor: scatterColors[idx % scatterColors.length],
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
pointStyle: 'circle'
}));
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
}
}
});
}
}
// 初始化第一个tab(默认显示的tab)的图表
const firstTab = tab_content[0]?.id;
if (firstTab && firstTab !== 'charts') {
initializeStaticChartsForPeriod(firstTab);
initializedTabs.add(firstTab);
}
});

View File

@@ -1,16 +1,65 @@
<div style="margin: -24px -24px 0 -24px; padding: 20px 24px; background: linear-gradient(135deg, #1976D2 0%, #2196F3 100%); border-radius: 20px 20px 0 0; margin-bottom: 20px;">
<h2 style="margin: 0; color: white; font-size: 1.3em; font-weight: 500; display: flex; align-items: center; gap: 8px;">
<h2 style="margin: 0 0 8px 0; color: white; font-size: 1.3em; font-weight: 500; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.2em;">📊</span> 数据可视化
</h2>
<p style="margin: 0; color: rgba(255,255,255,0.9); font-size: 0.85em; display: flex; align-items: center; gap: 6px;">
<span style="font-size: 1.1em;">💡</span> 提示:点击图例可以隐藏/显示对应的数据系列
</p>
</div>
<div style="display: flex; flex-direction: column; gap: 20px;">
<div class="chart-container" style="height: 280px; margin: 0; padding: 16px; background: white;">
<canvas id="providerCostPieChart_{{ period_id }}"></canvas>
<div style="display: flex; flex-direction: column; gap: 20px; max-height: none; overflow: visible;">
<!-- 原有图表 -->
<div class="chart-container" style="min-height: 280px; max-height: 400px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden;">
<h3 style="margin: 0 0 12px 0; color: #1976D2; font-size: 1.1em; font-weight: 600;">💰 供应商成本分布</h3>
<div style="position: relative; height: calc(100% - 40px); min-height: 220px;">
<canvas id="providerCostPieChart_{{ period_id }}"></canvas>
</div>
</div>
<div class="chart-container" style="height: 280px; margin: 0; padding: 16px; background: white;">
<canvas id="moduleCostPieChart_{{ period_id }}"></canvas>
<div class="chart-container" style="min-height: 280px; max-height: 500px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden;">
<h3 style="margin: 0 0 12px 0; color: #1976D2; font-size: 1.1em; font-weight: 600;">📦 模块成本分布</h3>
<div style="position: relative; height: calc(100% - 40px); min-height: 220px;">
<canvas id="moduleCostPieChart_{{ period_id }}"></canvas>
</div>
</div>
<div class="chart-container" style="height: 300px; margin: 0; padding: 16px; background: white;">
<canvas id="modelCostBarChart_{{ period_id }}"></canvas>
<div class="chart-container" style="min-height: 300px; max-height: 600px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: auto;">
<h3 style="margin: 0 0 12px 0; color: #1976D2; font-size: 1.1em; font-weight: 600;">🤖 模型成本对比</h3>
<div style="position: relative; min-height: 250px; height: auto;">
<canvas id="modelCostBarChart_{{ period_id }}"></canvas>
</div>
</div>
<!-- 新增图表 -->
<div class="chart-container" style="min-height: 320px; max-height: 600px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: auto;">
<h3 style="margin: 0 0 12px 0; color: #FF9800; font-size: 1.1em; font-weight: 600;">🔄 Token使用对比</h3>
<div style="position: relative; min-height: 270px; height: auto;">
<canvas id="tokenComparisonChart_{{ period_id }}"></canvas>
</div>
</div>
<div class="chart-container" style="min-height: 280px; max-height: 400px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden;">
<h3 style="margin: 0 0 12px 0; color: #9C27B0; font-size: 1.1em; font-weight: 600;">📞 供应商请求占比</h3>
<div style="position: relative; height: calc(100% - 40px); min-height: 220px;">
<canvas id="providerRequestsDoughnutChart_{{ period_id }}"></canvas>
</div>
</div>
<div class="chart-container" style="min-height: 320px; max-height: 700px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: auto;">
<h3 style="margin: 0 0 12px 0; color: #E91E63; font-size: 1.1em; font-weight: 600;">⚡ 平均响应时间</h3>
<div style="position: relative; min-height: 270px; height: auto;">
<canvas id="avgResponseTimeChart_{{ period_id }}"></canvas>
</div>
</div>
<div class="chart-container" style="min-height: 350px; max-height: 450px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden;">
<h3 style="margin: 0 0 12px 0; color: #00BCD4; font-size: 1.1em; font-weight: 600;">🎯 模型效率雷达</h3>
<div style="position: relative; height: calc(100% - 40px); min-height: 300px;">
<canvas id="modelEfficiencyRadarChart_{{ period_id }}"></canvas>
</div>
</div>
<div class="chart-container" style="min-height: 400px; max-height: 600px; margin: 0; padding: 16px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: auto;">
<h3 style="margin: 0 0 12px 0; color: #4CAF50; font-size: 1.1em; font-weight: 600;">⏱️ 响应时间分布</h3>
<div style="position: relative; min-height: 350px; height: auto;">
<canvas id="responseTimeScatterChart_{{ period_id }}"></canvas>
</div>
</div>
</div>