From cfb111eca52b95a92d4cf6aa6480ddcfdebdad1f Mon Sep 17 00:00:00 2001 From: minecraft1024a Date: Thu, 13 Nov 2025 12:09:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor(report):=20=E4=BD=BF=E7=94=A8Jinja2?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=87=8D=E6=9E=84=E6=8A=A5=E5=91=8A=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将报告生成逻辑与表示层分离,以提高代码的可维护性和可读性。 - HTML、CSS 和 JavaScript 从 Python f-string 中提取到独立的模板文件中。 - 引入 Jinja2 模板引擎动态渲染报告内容,使未来修改报告样式和结构更加方便,实现了逻辑和视图的分离。 --- src/chat/utils/report_generator.py | 336 ++++---------------- src/chat/utils/templates/charts_tab.html | 16 + src/chat/utils/templates/report.css | 177 +++++++++++ src/chat/utils/templates/report.html | 19 ++ src/chat/utils/templates/report.js | 134 ++++++++ src/chat/utils/templates/static_charts.html | 9 + src/chat/utils/templates/tab_content.html | 44 +++ 7 files changed, 468 insertions(+), 267 deletions(-) create mode 100644 src/chat/utils/templates/charts_tab.html create mode 100644 src/chat/utils/templates/report.css create mode 100644 src/chat/utils/templates/report.html create mode 100644 src/chat/utils/templates/report.js create mode 100644 src/chat/utils/templates/static_charts.html create mode 100644 src/chat/utils/templates/tab_content.html diff --git a/src/chat/utils/report_generator.py b/src/chat/utils/report_generator.py index 5526781fa..3bc974757 100644 --- a/src/chat/utils/report_generator.py +++ b/src/chat/utils/report_generator.py @@ -1,9 +1,12 @@ """ 该模块用于生成HTML格式的统计报告。 """ + from datetime import datetime, timedelta from typing import Any - +import json +import os +from jinja2 import Environment, FileSystemLoader import aiofiles from .statistic_keys import * # noqa: F403 @@ -48,6 +51,9 @@ class HTMLReportGenerator: self.name_mapping = name_mapping self.stat_period = stat_period 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: """ @@ -152,134 +158,37 @@ class HTMLReportGenerator: """ - # 增加饼图和条形图 - static_charts = self._generate_static_charts_div(stat_data, div_id) # 该功能尚未实现 - return f""" -
-

