Files
gzth/yuthon_ai_agent/controllers/report.py
T
2026-06-05 15:48:00 +08:00

206 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
import json
import logging
import os
from odoo import http, api, SUPERUSER_ID, fields
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
# H5 模板文件路径
_H5_TEMPLATE_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'static', 'src', 'html', 'report_template.html'
)
class AiReportController(http.Controller):
"""AI 动态报表公开控制器"""
def _get_report_by_token(self, token):
"""根据 token 查找并验证报表"""
db_name = request.env.cr.dbname if hasattr(request.env.cr, 'dbname') else 'unknown'
_logger.info(
'报表访问请求: token=%s, db=%s, uid=%s, remote=%s',
token[:12], db_name, request.env.uid,
request.httprequest.remote_addr if hasattr(request, 'httprequest') else 'unknown'
)
report = request.env['ai.report'].sudo().search([
('access_token', '=', token),
], limit=1)
if not report:
_logger.warning(
'报表不存在: token=%s, db=%s, 可能原因: token错误/数据库不匹配/记录已被删除',
token[:12], db_name
)
return None, '报表不存在或已被删除'
if not report.is_valid():
_logger.warning('报表已失效: token=%s, db=%s, expire=%s, active=%s',
token[:12], db_name, report.expire_date, report.active)
reason = '报表已过期' if (report.expire_date and report.expire_date < fields.Datetime.now()) else '报表已停用'
return None, f'{reason},请联系分享者重新生成'
_logger.info('报表验证通过: token=%s, report_id=%s, name=%s',
token[:12], report.id, report.name)
return report, None
@http.route('/ai/report/<string:token>', type='http', auth='none',
methods=['GET'], csrf=False)
def report_page(self, token, **kw):
"""渲染 H5 报表页面(增强版:动态注入 REPORT_CONFIG)"""
report, error = self._get_report_by_token(token)
if error:
return Response(
self._render_error_page(error),
content_type='text/html; charset=utf-8'
)
# 1. 获取基础数据
data = report.fetch_data()
# 2. 构建 REPORT_CONFIG
config = {}
template = report.template_id
# 2a. 标题
config['title'] = report.name
# 2b. 列配置:优先使用模板的 report_config.columns
if template and template.report_config:
try:
tmpl_config = json.loads(template.report_config)
config['columns'] = tmpl_config.get('columns', [])
config['cards'] = tmpl_config.get('cards', [])
config['groupBy'] = tmpl_config.get('groupBy', [])
config['defaultVisible'] = tmpl_config.get('defaultVisible', 10)
config['colorRules'] = tmpl_config.get('colorRules', [])
config['filters'] = tmpl_config.get('filters', [])
except json.JSONDecodeError:
pass
# 2c. 如果没有模板或模板没有列配置,从 report 自身字段生成
if not config.get('columns'):
fields = json.loads(report.field_list or '[]')
labels = {}
if report.field_labels:
labels = json.loads(report.field_labels)
config['columns'] = [
{'k': f, 'l': labels.get(f, f), 'w': 120}
for f in fields
]
else:
# 2c-bis: 兜底校验 — 如果模板 column 的 key 在数据中不存在,用数据字段重建
data_records = data.get('records', [])
if data_records:
first = data_records[0]
col_keys = {c.get('k', '') for c in config['columns']}
data_keys = set(first.keys())
# 排除 id 字段(Odoo search_read 总是返回 id)
display_keys = data_keys - {'id'}
missing = col_keys - data_keys
extra = display_keys - col_keys
if missing:
_logger.warning(
'模板 %s (id=%s) report_config 列 key 与数据不匹配: '
'missing=%s, extra=%s, col_keys=%s, data_keys=%s',
template.name, template.id, missing, extra, col_keys, data_keys)
# 从数据字段重建列,但尝试保留模板中的 label 和 width
old_col_map = {c.get('k', ''): c for c in config['columns']}
labels = {}
if report.field_labels:
labels = json.loads(report.field_labels)
elif template and template.field_labels:
labels = json.loads(template.field_labels)
fields = data.get('fields', list(display_keys))
config['columns'] = [
{
'k': f,
'l': labels.get(f, old_col_map.get(f, {}).get('l', f)),
'w': old_col_map.get(f, {}).get('w', 120),
}
for f in fields if f != 'id'
]
config['_column_mismatch'] = True # 标记列被重建,前端可用此提示
_logger.info(
'模板 %s: 列已从数据字段重建,共 %s 列', template.name, len(config['columns'])
)
# 2d. 处理数据加载失败
if not data.get('success'):
config['data'] = []
config['description'] = data.get('error', '数据加载失败')
else:
# 注入数据
config['data'] = data.get('records', [])
config['description'] = "共 {} 条记录".format(data.get('count', 0))
# 2e. 子标题(数据概要)
fields = data.get('fields', [])
records_count = data.get('count', 0)
config['subtitle'] = "{} 个字段 · {} 条记录".format(len(fields), records_count)
# 3. 读取 H5 模板
try:
with open(_H5_TEMPLATE_PATH, 'r', encoding='utf-8') as f:
html_template = f.read()
except FileNotFoundError:
return Response('模板文件未找到', content_type='text/plain')
# 4. 注入占位符
config_json_str = json.dumps(config, ensure_ascii=False, default=str)
html_content = html_template.replace('{{REPORT_CONFIG_JSON}}', config_json_str)
html_content = html_content.replace('{{DATA_URL}}', '/ai/report/{}/data'.format(token))
html_content = html_content.replace('{{REPORT_TITLE}}', report.name or '动态报表')
return Response(html_content, content_type='text/html; charset=utf-8')
@http.route('/ai/report/<string:token>/data', type='http', auth='none',
methods=['GET'], csrf=False)
def report_data(self, token, **kw):
"""返回报表数据(JSON API)"""
report, error = self._get_report_by_token(token)
if error:
return Response(
json.dumps({'error': error}, ensure_ascii=False),
content_type='application/json; charset=utf-8'
)
data = report.fetch_data()
return Response(
json.dumps(data, ensure_ascii=False, default=str),
content_type='application/json; charset=utf-8'
)
@staticmethod
def _render_error_page(message):
"""渲染简单的错误页面"""
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>报表错误</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; margin: 0;
background: #f5f5f5; color: #333;
}}
.error-box {{
text-align: center; padding: 40px;
background: white; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}}
.error-icon {{ font-size: 48px; margin-bottom: 16px; }}
.error-msg {{ font-size: 16px; color: #666; }}
</style>
</head>
<body>
<div class="error-box">
<div class="error-icon">&#9888;</div>
<div class="error-msg">{message}</div>
</div>
</body>
</html>'''