refactor(report): 使用Jinja2模板重构报告生成器

将报告生成逻辑与表示层分离,以提高代码的可维护性和可读性。

- HTML、CSS 和 JavaScript 从 Python f-string 中提取到独立的模板文件中。
- 引入 Jinja2 模板引擎动态渲染报告内容,使未来修改报告样式和结构更加方便,实现了逻辑和视图的分离。
This commit is contained in:
minecraft1024a
2025-11-13 12:09:37 +08:00
committed by Windpicker-owo
parent 7ac83cc77b
commit cfb111eca5
7 changed files with 468 additions and 267 deletions

View File

@@ -1,9 +1,12 @@
""" """
该模块用于生成HTML格式的统计报告。 该模块用于生成HTML格式的统计报告。
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
import json
import os
from jinja2 import Environment, FileSystemLoader
import aiofiles import aiofiles
from .statistic_keys import * # noqa: F403 from .statistic_keys import * # noqa: F403
@@ -48,6 +51,9 @@ class HTMLReportGenerator:
self.name_mapping = name_mapping self.name_mapping = name_mapping
self.stat_period = stat_period self.stat_period = stat_period
self.deploy_time = deploy_time self.deploy_time = deploy_time
# 初始化Jinja2环境
template_dir = os.path.join(os.path.dirname(__file__), "templates")
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
def _format_stat_data_div(self, stat_data: dict[str, Any], div_id: str, start_time: datetime, now: datetime) -> str: def _format_stat_data_div(self, stat_data: dict[str, Any], div_id: str, start_time: datetime, now: datetime) -> str:
""" """
@@ -152,134 +158,37 @@ class HTMLReportGenerator:
</div> </div>
</div> </div>
""" """
# 增加饼图和条形图 # 增加饼图和条形图
static_charts = self._generate_static_charts_div(stat_data, div_id) # 该功能尚未实现 static_charts = self._generate_static_charts_div(stat_data, div_id)
return f""" template = self.jinja_env.get_template("tab_content.html")
<div id="{div_id}" class="tab-content"> return template.render(
<p class="info-item"> div_id=div_id,
<strong>统计时段: </strong> start_time=start_time.strftime("%Y-%m-%d %H:%M:%S"),
{start_time.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")} end_time=now.strftime("%Y-%m-%d %H:%M:%S"),
</p> summary_cards=summary_cards,
{summary_cards} static_charts=static_charts,
{static_charts} model_rows=model_rows,
provider_rows=provider_rows,
module_rows=module_rows,
type_rows=type_rows,
chat_rows=chat_rows,
)
<h2>按模型分类统计</h2>
<table>
<tr><th>模型名称</th><th>调用次数</th><th>平均Token数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
<tbody>{model_rows}</tbody>
</table>
<h2>按供应商分类统计</h2>
<table>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th></tr>
<tbody>{provider_rows}</tbody>
</table>
<h2>按模块分类统计</h2>
<table>
<thead>
<tr><th>模块名称</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{module_rows}</tbody>
</table>
<h2>按请求类型分类统计</h2>
<table>
<thead>
<tr><th>请求类型</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{type_rows}</tbody>
</table>
<h2>聊天消息统计</h2>
<table>
<thead>
<tr><th>联系人/群组名称</th><th>消息数量</th></tr>
</thead>
<tbody>{chat_rows}</tbody>
</table>
</div>
"""
def _generate_chart_tab(self, chart_data: dict) -> str: def _generate_chart_tab(self, chart_data: dict) -> str:
"""生成图表选项卡的HTML内容。""" """生成图表选项卡的HTML内容。"""
return f""" template = self.jinja_env.get_template("charts_tab.html")
<div id="charts" class="tab-content"> return template.render()
<h2>数据图表</h2>
<div style="margin: 20px 0; text-align: center;"> def _generate_static_charts_div(self, stat_data: dict[str, Any], div_id: str)-> str:
<label style="margin-right: 10px; font-weight: bold;">时间范围:</label>
<button class="time-range-btn" onclick="switchTimeRange('6h')">6小时</button>
<button class="time-range-btn" onclick="switchTimeRange('12h')">12小时</button>
<button class="time-range-btn active" onclick="switchTimeRange('24h')">24小时</button>
<button class="time-range-btn" onclick="switchTimeRange('48h')">48小时</button>
</div>
<div style="margin-top: 20px;">
<div style="margin-bottom: 40px;"><canvas id="totalCostChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModuleChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModelChart" width="800" height="400"></canvas></div>
<div><canvas id="messageByChatChart" width="800" height="400"></canvas></div>
</div>
<style>
.time-range-btn {{
background-color: #ecf0f1; border: 1px solid #bdc3c7; color: #2c3e50;
padding: 8px 16px; margin: 0 5px; border-radius: 4px; cursor: pointer;
font-size: 14px; transition: all 0.3s ease;
}}
.time-range-btn:hover {{ background-color: #d5dbdb; }}
.time-range-btn.active {{ background-color: #3498db; color: white; border-color: #2980b9; }}
</style>
<script>
const allChartData = {chart_data};
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 }}
}};
function switchTimeRange(timeRange) {{
document.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
updateAllCharts(allChartData[timeRange], timeRange);
}}
function updateAllCharts(data, timeRange) {{
Object.values(currentCharts).forEach(chart => chart && chart.destroy());
currentCharts = {{}};
Object.keys(chartConfigs).forEach(type => createChart(type, data, timeRange));
}}
function createChart(chartType, data, timeRange) {{
const config = chartConfigs[chartType];
if (!data || !data[config.dataKey]) return;
const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#f1c40f'];
let datasets = [];
if (chartType === 'totalCost') {{
datasets = [{{ label: config.title, data: data[config.dataKey], borderColor: colors[0], backgroundColor: 'rgba(52, 152, 219, 0.1)', tension: 0.4, fill: config.fill }}];
}} 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 }});
i++;
}});
}}
currentCharts[chartType] = new Chart(document.getElementById(config.id), {{
type: 'line',
data: {{ labels: data.time_labels, datasets: datasets }},
options: {{
responsive: true,
plugins: {{ title: {{ display: true, text: `${{timeRange}}内${{config.title}}趋势`, font: {{ size: 16 }} }}, legend: {{ display: chartType !== 'totalCost', position: 'top' }} }},
scales: {{ x: {{ title: {{ display: true, text: '时间' }}, ticks: {{ maxTicksLimit: 12 }} }}, y: {{ title: {{ display: true, text: config.yAxisLabel }}, beginAtZero: true }} }},
interaction: {{ intersect: false, mode: 'index' }}
}}
}});
}}
document.addEventListener('DOMContentLoaded', function() {{
if (allChartData['24h']) {{
updateAllCharts(allChartData['24h'], '24h');
}}
}});
</script>
</div>
""" """
生成静态图表的HTML div。
:param stat_data: 统计数据。
:param div_id: The ID for the period, used to uniquely identify chart canvases.
:return: 渲染后的HTML字符串。
"""
template = self.jinja_env.get_template("static_charts.html")
return template.render(period_id=div_id)
async def generate_report(self, stat: dict[str, Any], chart_data: dict, now: datetime, output_path: str): async def generate_report(self, stat: dict[str, Any], chart_data: dict, now: datetime, output_path: str):
""" """
@@ -290,157 +199,50 @@ class HTMLReportGenerator:
:param now: 当前时间。 :param now: 当前时间。
:param output_path: 输出文件路径。 :param output_path: 输出文件路径。
""" """
tab_list = [ tab_list_html = [
f'<button class="tab-link" onclick="showTab(event, \'{period[0]}\')">{period[2]}</button>' f'<button class="tab-link" onclick="showTab(event, \'{period[0]}\')">{period[2]}</button>'
for period in self.stat_period for period in self.stat_period
] ]
tab_list.append('<button class="tab-link" onclick="showTab(event, \'charts\')">数据图表</button>') tab_list_html.append('<button class="tab-link" onclick="showTab(event, \'charts\')">数据图表</button>')
tab_content_list = [ tab_content_html_list = [
self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now) self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now)
for period in self.stat_period for period in self.stat_period
if period[0] != "all_time" if period[0] != "all_time"
] ]
tab_content_list.append( tab_content_html_list.append(self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now))
self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now) tab_content_html_list.append(self._generate_chart_tab(chart_data))
static_chart_data = {}
for period in self.stat_period:
period_id = period[0]
static_chart_data[period_id] = {
"provider_cost_data": stat[period_id].get(PIE_CHART_COST_BY_PROVIDER, {}),
"model_cost_data": stat[period_id].get(BAR_CHART_COST_BY_MODEL, {}),
}
static_chart_data["all_time"] = {
"provider_cost_data": stat["all_time"].get(PIE_CHART_COST_BY_PROVIDER, {}),
"model_cost_data": stat["all_time"].get(BAR_CHART_COST_BY_MODEL, {}),
}
# 渲染模板
# 读取CSS和JS文件内容
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.css"), "r", encoding="utf-8") as f:
report_css = await f.read()
async with aiofiles.open(os.path.join(self.jinja_env.loader.searchpath[0], "report.js"), "r", encoding="utf-8") as f:
report_js = await f.read()
# 渲染模板
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),
report_css=report_css,
report_js=report_js,
) )
tab_content_list.append(self._generate_chart_tab(chart_data))
joined_tab_list = "\n".join(tab_list)
joined_tab_content = "\n".join(tab_content_list)
html_template = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MoFox-Bot运行统计报告</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 20px; background-color: #f4f7f6; color: #333; line-height: 1.6; }}
.container {{ max-width: 900px; margin: 20px auto; background-color: #fff; padding: 25px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1, h2 {{ color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; margin-top: 0; }}
h1 {{ text-align: center; font-size: 2em; }}
h2 {{ font-size: 1.5em; margin-top: 30px; }}
p {{ margin-bottom: 10px; }}
.info-item {{ background-color: #ecf0f1; padding: 8px 12px; border-radius: 4px; margin-bottom: 8px; font-size: 0.95em; }}
.info-item strong {{ color: #2980b9; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 0.9em; }}
th, td {{ border: 1px solid #ddd; padding: 10px; text-align: left; }}
th {{ background-color: #3498db; color: white; font-weight: bold; }}
tr:nth-child(even) {{ background-color: #f9f9f9; }}
.footer {{ text-align: center; margin-top: 30px; font-size: 0.8em; color: #7f8c8d; }}
.tabs {{ overflow: hidden; background: #ecf0f1; display: flex; }}
.tabs button {{ background: inherit; border: none; outline: none; padding: 14px 16px; cursor: pointer; transition: 0.3s; font-size: 16px; }}
.tabs button:hover {{ background-color: #d4dbdc; }}
.tabs button.active {{ background-color: #b3bbbd; }}
.tab-content {{ display: none; padding: 20px; background-color: #fff; border: 1px solid #ccc; }}
.tab-content.active {{ display: block; }}
</style>
</head>
<body>
<div class="container">
<h1>MoFox-Bot运行统计报告</h1>
<p class="info-item"><strong>统计截止时间:</strong> {now.strftime("%Y-%m-%d %H:%M:%S")}</p>
<div class="tabs">{joined_tab_list}</div>
{joined_tab_content}
</div>
<script>
let i, tab_content, tab_links;
tab_content = document.getElementsByClassName("tab-content");
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");
function showTab(evt, tabName) {{
for (i = 0; i < tab_content.length; i++) tab_content[i].classList.remove("active");
for (i = 0; i < tab_links.length; i++) tab_links[i].classList.remove("active");
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
}}
</script>
</body>
</html>
"""
async with aiofiles.open(output_path, "w", encoding="utf-8") as f: async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
await f.write(html_template) await f.write(rendered_html)
def _generate_static_charts_div(self, stat_data: dict[str, Any], period_id: str) -> str:
"""生成静态图表饼图、条形图的HTML和JS。"""
provider_cost_data = stat_data.get(PIE_CHART_COST_BY_PROVIDER, {})
model_cost_data = stat_data.get(BAR_CHART_COST_BY_MODEL, {})
if not provider_cost_data and not model_cost_data:
return "<h2>数据总览</h2><p>当前时段暂无足够数据生成图表。</p>"
provider_labels = provider_cost_data.get('labels', [])
provider_data = provider_cost_data.get('data', [])
model_labels = model_cost_data.get('labels', [])
model_data = model_cost_data.get('data', [])
return f"""
<h2>数据总览</h2>
<div class="chart-grid">
<div class="chart-container">
<canvas id="providerCostPieChart_{period_id}"></canvas>
</div>
<div class="chart-container">
<canvas id="modelCostBarChart_{period_id}"></canvas>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {{
const colors = ['#3498db', '#2ecc71', '#f1c40f', '#e74c3c', '#9b59b6', '#1abc9c', '#34495e', '#e67e22'];
// Provider Cost Pie Chart
const providerCtx = document.getElementById('providerCostPieChart_{period_id}');
if (providerCtx && {provider_data}) {{
new Chart(providerCtx, {{
type: 'pie',
data: {{
labels: {provider_labels},
datasets: [{{
label: '按供应商花费',
data: {provider_data},
backgroundColor: colors,
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
title: {{ display: true, text: '按供应商花费分布', font: {{ size: 16 }} }},
legend: {{ position: 'top' }}
}}
}}
}});
}}
// Model Cost Bar Chart
const modelCtx = document.getElementById('modelCostBarChart_{period_id}');
if (modelCtx && {model_data}) {{
new Chart(modelCtx, {{
type: 'bar',
data: {{
labels: {model_labels},
datasets: [{{
label: '按模型花费',
data: {model_data},
backgroundColor: colors,
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
title: {{ display: true, text: '按模型花费排行', font: {{ size: 16 }} }},
legend: {{ display: false }}
}},
scales: {{
y: {{ beginAtZero: true, title: {{ display: true, text: '花费 (¥)' }} }}
}}
}}
}});
}}
}});
</script>
"""

View File

@@ -0,0 +1,16 @@
<div id="charts" class="tab-content">
<h2>数据图表</h2>
<div style="margin: 20px 0; text-align: center;">
<label style="margin-right: 10px; font-weight: bold;">时间范围:</label>
<button class="time-range-btn" onclick="switchTimeRange('6h')">6小时</button>
<button class="time-range-btn" onclick="switchTimeRange('12h')">12小时</button>
<button class="time-range-btn" onclick="switchTimeRange('24h')">24小时</button>
<button class="time-range-btn" onclick="switchTimeRange('48h')">48小时</button>
</div>
<div style="margin-top: 20px;">
<div style="margin-bottom: 40px;"><canvas id="totalCostChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModuleChart" width="800" height="400"></canvas></div>
<div style="margin-bottom: 40px;"><canvas id="costByModelChart" width="800" height="400"></canvas></div>
<div><canvas id="messageByChatChart" width="800" height="400"></canvas></div>
</div>
</div>

View File

@@ -0,0 +1,177 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 20px auto;
background-color: #fff;
padding: 25px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1, h2 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
margin-top: 0;
}
h1 {
text-align: center;
font-size: 2em;
}
h2 {
font-size: 1.5em;
margin-top: 30px;
}
p {
margin-bottom: 10px;
}
.info-item {
background-color: #ecf0f1;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 8px;
font-size: 0.95em;
}
.info-item strong {
color: #2980b9;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
font-size: 0.9em;
}
th, td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}
th {
background-color: #3498db;
color: white;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.footer {
text-align: center;
margin-top: 30px;
font-size: 0.8em;
color: #7f8c8d;
}
.tabs {
overflow: hidden;
background: #ecf0f1;
display: flex;
}
.tabs button {
background: inherit;
border: none;
outline: none;
padding: 14px 16px;
cursor: pointer;
transition: 0.3s;
font-size: 16px;
}
.tabs button:hover {
background-color: #d4dbdc;
}
.tabs button.active {
background-color: #b3bbbd;
}
.tab-content {
display: none;
padding: 20px;
background-color: #fff;
border: 1px solid #ccc;
}
.tab-content.active {
display: block;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
}
.card {
background-color: #ecf0f1;
padding: 15px;
border-radius: 5px;
text-align: center;
}
.card h3 {
margin: 0 0 10px;
font-size: 1em;
color: #2c3e50;
}
.card p {
margin: 0;
font-size: 1.2em;
font-weight: bold;
color: #34495e;
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}
.chart-container {
position: relative;
height: 40vh;
width: 100%;
}
.time-range-btn {
background-color: #ecf0f1;
border: 1px solid #bdc3c7;
color: #2c3e50;
padding: 8px 16px;
margin: 0 5px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.time-range-btn:hover {
background-color: #d5dbdb;
}
.time-range-btn.active {
background-color: #3498db;
color: white;
border-color: #2980b9;
}

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ report_title }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>{{ report_css }}</style>
</head>
<body>
<div class="container">
<h1>{{ report_title }}</h1>
<p class="info-item"><strong>统计截止时间:</strong> {{ generation_time }}</p>
<div class="tabs">{{ tab_list }}</div>
{{ tab_content }}
</div>
<script>{{ report_js }}</script>
</body>
</html>

View File

@@ -0,0 +1,134 @@
let i, tab_content, tab_links;
tab_content = document.getElementsByClassName("tab-content");
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");
function showTab(evt, tabName) {
for (i = 0; i < tab_content.length; i++) tab_content[i].classList.remove("active");
for (i = 0; i < tab_links.length; i++) tab_links[i].classList.remove("active");
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
}
document.addEventListener('DOMContentLoaded', function () {
// This is a placeholder for chart data which will be injected by python.
const allChartData = JSON.parse('{{ all_chart_data }}')
;
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 }
};
window.switchTimeRange = function(timeRange) {
document.querySelectorAll('.time-range-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
updateAllCharts(allChartData[timeRange], timeRange);
}
function updateAllCharts(data, timeRange) {
Object.values(currentCharts).forEach(chart => chart && chart.destroy());
currentCharts = {};
Object.keys(chartConfigs).forEach(type => createChart(type, data, timeRange));
}
function createChart(chartType, data, timeRange) {
const config = chartConfigs[chartType];
if (!data || !data[config.dataKey]) return;
const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#f1c40f'];
let datasets = [];
if (chartType === 'totalCost') {
datasets = [{ label: config.title, data: data[config.dataKey], borderColor: colors[0], backgroundColor: 'rgba(52, 152, 219, 0.1)', tension: 0.4, fill: config.fill }];
} 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 });
i++;
});
}
currentCharts[chartType] = new Chart(document.getElementById(config.id), {
type: 'line',
data: { labels: data.time_labels, datasets: datasets },
options: {
responsive: true,
plugins: { title: { display: true, text: `${timeRange}${config.title}趋势`, font: { size: 16 } }, legend: { display: chartType !== 'totalCost', position: 'top' } },
scales: { x: { title: { display: true, text: '时间' }, ticks: { maxTicksLimit: 12 } }, y: { title: { display: true, text: config.yAxisLabel }, beginAtZero: true } },
interaction: { intersect: false, mode: 'index' }
}
});
}
if (allChartData['24h']) {
updateAllCharts(allChartData['24h'], '24h');
// Activate the 24h button by default
document.querySelectorAll('.time-range-btn').forEach(btn => {
if (btn.textContent.includes('24小时')) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Static charts
const staticChartData = JSON.parse('{{ static_chart_data }}')
;
Object.keys(staticChartData).forEach(period_id => {
const providerCostData = staticChartData[period_id].provider_cost_data;
const modelCostData = staticChartData[period_id].model_cost_data;
const colors = ['#3498db', '#2ecc71', '#f1c40f', '#e74c3c', '#9b59b6', '#1abc9c', '#34495e', '#e67e22'];
// Provider Cost Pie Chart
const providerCtx = document.getElementById(`providerCostPieChart_${period_id}`);
if (providerCtx && providerCostData && providerCostData.data.length > 0) {
new Chart(providerCtx, {
type: 'pie',
data: {
labels: providerCostData.labels,
datasets: [{
label: '按供应商花费',
data: providerCostData.data,
backgroundColor: colors,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: '按供应商花费分布', font: { size: 16 } },
legend: { position: 'top' }
}
}
});
}
// Model Cost Bar Chart
const modelCtx = document.getElementById(`modelCostBarChart_${period_id}`);
if (modelCtx && modelCostData && modelCostData.data.length > 0) {
new Chart(modelCtx, {
type: 'bar',
data: {
labels: modelCostData.labels,
datasets: [{
label: '按模型花费',
data: modelCostData.data,
backgroundColor: colors,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: '按模型花费排行', font: { size: 16 } },
legend: { display: false }
},
scales: {
y: { beginAtZero: true, title: { display: true, text: '花费 (¥)' } }
}
}
});
}
});
});

View File

@@ -0,0 +1,9 @@
<h2>数据总览</h2>
<div class="chart-grid">
<div class="chart-container">
<canvas id="providerCostPieChart_{{ period_id }}"></canvas>
</div>
<div class="chart-container">
<canvas id="modelCostBarChart_{{ period_id }}"></canvas>
</div>
</div>

View File

@@ -0,0 +1,44 @@
<div id="{{ div_id }}" class="tab-content">
<p class="info-item">
<strong>统计时段: </strong>
{{ start_time }} ~ {{ end_time }}
</p>
{{ summary_cards }}
{{ static_charts }}
<h2>按模型分类统计</h2>
<table>
<tr><th>模型名称</th><th>调用次数</th><th>平均Token数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th><th>平均耗时(秒)</th></tr>
<tbody>{{ model_rows }}</tbody>
</table>
<h2>按供应商分类统计</h2>
<table>
<tr><th>供应商名称</th><th>调用次数</th><th>Token总量</th><th>TPS</th><th>每K Token成本</th><th>累计花费</th></tr>
<tbody>{{ provider_rows }}</tbody>
</table>
<h2>按模块分类统计</h2>
<table>
<thead>
<tr><th>模块名称</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ module_rows }}</tbody>
</table>
<h2>按请求类型分类统计</h2>
<table>
<thead>
<tr><th>请求类型</th><th>调用次数</th><th>输入Token</th><th>输出Token</th><th>Token总量</th><th>累计花费</th><th>平均耗时(秒)</th><th>标准差(秒)</th></tr>
</thead>
<tbody>{{ type_rows }}</tbody>
</table>
<h2>聊天消息统计</h2>
<table>
<thead>
<tr><th>联系人/群组名称</th><th>消息数量</th></tr>
</thead>
<tbody>{{ chat_rows }}</tbody>
</table>
</div>