feat(report): 增强统计报告,增加模块花费图表并优化UI

本次更新对统计报告进行了多项功能增强和界面优化,旨在提供更丰富的分析维度和更佳的用户体验。

主要变更包括:
- **新功能**:
  - 新增“按模块花费”饼图,以提供新的成本分析维度。
  - 在供应商统计表格中加入“平均耗时”指标,用于性能评估。
  - 在报告顶部添加“名词解释”卡片,帮助用户理解关键指标。

- **UI/UX 优化**:
  - 重构页面布局为主内容区与图表侧边栏,提升信息密度和可读性,并实现响应式设计。
  - 全面优化图表视觉效果,包括更新调色板、增加加载动画、改进提示框,使其更具表现力和交互性。
This commit is contained in:
minecraft1024a
2025-11-29 10:12:33 +08:00
parent 7efbf58dda
commit 9dff133146
7 changed files with 207 additions and 33 deletions

View File

@@ -92,6 +92,7 @@ class HTMLReportGenerator:
f"<td>{stat_data[TPS_BY_PROVIDER].get(provider_name, 0):.2f}</td>" f"<td>{stat_data[TPS_BY_PROVIDER].get(provider_name, 0):.2f}</td>"
f"<td>{stat_data[COST_PER_KTOK_BY_PROVIDER].get(provider_name, 0):.4f} ¥</td>" f"<td>{stat_data[COST_PER_KTOK_BY_PROVIDER].get(provider_name, 0):.4f} ¥</td>"
f"<td>{stat_data[COST_BY_PROVIDER].get(provider_name, 0):.4f} ¥</td>" f"<td>{stat_data[COST_BY_PROVIDER].get(provider_name, 0):.4f} ¥</td>"
f"<td>{stat_data.get(AVG_TIME_COST_BY_PROVIDER, {}).get(provider_name, 0):.3f} 秒</td>"
f"</tr>" f"</tr>"
for provider_name, count in sorted(stat_data[REQ_CNT_BY_PROVIDER].items()) for provider_name, count in sorted(stat_data[REQ_CNT_BY_PROVIDER].items())
] ]
@@ -316,10 +317,12 @@ class HTMLReportGenerator:
period_id = period[0] period_id = period[0]
static_chart_data[period_id] = { static_chart_data[period_id] = {
"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, {}),
"model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}), "model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}),
} }
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, {}),
"model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}), "model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}),
} }

View File

@@ -299,6 +299,7 @@ class StatisticOutputTask(AsyncTask):
# Chart data # Chart data
PIE_CHART_COST_BY_PROVIDER: {}, PIE_CHART_COST_BY_PROVIDER: {},
PIE_CHART_REQ_BY_PROVIDER: {}, PIE_CHART_REQ_BY_PROVIDER: {},
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: {},
} }
@@ -457,6 +458,15 @@ class StatisticOutputTask(AsyncTask):
"data": [round(item[1], 4) for item in sorted_providers], "data": [round(item[1], 4) for item in sorted_providers],
} }
# 按模块花费饼图
module_costs = period_stats[COST_BY_MODULE]
if module_costs:
sorted_modules = sorted(module_costs.items(), key=lambda item: item[1], reverse=True)
period_stats[PIE_CHART_COST_BY_MODULE] = {
"labels": [item[0] for item in sorted_modules],
"data": [round(item[1], 4) for item in sorted_modules],
}
# 按模型花费条形图 # 按模型花费条形图
model_costs = period_stats[COST_BY_MODEL] model_costs = period_stats[COST_BY_MODEL]
if model_costs: if model_costs:

View File

@@ -59,6 +59,7 @@ STD_TIME_COST_BY_PROVIDER = "std_time_costs_by_provider"
# 新增饼图和条形图数据 # 新增饼图和条形图数据
PIE_CHART_COST_BY_PROVIDER = "pie_chart_cost_by_provider" PIE_CHART_COST_BY_PROVIDER = "pie_chart_cost_by_provider"
PIE_CHART_REQ_BY_PROVIDER = "pie_chart_req_by_provider" PIE_CHART_REQ_BY_PROVIDER = "pie_chart_req_by_provider"
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"

View File