- 统计时段: - {start_time.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")} -

- {summary_cards} - {static_charts} + static_charts = self._generate_static_charts_div(stat_data, div_id) + template = self.jinja_env.get_template("tab_content.html") + return template.render( + div_id=div_id, + start_time=start_time.strftime("%Y-%m-%d %H:%M:%S"), + end_time=now.strftime("%Y-%m-%d %H:%M:%S"), + summary_cards=summary_cards, + static_charts=static_charts, + model_rows=model_rows, + provider_rows=provider_rows, + module_rows=module_rows, + type_rows=type_rows, + chat_rows=chat_rows, + ) -

按模型分类统计

- - - {model_rows} -
模型名称调用次数平均Token数Token总量TPS每K Token成本累计花费平均耗时(秒)
- -

按供应商分类统计

- - - {provider_rows} -
供应商名称调用次数Token总量TPS每K Token成本累计花费
- -

按模块分类统计

- - - - - {module_rows} -
模块名称调用次数输入Token输出TokenToken总量累计花费平均耗时(秒)标准差(秒)
- -

按请求类型分类统计

- - - - - {type_rows} -
请求类型调用次数输入Token输出TokenToken总量累计花费平均耗时(秒)标准差(秒)
- -

聊天消息统计

- - - - - {chat_rows} -
联系人/群组名称消息数量
-
- """ def _generate_chart_tab(self, chart_data: dict) -> str: """生成图表选项卡的HTML内容。""" - return f""" -
-

数据图表

-
- - - - - -
-
-
-
-
-
-
- - -
+ template = self.jinja_env.get_template("charts_tab.html") + return template.render() + + def _generate_static_charts_div(self, stat_data: dict[str, Any], div_id: str)-> str: """ + 生成静态图表的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): """ @@ -290,157 +199,50 @@ class HTMLReportGenerator: :param now: 当前时间。 :param output_path: 输出文件路径。 """ - tab_list = [ + tab_list_html = [ f'' for period in self.stat_period ] - tab_list.append('') + tab_list_html.append('') - tab_content_list = [ + tab_content_html_list = [ self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now) for period in self.stat_period if period[0] != "all_time" ] - tab_content_list.append( - self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now) + tab_content_html_list.append(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""" - - - - - - MoFox-Bot运行统计报告 - - - - -
-

MoFox-Bot运行统计报告

-

统计截止时间: {now.strftime("%Y-%m-%d %H:%M:%S")}

-
{joined_tab_list}
- {joined_tab_content} -
- - - - """ async with aiofiles.open(output_path, "w", encoding="utf-8") as f: - await f.write(html_template) - 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 "

数据总览

当前时段暂无足够数据生成图表。

" - - 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""" -

数据总览

-
-
- -
-
- -
-
- - """ + await f.write(rendered_html) diff --git a/src/chat/utils/templates/charts_tab.html b/src/chat/utils/templates/charts_tab.html new file mode 100644 index 000000000..52e6c3024 --- /dev/null +++ b/src/chat/utils/templates/charts_tab.html @@ -0,0 +1,16 @@ +
+

数据图表

+
+ + + + + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/src/chat/utils/templates/report.css b/src/chat/utils/templates/report.css new file mode 100644 index 000000000..1c6603e5d --- /dev/null +++ b/src/chat/utils/templates/report.css @@ -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; +} \ No newline at end of file diff --git a/src/chat/utils/templates/report.html b/src/chat/utils/templates/report.html new file mode 100644 index 000000000..9ac7d2e3e --- /dev/null +++ b/src/chat/utils/templates/report.html @@ -0,0 +1,19 @@ + + + + + + {{ report_title }} + + + + +
+

{{ report_title }}

+

统计截止时间: {{ generation_time }}

+
{{ tab_list }}
+ {{ tab_content }} +
+ + + \ No newline at end of file diff --git a/src/chat/utils/templates/report.js b/src/chat/utils/templates/report.js new file mode 100644 index 000000000..7f944cb3a --- /dev/null +++ b/src/chat/utils/templates/report.js @@ -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: '花费 (¥)' } } + } + } + }); + } + }); +}); \ No newline at end of file diff --git a/src/chat/utils/templates/static_charts.html b/src/chat/utils/templates/static_charts.html new file mode 100644 index 000000000..3fd37ad4e --- /dev/null +++ b/src/chat/utils/templates/static_charts.html @@ -0,0 +1,9 @@ +

数据总览

+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/src/chat/utils/templates/tab_content.html b/src/chat/utils/templates/tab_content.html new file mode 100644 index 000000000..130c20cff --- /dev/null +++ b/src/chat/utils/templates/tab_content.html @@ -0,0 +1,44 @@ +
+

+ 统计时段: + {{ start_time }} ~ {{ end_time }} +

+ {{ summary_cards }} + {{ static_charts }} + +

按模型分类统计

+ + + {{ model_rows }} +
模型名称调用次数平均Token数Token总量TPS每K Token成本累计花费平均耗时(秒)
+ +

按供应商分类统计

+ + + {{ provider_rows }} +
供应商名称调用次数Token总量TPS每K Token成本累计花费
+ +

按模块分类统计

+ + + + + {{ module_rows }} +
模块名称调用次数输入Token输出TokenToken总量累计花费平均耗时(秒)标准差(秒)
+ +

按请求类型分类统计

+ + + + + {{ type_rows }} +
请求类型调用次数输入Token输出TokenToken总量累计花费平均耗时(秒)标准差(秒)
+ +

聊天消息统计

+ + + + + {{ chat_rows }} +
联系人/群组名称消息数量
+
\ No newline at end of file