feat(report): 丰富统计报告,增加多种高级可视化图表
新增了五种高级数据可视化图表,以提供更深入的模型性能和使用情况分析: - **Token使用对比图**: 直观展示各模型的输入与输出Token数量。 - **供应商请求占比图**: 显示不同服务供应商的请求分布。 - **平均响应时间图**: 对比各模型的平均响应速度。 - **模型效率雷达图**: 从多个维度(请求量、TPS、速度、成本、容量)综合评估模型性能。 - **响应时间分布散点图**: 展示每次请求的具体响应时间,分析性能稳定性。 此外,对报告的UI/UX进行了全面优化,包括采用卡片式布局、实现可交互的图例以筛选数据、以及改进图表样式和提示信息,提升了报告的可读性和易用性。
This commit is contained in:
@@ -319,11 +319,21 @@ class HTMLReportGenerator:
|
|||||||
"provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}),
|
"provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}),
|
||||||
"module_cost_data": stat[period_id].get(PIE_CHART_COST_BY_MODULE, {}),
|
"module_cost_data": stat[period_id].get(PIE_CHART_COST_BY_MODULE, {}),
|
||||||
"model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}),
|
"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"] = {
|
static_chart_data["all_time"] = {
|
||||||
"provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}),
|
"provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}),
|
||||||
"module_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_MODULE, {}),
|
"module_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_MODULE, {}),
|
||||||
"model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}),
|
"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()
|
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:
|
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()
|
report_js = await f.read()
|
||||||
# 渲染模板
|
# 渲染模板(使用紧凑的JSON格式减少文件大小)
|
||||||
template = self.jinja_env.get_template("report.html")
|
template = self.jinja_env.get_template("report.html")
|
||||||
rendered_html = template.render(
|
rendered_html = template.render(
|
||||||
report_title="MoFox-Bot运行统计报告",
|
report_title="MoFox-Bot运行统计报告",
|
||||||
generation_time=now.strftime("%Y-%m-%d %H:%M:%S"),
|
generation_time=now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
tab_list="\n".join(tab_list_html),
|
tab_list="\n".join(tab_list_html),
|
||||||
tab_content="\n".join(tab_content_html_list),
|
tab_content="\n".join(tab_content_html_list),
|
||||||
all_chart_data=json.dumps(chart_data),
|
all_chart_data=json.dumps(chart_data, separators=(',', ':'), ensure_ascii=False),
|
||||||
static_chart_data=json.dumps(static_chart_data),
|
static_chart_data=json.dumps(static_chart_data, separators=(',', ':'), ensure_ascii=False),
|
||||||
report_css=report_css,
|
report_css=report_css,
|
||||||
report_js=report_js,
|
report_js=report_js,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ class StatisticOutputTask(AsyncTask):
|
|||||||
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: {},
|
||||||
|
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
|
for period_key, _ in collect_period
|
||||||
}
|
}
|
||||||
@@ -475,6 +482,91 @@ class StatisticOutputTask(AsyncTask):
|
|||||||
"labels": [item[0] for item in sorted_models],
|
"labels": [item[0] for item in sorted_models],
|
||||||
"data": [round(item[1], 4) 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
|
return stats
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -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_COST_BY_MODEL = "bar_chart_cost_by_model"
|
||||||
BAR_CHART_REQ_BY_MODEL = "bar_chart_req_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" # 按用户的消息数
|
MSG_CNT_BY_USER = "messages_by_user" # 按用户的消息数
|
||||||
ACTIVE_CHATS_CNT = "active_chats_count" # 活跃聊天数
|
ACTIVE_CHATS_CNT = "active_chats_count" # 活跃聊天数
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ tab_links = document.getElementsByClassName("tab-link");
|
|||||||
if (tab_content.length > 0) tab_content[0].classList.add("active");
|
if (tab_content.length > 0) tab_content[0].classList.add("active");
|
||||||
if (tab_links.length > 0) tab_links[0].classList.add("active");
|
if (tab_links.length > 0) tab_links[0].classList.add("active");
|
||||||
|
|
||||||
|
// 跟踪哪些tab的图表已经初始化
|
||||||
|
const initializedTabs = new Set();
|
||||||
|
|
||||||
function showTab(evt, tabName) {
|
function showTab(evt, tabName) {
|
||||||
for (i = 0; i < tab_content.length; i++) {
|
for (i = 0; i < tab_content.length; i++) {
|
||||||
tab_content[i].classList.remove("active");
|
tab_content[i].classList.remove("active");
|
||||||
@@ -15,6 +18,12 @@ function showTab(evt, tabName) {
|
|||||||
document.getElementById(tabName).classList.add("active");
|
document.getElementById(tabName).classList.add("active");
|
||||||
document.getElementById(tabName).style.animation = 'slideIn 0.5s ease-out';
|
document.getElementById(tabName).style.animation = 'slideIn 0.5s ease-out';
|
||||||
evt.currentTarget.classList.add("active");
|
evt.currentTarget.classList.add("active");
|
||||||
|
|
||||||
|
// 懒加载:只在第一次切换到tab时初始化该tab的图表
|
||||||
|
if (!initializedTabs.has(tabName) && tabName !== 'charts') {
|
||||||
|
initializeStaticChartsForPeriod(tabName);
|
||||||
|
initializedTabs.add(tabName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
@@ -176,7 +185,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
console.error("Problematic static_chart_data string:", static_chart_data_json_string);
|
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 providerCostData = staticChartData[period_id].provider_cost_data;
|
||||||
const moduleCostData = staticChartData[period_id].module_cost_data;
|
const moduleCostData = staticChartData[period_id].module_cost_data;
|
||||||
const modelCostData = staticChartData[period_id].model_cost_data;
|
const modelCostData = staticChartData[period_id].model_cost_data;
|
||||||
@@ -212,22 +226,57 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: false,
|
||||||
aspectRatio: 1.3,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: false
|
||||||
text: '🏢 按供应商花费分布',
|
|
||||||
font: { size: 13, weight: '500' },
|
|
||||||
color: '#1C1B1F',
|
|
||||||
padding: { top: 4, bottom: 12 }
|
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'right',
|
||||||
|
align: 'center',
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 10,
|
padding: 8,
|
||||||
font: { size: 11 }
|
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: {
|
tooltip: {
|
||||||
@@ -279,22 +328,55 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: false,
|
||||||
aspectRatio: 1.3,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: false
|
||||||
text: '🔧 按模块使用分布',
|
|
||||||
font: { size: 13, weight: '500' },
|
|
||||||
color: '#1C1B1F',
|
|
||||||
padding: { top: 4, bottom: 12 }
|
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'right',
|
||||||
|
align: 'center',
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 10,
|
padding: 8,
|
||||||
font: { size: 11 }
|
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: {
|
tooltip: {
|
||||||
@@ -331,33 +413,53 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
// Model Cost Bar Chart
|
// Model Cost Bar Chart
|
||||||
const modelCtx = document.getElementById(`modelCostBarChart_${period_id}`);
|
const modelCtx = document.getElementById(`modelCostBarChart_${period_id}`);
|
||||||
if (modelCtx && modelCostData && modelCostData.data && modelCostData.data.length > 0) {
|
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, {
|
new Chart(modelCtx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: modelCostData.labels,
|
labels: modelCostData.labels,
|
||||||
datasets: [{
|
datasets: datasets
|
||||||
label: '按模型花费',
|
|
||||||
data: modelCostData.data,
|
|
||||||
backgroundColor: colors,
|
|
||||||
borderColor: colors,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderRadius: 6,
|
|
||||||
hoverBackgroundColor: colors.map(c => c + 'dd')
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: false,
|
||||||
aspectRatio: 1.2,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: false
|
||||||
text: '🤖 按模型花费排行',
|
},
|
||||||
font: { size: 13, weight: '500' },
|
legend: {
|
||||||
color: '#1C1B1F',
|
display: true,
|
||||||
padding: { top: 4, bottom: 12 }
|
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: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
@@ -366,16 +468,39 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
cornerRadius: 8,
|
cornerRadius: 8,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context) {
|
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: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
|
stacked: false,
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
ticks: {
|
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: {
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -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;">
|
<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> 数据可视化
|
<span style="font-size: 1.2em;">📊</span> 数据可视化
|
||||||
</h2>
|
</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>
|
||||||
<div style="display: flex; flex-direction: column; gap: 20px;">
|
<div style="display: flex; flex-direction: column; gap: 20px; max-height: none; overflow: visible;">
|
||||||
<div class="chart-container" style="height: 280px; margin: 0; padding: 16px; background: white;">
|
<!-- 原有图表 -->
|
||||||
<canvas id="providerCostPieChart_{{ period_id }}"></canvas>
|
<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>
|
||||||
<div class="chart-container" style="height: 280px; margin: 0; padding: 16px; background: white;">
|
<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;">
|
||||||
<canvas id="moduleCostPieChart_{{ period_id }}"></canvas>
|
<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>
|
||||||
<div class="chart-container" style="height: 300px; margin: 0; padding: 16px; background: white;">
|
<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;">
|
||||||
<canvas id="modelCostBarChart_{{ period_id }}"></canvas>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user