@@ -33,6 +33,7 @@ body {
border-radius: 28px; border-radius: 28px;
box-shadow: 0 4px 16px var(--md-sys-color-shadow); box-shadow: 0 4px 16px var(--md-sys-color-shadow);
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
} }
@keyframes slideIn { @keyframes slideIn {
@@ -48,18 +49,43 @@ body {
/* Dashboard Layout */ /* Dashboard Layout */
.dashboard-layout { .dashboard-layout {
display: flex; display: flex;
flex-direction: column;
gap: 32px; gap: 32px;
align-items: start;
position: relative;
} }
.main-content { .main-content {
width: 100%; flex: 1;
min-width: 0; min-width: 0;
} }
.sidebar-content { .sidebar-content {
width: 100%; width: 380px;
min-width: 0; min-width: 380px;
flex-shrink: 0;
padding: 24px;
background: linear-gradient(135deg, #FAFAFA 0%, #FFFFFF 100%);
border-radius: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid var(--md-sys-color-outline);
}
/* 自定义侧边栏滚动条 */
.sidebar-content::-webkit-scrollbar {
width: 6px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--md-sys-color-outline);
border-radius: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: var(--md-sys-color-primary);
} }
/* Typography - Material Design 3 */ /* Typography - Material Design 3 */
@@ -378,6 +404,24 @@ tbody tr:hover {
} }
/* Responsive Design */ /* Responsive Design */
@media (max-width: 1200px) {
.dashboard-layout {
flex-direction: column;
}
.main-content {
margin-right: 0;
}
.sidebar-content {
position: static;
width: 100%;
min-width: 0;
max-height: none;
right: auto;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 20px; padding: 20px;

View File

@@ -178,8 +178,21 @@ document.addEventListener('DOMContentLoaded', function () {
Object.keys(staticChartData).forEach(period_id => { Object.keys(staticChartData).forEach(period_id => {
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 modelCostData = staticChartData[period_id].model_cost_data; const modelCostData = staticChartData[period_id].model_cost_data;
const colors = ['#3498db', '#2ecc71', '#f1c40f', '#e74c3c', '#9b59b6', '#1abc9c', '#34495e', '#e67e22']; // 扩展的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 // Provider Cost Pie Chart
const providerCtx = document.getElementById(`providerCostPieChart_${period_id}`); const providerCtx = document.getElementById(`providerCostPieChart_${period_id}`);
@@ -200,28 +213,95 @@ document.addEventListener('DOMContentLoaded', function () {
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
aspectRatio: 1.5, aspectRatio: 1.3,
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: '按供应商花费分布', text: '🏢 按供应商花费分布',
font: { size: 14, weight: '500' }, font: { size: 13, weight: '500' },
color: '#1C1B1F', color: '#1C1B1F',
padding: { top: 8, bottom: 16 } padding: { top: 4, bottom: 12 }
}, },
legend: { legend: {
position: 'right', position: 'bottom',
labels: { labels: {
usePointStyle: true, usePointStyle: true,
padding: 15, padding: 10,
font: { size: 12 } font: { size: 11 }
} }
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12, padding: 12,
titleFont: { size: 14 }, titleFont: { size: 13 },
bodyFont: { 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: true,
aspectRatio: 1.3,
plugins: {
title: {
display: true,
text: '🔧 按模块使用分布',
font: { size: 13, weight: '500' },
color: '#1C1B1F',
padding: { top: 4, bottom: 12 }
},
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 10,
font: { size: 11 }
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: { size: 13 },
bodyFont: { size: 12 },
cornerRadius: 8, cornerRadius: 8,
callbacks: { callbacks: {
label: function(context) { label: function(context) {
@@ -261,28 +341,28 @@ document.addEventListener('DOMContentLoaded', function () {
backgroundColor: colors, backgroundColor: colors,
borderColor: colors, borderColor: colors,
borderWidth: 2, borderWidth: 2,
borderRadius: 8, borderRadius: 6,
hoverBackgroundColor: colors.map(c => c + 'dd') hoverBackgroundColor: colors.map(c => c + 'dd')
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
aspectRatio: 1.5, aspectRatio: 1.2,
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: '按模型花费排行', text: '🤖 按模型花费排行',
font: { size: 14, weight: '500' }, font: { size: 13, weight: '500' },
color: '#1C1B1F', color: '#1C1B1F',
padding: { top: 8, bottom: 16 } padding: { top: 4, bottom: 12 }
}, },
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12, padding: 12,
titleFont: { size: 14 }, titleFont: { size: 13 },
bodyFont: { size: 13 }, bodyFont: { size: 12 },
cornerRadius: 8, cornerRadius: 8,
callbacks: { callbacks: {
label: function(context) { label: function(context) {
@@ -293,16 +373,22 @@ document.addEventListener('DOMContentLoaded', function () {
}, },
scales: { scales: {
x: { x: {
grid: { display: false } grid: { display: false },
ticks: {
font: { size: 10 }
}
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
title: { title: {
display: true, display: true,
text: '💰 花费 (¥)', text: '💰 花费 (¥)',
font: { size: 13, weight: 'bold' } font: { size: 11, weight: 'bold' }
}, },
grid: { color: 'rgba(0, 0, 0, 0.05)' } grid: { color: 'rgba(0, 0, 0, 0.05)' },
ticks: {
font: { size: 10 }
}
} }
}, },
animation: { animation: {

View File

@@ -1,9 +1,16 @@
<h2>📊 数据总览</h2> <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="display: flex; flex-direction: column; gap: 24px;"> <h2 style="margin: 0; color: white; font-size: 1.3em; font-weight: 500; display: flex; align-items: center; gap: 8px;">
<div class="chart-container" style="height: 300px;"> <span style="font-size: 1.2em;">📊</span> 数据可视化
</h2>
</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> <canvas id="providerCostPieChart_{{ period_id }}"></canvas>
</div> </div>
<div class="chart-container" style="height: 300px;"> <div class="chart-container" style="height: 280px; margin: 0; padding: 16px; background: white;">
<canvas id="moduleCostPieChart_{{ period_id }}"></canvas>
</div>
<div class="chart-container" style="height: 300px; margin: 0; padding: 16px; background: white;">
<canvas id="modelCostBarChart_{{ period_id }}"></canvas> <canvas id="modelCostBarChart_{{ period_id }}"></canvas>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,25 @@
<div id="{{ div_id }}" class="tab-content"> <div id="{{ div_id }}" class="tab-content">
<div class="info-item" style="background: linear-gradient(135deg, #E3F2FD 0%, #BBDEFB 100%); border-left-color: #1976D2;">
<strong>📖 名词解释</strong>
<div style="margin-top: 12px; line-height: 1.8; font-size: 0.9em;">
<div style="margin-bottom: 8px;">
<strong style="color: #1976D2;">🎯 Token:</strong> AI处理文本的基本单位约等于0.75个英文单词或0.5个中文字
</div>
<div style="margin-bottom: 8px;">
<strong style="color: #1976D2;">💸 TPS:</strong> Token Per Second每秒处理的Token数量衡量AI响应速度
</div>
<div style="margin-bottom: 8px;">
<strong style="color: #1976D2;">📊 每K Token成本:</strong> 每1000个Token的成本用于比较不同模型的性价比
</div>
<div style="margin-bottom: 8px;">
<strong style="color: #1976D2;">⚡ Token效率:</strong> 输出Token与输入Token的比率反映模型输出丰富度
</div>
<div>
<strong style="color: #1976D2;">🔄 消息/请求比:</strong> 平均每次AI请求处理的用户消息数量
</div>
</div>
</div>
<div class="dashboard-layout"> <div class="dashboard-layout">
<div class="main-content"> <div class="main-content">
<p class="info-item"> <p class="info-item">
@@ -15,7 +36,9 @@
<h2>🏢 按供应商分类统计</h2> <h2>🏢 按供应商分类统计</h2>
<table> <table>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th></tr> <thead>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
</thead>
<tbody>{{ provider_rows }}</tbody> <tbody>{{ provider_rows }}</tbody>
</table> </table>
@@ -58,9 +81,9 @@
<tbody>{{ efficiency_rows }}</tbody> <tbody>{{ efficiency_rows }}</tbody>
</table> </table>
</div> </div>
</div>
<div class="sidebar-content">
<div class="sidebar-content"> {{ static_charts }}
{{ static_charts }} </div>
</div> </div>
</div> </div>