@@ -22,6 +22,7 @@
|
||||
'views/ai_provider_views.xml',
|
||||
'views/ai_rule_views.xml',
|
||||
'views/ai_manual_views.xml',
|
||||
'views/ai_report_views.xml',
|
||||
'views/ai_user_profile_views.xml',
|
||||
'views/ai_token_usage_views.xml',
|
||||
'views/ai_conversation_views.xml',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
from . import report
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from odoo import http, api, SUPERUSER_ID
|
||||
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 查找并验证报表"""
|
||||
report = request.env['ai.report'].sudo().search([
|
||||
('access_token', '=', token),
|
||||
], limit=1)
|
||||
if not report:
|
||||
return None, '报表不存在或已被删除'
|
||||
if not report.is_valid():
|
||||
return None, '报表已过期或已停用'
|
||||
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, error = self._get_report_by_token(token)
|
||||
if error:
|
||||
return Response(
|
||||
self._render_error_page(error),
|
||||
content_type='text/html; charset=utf-8'
|
||||
)
|
||||
|
||||
# 读取 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')
|
||||
|
||||
# 替换模板中的占位变量
|
||||
html_content = html_template.replace('{{REPORT_NAME}}', report.name or '')
|
||||
html_content = html_content.replace('{{DATA_URL}}', f'/ai/report/{token}/data')
|
||||
|
||||
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">⚠</div>
|
||||
<div class="error-msg">{message}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'''
|
||||
@@ -4,3 +4,5 @@ from . import ai_user_profile
|
||||
from . import ai_token_usage
|
||||
from . import ai_rule
|
||||
from . import ai_manual
|
||||
from . import ai_report
|
||||
from . import ai_report_template
|
||||
|
||||
@@ -245,6 +245,29 @@ class AiConversation(models.Model):
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'generate_report',
|
||||
'description': (
|
||||
'当查询结果数据较多(超过5条)或用户要求查看列表/报表时,'
|
||||
'生成一个动态报表H5页面链接。用户点击链接可在手机上查看整齐的表格数据。'
|
||||
'注意:必须先调用 odoo_fields_get 确认字段名称,再调用此工具。'
|
||||
),
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'model': {'type': 'string', 'description': '模型技术名称,如 sale.order'},
|
||||
'domain': {'type': 'array', 'description': "查询条件 domain,如 [['state','=','sale']]", 'items': {}},
|
||||
'fields': {'type': 'array', 'items': {'type': 'string'}, 'description': '要显示的字段列表'},
|
||||
'field_labels': {'type': 'object', 'description': '字段中文标签,如 {"name":"订单号","amount_total":"金额"}'},
|
||||
'name': {'type': 'string', 'description': '报表名称,如"本月销售订单"'},
|
||||
'order': {'type': 'string', 'description': '排序,如 "create_date desc"'},
|
||||
},
|
||||
'required': ['model', 'fields', 'name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
if self.provider_id.allow_write:
|
||||
tools += [
|
||||
@@ -325,6 +348,10 @@ class AiConversation(models.Model):
|
||||
})
|
||||
return {'success': True, 'message': f'已记住:{content}'}
|
||||
|
||||
# generate_report 工具单独处理
|
||||
if name == 'generate_report':
|
||||
return self._execute_generate_report(args)
|
||||
|
||||
model_name = args.get('model')
|
||||
if not model_name or model_name not in self.env:
|
||||
return {'error': f'未知模型:{model_name}'}
|
||||
@@ -397,6 +424,43 @@ class AiConversation(models.Model):
|
||||
return skill_rule.execute_skill(args)
|
||||
return None
|
||||
|
||||
def _execute_generate_report(self, args):
|
||||
"""执行 generate_report 工具:创建动态报表并返回 URL"""
|
||||
import json as _json
|
||||
|
||||
model_name = args.get('model')
|
||||
if not model_name or model_name not in self.env:
|
||||
return {'error': f'未知模型:{model_name}'}
|
||||
|
||||
field_list = args.get('fields') or []
|
||||
if not field_list:
|
||||
return {'error': '必须指定显示字段'}
|
||||
|
||||
report_name = args.get('name') or '查询报表'
|
||||
domain = args.get('domain') or []
|
||||
field_labels = args.get('field_labels') or {}
|
||||
order = args.get('order') or 'id desc'
|
||||
|
||||
try:
|
||||
report = self.env['ai.report'].sudo().create({
|
||||
'name': report_name,
|
||||
'model_name': model_name,
|
||||
'domain': _json.dumps(domain, ensure_ascii=False),
|
||||
'field_list': _json.dumps(field_list, ensure_ascii=False),
|
||||
'field_labels': _json.dumps(field_labels, ensure_ascii=False) if field_labels else '',
|
||||
'order': order,
|
||||
'create_uid': self.env.uid,
|
||||
})
|
||||
url = report.get_report_url()
|
||||
return {
|
||||
'success': True,
|
||||
'url': url,
|
||||
'message': f'已生成报表链接,用户可点击查看: {url}',
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.exception('generate_report failed: %s', e)
|
||||
return {'error': f'生成报表失败: {e}'}
|
||||
|
||||
def _call_chat_completion(self, provider, messages, tools=None):
|
||||
"""封装一次 HTTP 调用,返回 (assistant_message_dict, error_str)。
|
||||
同时记录 Token 使用量。
|
||||
@@ -527,10 +591,14 @@ class AiConversation(models.Model):
|
||||
'不要凭空编造结果。不确定字段名时先调用 odoo_fields_get。\n'
|
||||
'所有回答使用中文,简洁清晰。\n'
|
||||
'【格式要求】回复不要使用 Markdown 语法(禁止使用 | 表格、** 加粗、# 标题等)。\n'
|
||||
'列表数据请用编号+冒号格式,每条一行,字段用空格分隔,例如:\n'
|
||||
'1. 张三 研发部 2025-03-01\n'
|
||||
'2. 李四 财务部 2025-03-15\n'
|
||||
'多字段时用“ / ”分隔,保持简洁可读。\n'
|
||||
'少量数据(≤5条)直接用编号列表展示,如:\n'
|
||||
'1. 张三 / 研发部 / 2025-03-01\n'
|
||||
'2. 李四 / 财务部 / 2025-03-15\n'
|
||||
'【报表链接】当查询结果超过5条,或用户要求“列表/报表/明细”时,'
|
||||
'必须调用 generate_report 工具生成报表链接。'
|
||||
'回复时简要说明查询结果并附上链接,如:\n'
|
||||
'查询到 XX 条记录,请点击查看详细数据:\n'
|
||||
'URL链接\n'
|
||||
'查询结果中的时间已经转换为北京时间(UTC+8),直接展示即可,无需再做时区转换。\n\n'
|
||||
'【重要】你具有记忆能力。当对话中发现用户的新偏好、工作内容、习惯等值得长期记住的信息时,'
|
||||
'主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n'
|
||||
@@ -561,6 +629,13 @@ class AiConversation(models.Model):
|
||||
system_msg += manual_content + '\n'
|
||||
system_msg += '当用户询问如何操作某个功能时,优先参考以上手册内容进行指导。\n'
|
||||
|
||||
# —— 注入报表模板 ——
|
||||
templates_info = self.env['ai.report.template'].get_templates_for_ai()
|
||||
if templates_info:
|
||||
system_msg += '\n--- 可用报表模板(调用 generate_report 时可参考)---\n'
|
||||
system_msg += templates_info + '\n'
|
||||
system_msg += '当用户查询相关数据时,优先使用对应模板的字段和条件生成报表。\n'
|
||||
|
||||
if user_context:
|
||||
system_msg += f'\n--- 当前用户信息 ---\n{user_context}\n'
|
||||
messages = [{'role': 'system', 'content': system_msg}]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiReport(models.Model):
|
||||
_name = 'ai.report'
|
||||
_description = '动态报表'
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char('报表名称', required=True)
|
||||
model_name = fields.Char('数据模型', required=True,
|
||||
help='Odoo 模型技术名称,如 sale.order')
|
||||
domain = fields.Text('查询条件', default='[]',
|
||||
help='JSON 格式的 Odoo domain')
|
||||
field_list = fields.Text('显示字段', required=True,
|
||||
help='JSON 数组,如 ["name","date_order","partner_id"]')
|
||||
field_labels = fields.Text('字段标签',
|
||||
help='JSON 对象,如 {"name":"订单号","date_order":"下单日期"}')
|
||||
order = fields.Char('排序', default='id desc')
|
||||
limit = fields.Integer('最大条数', default=200)
|
||||
access_token = fields.Char('访问令牌', index=True, copy=False)
|
||||
active = fields.Boolean(default=True)
|
||||
expire_date = fields.Datetime('过期时间')
|
||||
template_id = fields.Many2one('ai.report.template', string='使用模板')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('access_token'):
|
||||
vals['access_token'] = self._generate_token()
|
||||
if not vals.get('expire_date'):
|
||||
# 默认 7 天后过期
|
||||
vals['expire_date'] = fields.Datetime.now() + timedelta(days=7)
|
||||
return super().create(vals_list)
|
||||
|
||||
@staticmethod
|
||||
def _generate_token():
|
||||
"""生成唯一访问令牌"""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def get_report_url(self):
|
||||
"""获取报表的完整公开 URL"""
|
||||
self.ensure_one()
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
return f"{base_url}/ai/report/{self.access_token}"
|
||||
|
||||
def fetch_data(self):
|
||||
"""执行查询并返回数据(使用 SUPERUSER 权限)"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
model_name = self.model_name
|
||||
if model_name not in self.env:
|
||||
return {'error': f'模型 {model_name} 不存在'}
|
||||
|
||||
domain = json.loads(self.domain or '[]')
|
||||
field_list = json.loads(self.field_list or '[]')
|
||||
order = self.order or 'id desc'
|
||||
limit = self.limit or 200
|
||||
|
||||
Model = self.env[model_name].sudo()
|
||||
records = Model.search_read(domain, field_list, limit=limit, order=order)
|
||||
|
||||
# 获取字段标签
|
||||
labels = {}
|
||||
if self.field_labels:
|
||||
labels = json.loads(self.field_labels)
|
||||
else:
|
||||
# 从模型中获取字段 string
|
||||
fields_info = Model.fields_get(field_list, attributes=['string', 'type'])
|
||||
for fname in field_list:
|
||||
if fname in fields_info:
|
||||
labels[fname] = fields_info[fname].get('string', fname)
|
||||
|
||||
# 处理 Many2one 字段(转为 display_name)
|
||||
fields_info = Model.fields_get(field_list, attributes=['type'])
|
||||
for record in records:
|
||||
for fname in field_list:
|
||||
val = record.get(fname)
|
||||
ftype = fields_info.get(fname, {}).get('type', '')
|
||||
if ftype == 'many2one' and isinstance(val, (list, tuple)):
|
||||
# Many2one 返回 [id, name],取 name
|
||||
record[fname] = val[1] if len(val) > 1 else str(val[0])
|
||||
elif val is False:
|
||||
record[fname] = ''
|
||||
elif ftype in ('date', 'datetime') and val:
|
||||
# 保持字符串格式
|
||||
record[fname] = str(val) if not isinstance(val, str) else val
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'name': self.name,
|
||||
'fields': field_list,
|
||||
'labels': labels,
|
||||
'records': records,
|
||||
'count': len(records),
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.exception('Report fetch_data error: %s', e)
|
||||
return {'error': str(e)}
|
||||
|
||||
def is_valid(self):
|
||||
"""检查报表是否有效(未过期、启用状态)"""
|
||||
self.ensure_one()
|
||||
if not self.active:
|
||||
return False
|
||||
if self.expire_date and self.expire_date < fields.Datetime.now():
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiReportTemplate(models.Model):
|
||||
_name = 'ai.report.template'
|
||||
_description = '报表模板'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char('模板名称', required=True)
|
||||
model_name = fields.Char('数据模型', required=True,
|
||||
help='Odoo 模型技术名称,如 sale.order')
|
||||
field_list = fields.Text('字段列表',
|
||||
help='JSON 数组,如 ["name","date_order","partner_id","amount_total"]')
|
||||
field_labels = fields.Text('字段标签',
|
||||
help='JSON 对象,如 {"name":"订单号","amount_total":"金额"}')
|
||||
default_domain = fields.Text('默认条件', default='[]',
|
||||
help='JSON 格式的默认查询 domain')
|
||||
default_order = fields.Char('默认排序', default='id desc')
|
||||
description = fields.Text('使用说明',
|
||||
help='告诉 AI 何时使用此模板,如"当用户查询销售订单列表时使用此模板"')
|
||||
sequence = fields.Integer('排序', default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.model
|
||||
def get_templates_for_ai(self):
|
||||
"""获取所有启用模板的摘要,供 AI system_msg 注入"""
|
||||
templates = self.sudo().search([('active', '=', True)])
|
||||
if not templates:
|
||||
return ''
|
||||
lines = []
|
||||
for t in templates:
|
||||
fields_str = t.field_list or '[]'
|
||||
lines.append(
|
||||
f'- 模板"{t.name}":模型={t.model_name},字段={fields_str},'
|
||||
f'说明={t.description or "无"}'
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
@@ -17,3 +17,7 @@ access_ai_rule_user,AI规则-用户只读,model_ai_rule,yuthon_ai_agent.group_ai
|
||||
access_ai_rule_admin,AI规则-管理员,model_ai_rule,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
|
||||
access_ai_manual_user,操作手册-用户只读,model_ai_manual,yuthon_ai_agent.group_ai_agent_user,1,0,0,0
|
||||
access_ai_manual_admin,操作手册-管理员,model_ai_manual,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
|
||||
access_ai_report_user,动态报表-用户,model_ai_report,yuthon_ai_agent.group_ai_agent_user,1,0,1,0
|
||||
access_ai_report_admin,动态报表-管理员,model_ai_report,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
|
||||
access_ai_report_template_user,报表模板-用户只读,model_ai_report_template,yuthon_ai_agent.group_ai_agent_user,1,0,0,0
|
||||
access_ai_report_template_admin,报表模板-管理员,model_ai_report_template,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{{REPORT_NAME}}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1a73e8 0%, #4285f4 100%);
|
||||
color: white;
|
||||
padding: 20px 16px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.header .meta {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
.toolbar .count {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.toolbar .refresh-btn {
|
||||
background: #1a73e8;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.toolbar .refresh-btn:active { opacity: 0.7; }
|
||||
.toolbar .refresh-btn.loading { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
/* 表格容器 */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: white;
|
||||
margin: 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 600px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
thead {
|
||||
background: #fafafa;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
th {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #e8e8e8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
word-break: break-all;
|
||||
max-width: 200px;
|
||||
}
|
||||
tr:hover td { background: #f8f9fa; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* 序号列 */
|
||||
.row-num {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-box {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
.loading-box .spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid #e8e8e8;
|
||||
border-top-color: #1a73e8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 空数据 */
|
||||
.empty-box {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
.empty-box .icon { font-size: 48px; margin-bottom: 12px; }
|
||||
|
||||
/* 底部信息 */
|
||||
.footer {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 滚动提示 */
|
||||
.scroll-hint {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
padding: 6px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.scroll-hint { display: block; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 id="reportTitle">{{REPORT_NAME}}</h1>
|
||||
<div class="meta">
|
||||
<span id="updateTime">加载中...</span>
|
||||
<span id="dataSource"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="count" id="recordCount">--</span>
|
||||
<button class="refresh-btn" id="refreshBtn" onclick="loadData()">
|
||||
↻ 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="scroll-hint">← 左右滑动查看更多 →</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="loading-box">
|
||||
<div class="spinner"></div>
|
||||
<div>正在加载数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer" id="footer"></div>
|
||||
|
||||
<script>
|
||||
const DATA_URL = '{{DATA_URL}}';
|
||||
|
||||
async function loadData() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = '↻ 加载中...';
|
||||
|
||||
try {
|
||||
const resp = await fetch(DATA_URL);
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
showError(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
renderTable(data);
|
||||
document.getElementById('updateTime').textContent =
|
||||
'更新: ' + new Date().toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
document.getElementById('recordCount').textContent =
|
||||
'共 ' + data.count + ' 条数据';
|
||||
} catch (e) {
|
||||
showError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '↻ 刷新';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const { fields, labels, records } = data;
|
||||
if (!records || records.length === 0) {
|
||||
document.getElementById('content').innerHTML =
|
||||
'<div class="empty-box"><div class="icon">📄</div><div>暂无数据</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="table-wrapper"><table><thead><tr>';
|
||||
html += '<th class="row-num">#</th>';
|
||||
for (const f of fields) {
|
||||
html += '<th>' + escapeHtml(labels[f] || f) + '</th>';
|
||||
}
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const rec = records[i];
|
||||
html += '<tr><td class="row-num">' + (i + 1) + '</td>';
|
||||
for (const f of fields) {
|
||||
let val = rec[f];
|
||||
if (val === null || val === undefined || val === false) val = '';
|
||||
if (Array.isArray(val)) val = val[1] || val[0] || '';
|
||||
html += '<td>' + escapeHtml(String(val)) + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
document.getElementById('content').innerHTML = html;
|
||||
document.getElementById('footer').textContent =
|
||||
'数据由 AI 助手生成 · 有效期 7 天';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('content').innerHTML =
|
||||
'<div class="empty-box"><div class="icon">⚠</div><div>' +
|
||||
escapeHtml(msg) + '</div></div>';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 页面加载时获取数据
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 动态报表 - 列表视图 -->
|
||||
<!-- ============================================ -->
|
||||
<record id="view_ai_report_tree" model="ir.ui.view">
|
||||
<field name="name">ai.report.tree</field>
|
||||
<field name="model">ai.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="动态报表" default_order="create_date desc">
|
||||
<field name="name"/>
|
||||
<field name="model_name"/>
|
||||
<field name="create_uid" string="创建人"/>
|
||||
<field name="create_date" string="创建时间"/>
|
||||
<field name="expire_date"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 动态报表 - 表单视图 -->
|
||||
<!-- ============================================ -->
|
||||
<record id="view_ai_report_form" model="ir.ui.view">
|
||||
<field name="name">ai.report.form</field>
|
||||
<field name="model">ai.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="动态报表">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="已停用"
|
||||
bg_color="text-bg-danger" invisible="active"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="报表名称"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="查询参数">
|
||||
<field name="model_name"/>
|
||||
<field name="domain"/>
|
||||
<field name="field_list"/>
|
||||
<field name="field_labels"/>
|
||||
<field name="order"/>
|
||||
<field name="limit"/>
|
||||
</group>
|
||||
<group string="访问设置">
|
||||
<field name="access_token" readonly="1"/>
|
||||
<field name="expire_date"/>
|
||||
<field name="template_id"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- 搜索视图 -->
|
||||
<record id="view_ai_report_search" model="ir.ui.view">
|
||||
<field name="name">ai.report.search</field>
|
||||
<field name="model">ai.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="搜索报表">
|
||||
<field name="name"/>
|
||||
<field name="model_name"/>
|
||||
<filter name="filter_active" string="有效"
|
||||
domain="[('active','=',True),('expire_date','>=', context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter name="filter_expired" string="已过期"
|
||||
domain="[('expire_date','<', context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_model" string="数据模型"
|
||||
context="{'group_by': 'model_name'}"/>
|
||||
<filter name="group_user" string="创建人"
|
||||
context="{'group_by': 'create_uid'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ai_report" model="ir.actions.act_window">
|
||||
<field name="name">动态报表</field>
|
||||
<field name="res_model">ai.report</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<!-- 菜单 → 系统配置 -->
|
||||
<menuitem id="menu_ai_report" name="动态报表"
|
||||
parent="menu_system_config" action="action_ai_report" sequence="4"
|
||||
groups="yuthon_ai_agent.group_ai_agent_admin"/>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 报表模板 - 列表视图 -->
|
||||
<!-- ============================================ -->
|
||||
<record id="view_ai_report_template_tree" model="ir.ui.view">
|
||||
<field name="name">ai.report.template.tree</field>
|
||||
<field name="model">ai.report.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="报表模板">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="model_name"/>
|
||||
<field name="description"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 报表模板 - 表单视图 -->
|
||||
<!-- ============================================ -->
|
||||
<record id="view_ai_report_template_form" model="ir.ui.view">
|
||||
<field name="name">ai.report.template.form</field>
|
||||
<field name="model">ai.report.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="报表模板">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="已停用"
|
||||
bg_color="text-bg-danger" invisible="active"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="如:销售订单列表模板"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="模板配置">
|
||||
<field name="model_name"
|
||||
placeholder="如:sale.order"/>
|
||||
<field name="field_list"
|
||||
placeholder='["name","date_order","partner_id","amount_total","state"]'/>
|
||||
<field name="field_labels"
|
||||
placeholder='{"name":"订单号","date_order":"下单日期","partner_id":"客户","amount_total":"金额","state":"状态"}'/>
|
||||
<field name="default_domain"
|
||||
placeholder="[['state','=','sale']]"/>
|
||||
<field name="default_order"
|
||||
placeholder="date_order desc"/>
|
||||
</group>
|
||||
<group string="其他">
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="使用说明">
|
||||
<field name="description" nolabel="1"
|
||||
placeholder="描述此模板的用途,如:当用户查询销售订单列表时使用此模板"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ai_report_template" model="ir.actions.act_window">
|
||||
<field name="name">报表模板</field>
|
||||
<field name="res_model">ai.report.template</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<!-- 菜单 → 系统配置 -->
|
||||
<menuitem id="menu_ai_report_template" name="报表模板"
|
||||
parent="menu_system_config" action="action_ai_report_template" sequence="5"
|
||||
groups="yuthon_ai_agent.group_ai_agent_admin"/>
|
||||
|
||||
</odoo>
|
||||
@@ -117,10 +117,10 @@
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_wecom_ai_chat"
|
||||
<!-- <menuitem id="menu_wecom_ai_chat"
|
||||
name="企业微信AI对话"
|
||||
parent="yuthon_ai_agent.menu_ai_root"
|
||||
action="action_wecom_ai_chat"
|
||||
sequence="41"
|
||||
groups="base.group_no_one"/>
|
||||
groups="base.group_no_one"/> -->
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user