!11740 ai模块测试

Merge pull request !11740 from 李鹏宇/dev
This commit is contained in:
李鹏宇
2026-05-28 08:10:21 +00:00
committed by Gitee
11 changed files with 792 additions and 6 deletions
+1
View File
@@ -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
View File
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import main
from . import report
+104
View File
@@ -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">&#9888;</div>
<div class="error-msg">{message}</div>
</div>
</body>
</html>'''
+2
View File
@@ -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
+79 -4
View File
@@ -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}]
+115
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_ai_rule_admin AI规则-管理员 model_ai_rule yuthon_ai_agent.group_ai_agent_admin 1 1 1 1
18 access_ai_manual_user 操作手册-用户只读 model_ai_manual yuthon_ai_agent.group_ai_agent_user 1 0 0 0
19 access_ai_manual_admin 操作手册-管理员 model_ai_manual yuthon_ai_agent.group_ai_agent_admin 1 1 1 1
20 access_ai_report_user 动态报表-用户 model_ai_report yuthon_ai_agent.group_ai_agent_user 1 0 1 0
21 access_ai_report_admin 动态报表-管理员 model_ai_report yuthon_ai_agent.group_ai_agent_admin 1 1 1 1
22 access_ai_report_template_user 报表模板-用户只读 model_ai_report_template yuthon_ai_agent.group_ai_agent_user 1 0 0 0
23 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()">
&#8635; 刷新
</button>
</div>
<div class="scroll-hint">&#8592; 左右滑动查看更多 &#8594;</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 = '&#8635; 加载中...';
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 = '&#8635; 刷新';
}
}
function renderTable(data) {
const { fields, labels, records } = data;
if (!records || records.length === 0) {
document.getElementById('content').innerHTML =
'<div class="empty-box"><div class="icon">&#128196;</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">&#9888;</div><div>' +
escapeHtml(msg) + '</div></div>';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 页面加载时获取数据
loadData();
</script>
</body>
</html>
+171
View File
@@ -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','&lt;', 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>
+2 -2
View File
@@ -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>