图片识别

This commit is contained in:
李鹏宇
2026-06-12 14:47:38 +08:00
parent 2c2988642d
commit 2f9330456b
10 changed files with 548 additions and 45 deletions
+80 -3
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import json
import base64
from odoo import http
from odoo.http import request
@@ -16,15 +17,15 @@ class AiChatController(http.Controller):
return None
@http.route('/ai/chat/send', type='json', auth='user', methods=['POST'], csrf=False)
def chat_send(self, conversation_id, message):
"""发送消息"""
def chat_send(self, conversation_id, message, attachment_ids=None):
"""发送消息(支持附件 OCR)"""
err = self._check_ai_access()
if err:
return err
conv = request.env['ai.conversation'].browse(conversation_id)
if not conv.exists():
return {'error': '对话不存在'}
reply = conv.send_message(message)
reply = conv.send_message(message, attachment_ids=attachment_ids)
if isinstance(reply, dict) and 'error' in reply:
return reply
last_msg = conv.message_ids.filtered(lambda m: m.role == 'assistant')[-1:]
@@ -161,3 +162,79 @@ class AiChatController(http.Controller):
'style_preference': profile.style_preference or 'concise',
'memory_count': profile.memory_count,
}
@http.route('/ai/attachment/upload', type='http', auth='user', methods=['POST'], csrf=False)
def attachment_upload(self, **kwargs):
"""上传聊天附件(图片/PDF),调 OCR 提取文字并返回结果。
请求:multipart/form-data,字段名 'file'
返回:JSON { success, attachment_id, filename, mimetype, ocr_text, error }
"""
err = self._check_ai_access()
if err:
return request.make_json_response(err)
uploaded = request.httprequest.files.get('file')
if not uploaded:
return request.make_json_response({'success': False, 'error': '未收到文件'})
# 读取文件内容
raw_bytes = uploaded.read()
filename = uploaded.filename or 'untitled'
mimetype = uploaded.content_type or 'application/octet-stream'
# 限制文件类型
allowed = {'image/png', 'image/jpeg', 'image/jpg', 'image/bmp',
'image/gif', 'application/pdf'}
if mimetype not in allowed:
return request.make_json_response({
'success': False,
'error': f'不支持的文件格式:{mimetype},仅支持 PNG/JPG/BMP/GIF/PDF'
})
# 限制文件大小 (10MB)
max_size = 10 * 1024 * 1024
if len(raw_bytes) > max_size:
return request.make_json_response({
'success': False,
'error': f'文件过大({len(raw_bytes) / 1024 / 1024:.1f}MB),上限 10MB'
})
# 获取当前对话的 Provider OCR 配置
conv_id = kwargs.get('conversation_id')
provider = None
if conv_id:
conv = request.env['ai.conversation'].sudo().browse(int(conv_id))
if conv.exists():
provider = conv.provider_id
# 创建 ir.attachment
attachment = request.env['ir.attachment'].sudo().create({
'name': filename,
'datas': base64.b64encode(raw_bytes).decode('utf-8'),
'mimetype': mimetype,
'res_model': 'ai.message',
'res_id': 0,
})
# OCR 提取文字
ocr_text = ''
if provider and provider.ocr_enabled:
from ..models.ocr_provider import get_ocr_instance
ocr = get_ocr_instance(provider)
if ocr:
result = ocr.smart_recognize(raw_bytes, mimetype)
if result.get('success'):
ocr_text = result.get('text') or ''
attachment.sudo().write({'ai_ocr_content': ocr_text})
else:
# OCR 失败不阻塞上传,前端仍可发送(只是没有文字提取)
pass
return request.make_json_response({
'success': True,
'attachment_id': attachment.id,
'filename': filename,
'mimetype': mimetype,
'ocr_text': ocr_text,
})
+2
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import ai_agent
from . import ai_attachment
from . import ai_user_profile
from . import ai_token_usage
from . import ai_rule
@@ -7,3 +8,4 @@ from . import ai_manual
from . import ai_report
from . import ai_report_template
from . import ai_report_template_config
from . import ocr_provider
+64 -5
View File
@@ -63,6 +63,26 @@ class AiProvider(models.Model):
help='允许 AI 调用 create/write/unlink 工具。建议仅在受信任环境开启。'
)
# OCR 配置
ocr_enabled = fields.Boolean(
string='启用 OCR',
default=False,
help='开启后聊天支持上传图片/PDF,自动调用腾讯云 OCR 提取文字后再发给 AI'
)
ocr_secret_id = fields.Char(
string='OCR SecretId',
help='腾讯云 API 密钥 SecretId,在 https://console.cloud.tencent.com/cam/capi 获取'
)
ocr_secret_key = fields.Char(
string='OCR SecretKey',
help='腾讯云 API 密钥 SecretKey'
)
ocr_region = fields.Char(
string='OCR 地域',
default='ap-guangzhou',
help='腾讯云 OCR 服务地域,如 ap-guangzhou, ap-shanghai, ap-beijing'
)
# 规则配置
rule_ids = fields.Many2many(
'ai.rule', 'ai_rule_provider_rel', 'provider_id', 'rule_id',
@@ -729,8 +749,13 @@ class AiConversation(models.Model):
)
return content
def send_message(self, content):
"""发送用户消息并获取 AI 回复(支持工具调用)。"""
def send_message(self, content, attachment_ids=None):
"""发送用户消息并获取 AI 回复(支持工具调用)。
:param content: 用户输入的文本
:param attachment_ids: 可选,附件 ID 列表。附件会先经 OCR 提取文字,
拼入消息首部再发送给 AI。
"""
self.ensure_one()
# 权限校验
if not self.env.user.has_group('yuthon_ai_agent.group_ai_agent_user'):
@@ -740,12 +765,38 @@ class AiConversation(models.Model):
if not provider or not provider.active:
return {'error': 'AI 服务未配置或已停用。'}
# —— 附件 OCR 预处理 ——
ocr_result = ''
msg_attachment_ids = []
if attachment_ids:
attachments = self.env['ir.attachment'].sudo().browse(attachment_ids).exists()
if attachments:
msg_attachment_ids = [(4, a.id) for a in attachments]
ocr_parts = []
for att in attachments:
att_text = att.ai_ocr_content or ''
if att_text:
ocr_parts.append(
f'【附件:{att.name}】\n'
f'{att_text}\n'
f'--- 附件内容结束 ---'
)
if ocr_parts:
ocr_result = '\n\n'.join(ocr_parts) + '\n\n'
# 构建最终发送给 AI 的用户消息内容
full_content = ocr_result + content if ocr_result else content
# 1. 保存用户消息
self.env['ai.message'].create({
msg_vals = {
'conversation_id': self.id,
'role': 'user',
'content': content,
})
'content': full_content,
}
if msg_attachment_ids:
msg_vals['attachment_ids'] = msg_attachment_ids
msg_vals['ocr_content'] = ocr_result
self.env['ai.message'].create(msg_vals)
# 2. 构建发送给模型的 messages
# 获取用户画像
@@ -913,4 +964,12 @@ class AiMessage(models.Model):
('system', '系统'),
], string='角色', required=True)
content = fields.Text(string='内容', required=True)
attachment_ids = fields.Many2many(
'ir.attachment', 'ai_message_attachment_rel',
'message_id', 'attachment_id', string='附件'
)
ocr_content = fields.Text(
string='OCR 识别结果',
help='附件图片/PDF 经 OCR 提取的文本内容'
)
create_uid = fields.Many2one('res.users', string='发送者', default=lambda self: self.env.uid)
@@ -74,6 +74,45 @@
font-size: 13px;
}
/* 附件上传按钮 */
.ai-chat-attach-btn {
flex-shrink: 0;
}
/* 附件预览区 */
.ai-chat-attach-preview {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
background: #f8f9fa;
}
.ai-chat-attach-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 12px;
color: #495057;
}
.ai-chat-attach-remove {
background: none;
border: none;
color: #adb5bd;
cursor: pointer;
padding: 0 2px;
font-size: 10px;
line-height: 1;
border-radius: 3px;
}
.ai-chat-attach-remove:hover {
color: #dc3545;
background: #f8d7da;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
@@ -246,3 +246,72 @@
background: #d1d5db;
cursor: not-allowed;
}
/* 附件上传按钮 */
.o_ai_attach_btn {
background: none;
border: 1px solid #d1d5db;
border-radius: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #6b7280;
transition: all 0.2s;
flex-shrink: 0;
}
.o_ai_attach_btn:hover {
background: #f3f4f6;
border-color: #7c3aed;
color: #7c3aed;
}
/* 附件预览区 */
.o_ai_attach_preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 12px;
background: #f8f7f4;
border-top: 1px solid #e5e7eb;
flex-shrink: 0;
}
.o_ai_attach_item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 12px;
max-width: 200px;
}
.o_ai_attach_icon {
color: #7c3aed;
font-size: 13px;
flex-shrink: 0;
}
.o_ai_attach_name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #374151;
}
.o_ai_attach_remove {
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0 2px;
font-size: 10px;
border-radius: 3px;
flex-shrink: 0;
line-height: 1;
}
.o_ai_attach_remove:hover {
color: #ef4444;
background: #fef2f2;
}
@@ -15,6 +15,7 @@ export class AiChatAction extends Component {
inputText: "",
loading: false,
title: "AI 对话",
attachments: [], // {attachment_id, filename, mimetype}
});
const params = this.props.action?.params || this.props.action?.context || {};
this.resId = params.res_id || params.default_res_id || 0;
@@ -46,24 +47,108 @@ export class AiChatAction extends Component {
}
}
// ---- 文件上传 ----
triggerFileInput() {
const el = document.getElementById("ai_chat_file_input");
if (el) el.click();
}
onFileSelected(ev) {
const file = ev.target.files[0];
if (!file) return;
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif", "application/pdf"];
if (!allowed.includes(file.type)) {
alert("仅支持 PNG、JPG、BMP、GIF、PDF 文件");
ev.target.value = "";
return;
}
if (file.size > 10 * 1024 * 1024) {
alert("文件不能超过 10MB");
ev.target.value = "";
return;
}
this._uploadFile(file);
ev.target.value = "";
}
onPaste(ev) {
const items = (ev.clipboardData || ev.originalEvent.clipboardData).items;
for (const item of items) {
if (item.kind === "file" && item.type.startsWith("image/")) {
ev.preventDefault();
this._uploadFile(item.getAsFile());
return;
}
}
}
async _uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
if (this.resId) {
formData.append("conversation_id", this.resId);
}
try {
const resp = await fetch("/ai/attachment/upload", {
method: "POST",
body: formData,
});
const data = await resp.json();
if (data.success) {
this.state.attachments = [
...this.state.attachments,
{
attachment_id: data.attachment_id,
filename: data.filename,
mimetype: data.mimetype,
},
];
} else {
alert(data.error || "上传失败");
}
} catch (e) {
console.error("[AI] Upload failed:", e);
alert("上传失败,请检查网络");
}
}
removeAttachment(idx) {
this.state.attachments = this.state.attachments.filter((_, i) => i !== idx);
}
// ---- 发送消息 ----
async sendMessage() {
const text = this.state.inputText.trim();
if (!text) return;
const hasAttachments = this.state.attachments.length > 0;
if (!text && !hasAttachments) return;
if (!this.resId) return;
this.state.inputText = "";
// 收集附件 ID
const attIds = this.state.attachments.map(a => a.attachment_id);
// 构建显示文本
let displayText = text || "";
if (hasAttachments) {
const fileNames = this.state.attachments.map(a => a.filename).join("、");
displayText = text ? "[附件: " + fileNames + "]\n" + text : "[附件: " + fileNames + "]";
}
this.state.inputText = "";
this.state.attachments = [];
this.state.loading = true;
this.state.messages = [
...this.state.messages,
{ role: "user", content: text, date: "刚刚" },
{ role: "user", content: displayText, date: "刚刚" },
];
try {
const res = await this.rpc("/ai/chat/send", {
conversation_id: this.resId,
message: text,
});
const params = { conversation_id: this.resId, message: displayText };
if (attIds.length > 0) {
params.attachment_ids = attIds;
}
const res = await this.rpc("/ai/chat/send", params);
if (res.error) {
this.state.messages = [
...this.state.messages,
@@ -95,6 +180,7 @@ export class AiChatAction extends Component {
if (!confirm("确定清空所有消息?")) return;
this.rpc("/ai/chat/clear", { conversation_id: this.resId });
this.state.messages = [];
this.state.attachments = [];
}
}
+152 -12
View File
@@ -7,8 +7,8 @@ import { onMounted } from "@odoo/owl";
/**
* AI 浮动聊天窗口 — 右下角弹出式聊天面板
* 支持对话切换、新建对话
* Odoo 17 版本 — 使用 jsonrpc
* 支持对话切换、新建对话、附件上传(图片/PDF → OCR 识别)
* Odoo 17 版本 — 使用 jsonrpc + fetch (文件上传)
*/
let convId = null;
@@ -17,6 +17,7 @@ let chatEl = null;
let isVisible = false;
let isExpanded = false;
let showConvList = false;
let pendingAttachments = []; // {attachment_id, filename, mimetype} 待发送附件
function rpcCall(url, params) {
return jsonrpc(url, params || {});
@@ -64,6 +65,7 @@ async function createNewConv() {
convId = res.id;
convName = res.name || "新对话";
showConvList = false;
pendingAttachments = [];
renderChat();
}
} catch (e) {
@@ -75,9 +77,110 @@ async function switchConv(id, name) {
convId = id;
convName = name || "对话";
showConvList = false;
pendingAttachments = [];
renderChat();
}
// ---- 文件上传 ----
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
if (convId) {
formData.append("conversation_id", convId);
}
try {
const resp = await fetch("/ai/attachment/upload", {
method: "POST",
body: formData,
});
const data = await resp.json();
if (data.success) {
pendingAttachments.push({
attachment_id: data.attachment_id,
filename: data.filename,
mimetype: data.mimetype,
ocr_text: data.ocr_text || "",
});
renderAttachmentPreview();
} else {
alert(data.error || "上传失败");
}
} catch (e) {
console.error("[AI] Upload failed:", e);
alert("上传失败,请检查网络");
}
}
function triggerFileInput() {
const input = document.getElementById("yuthon_file_input");
if (input) input.click();
}
function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif", "application/pdf"];
if (!allowed.includes(file.type)) {
alert("仅支持 PNG、JPG、BMP、GIF、PDF 文件");
e.target.value = "";
return;
}
if (file.size > 10 * 1024 * 1024) {
alert("文件不能超过 10MB");
e.target.value = "";
return;
}
uploadFile(file);
e.target.value = "";
}
function onChatPaste(e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (const item of items) {
if (item.kind === "file" && item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
uploadFile(file);
return;
}
}
}
function renderAttachmentPreview() {
const container = document.getElementById("yuthon_attach_preview");
if (!container) return;
if (pendingAttachments.length === 0) {
container.innerHTML = "";
container.style.display = "none";
return;
}
container.style.display = "flex";
container.innerHTML = pendingAttachments.map((att, idx) => {
const isImage = att.mimetype.startsWith("image/");
const icon = isImage ? "fa-file-image-o" : "fa-file-pdf-o";
return `
<div class="o_ai_attach_item" title="${escapeHtml(att.filename)}">
<i class="fa ${icon} o_ai_attach_icon"></i>
<span class="o_ai_attach_name">${escapeHtml(att.filename)}</span>
<button class="o_ai_attach_remove" data-idx="${idx}" title="移除">
<i class="fa fa-times"></i>
</button>
</div>
`;
}).join("");
container.querySelectorAll(".o_ai_attach_remove").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
pendingAttachments.splice(idx, 1);
renderAttachmentPreview();
});
});
}
// ---- 渲染 ----
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
@@ -133,9 +236,17 @@ function renderChat() {
</div>
`).join('')}
</div>
<div id="yuthon_attach_preview" class="o_ai_attach_preview" style="display:${showConvList ? 'none' : 'flex'}">
</div>
<div class="o_ai_input_area" style="display:${showConvList ? 'none' : 'flex'}">
<button id="yuthon_attach_btn" class="o_ai_attach_btn" title="上传图片或PDF">
<i class="fa fa-paperclip"></i>
</button>
<input type="file" id="yuthon_file_input"
accept="image/png,image/jpeg,image/jpg,image/bmp,image/gif,application/pdf"
style="display:none"/>
<textarea id="yuthon_chat_input" class="o_ai_input"
placeholder="输入消息..." rows="1"></textarea>
placeholder="输入消息... 支持粘贴图片" rows="1"></textarea>
<button id="yuthon_chat_send" class="o_ai_send_btn">
<i class="fa fa-paper-plane"></i>
</button>
@@ -150,9 +261,19 @@ function renderChat() {
document.getElementById("yuthon_chat_send")?.addEventListener("click", sendMsg);
document.getElementById("yuthon_conv_switch")?.addEventListener("click", toggleConvList);
document.getElementById("yuthon_conv_new")?.addEventListener("click", createNewConv);
document.getElementById("yuthon_chat_input")?.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
document.getElementById("yuthon_attach_btn")?.addEventListener("click", triggerFileInput);
document.getElementById("yuthon_file_input")?.addEventListener("change", onFileSelected);
const inputEl = document.getElementById("yuthon_chat_input");
if (inputEl) {
inputEl.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
inputEl.addEventListener("paste", onChatPaste);
}
// 恢复附件预览
renderAttachmentPreview();
// 滚动到底部
const msgsEl = document.getElementById("yuthon_chat_msgs");
@@ -197,6 +318,7 @@ async function loadConvListUI() {
if (delId === convId) {
convId = null;
convName = "";
pendingAttachments = [];
}
loadConvListUI();
} catch (err) {
@@ -214,17 +336,28 @@ function toggleConvList() {
async function sendMsg() {
const input = document.getElementById("yuthon_chat_input");
const text = input?.value.trim();
if (!text) return;
const hasAttachments = pendingAttachments.length > 0;
if (!text && !hasAttachments) return;
input.value = "";
const id = await ensureConv();
if (!id) return;
// 收集附件 ID 列表
const attIds = pendingAttachments.map(a => a.attachment_id);
// 构建显示文本(用户消息气泡)
let displayText = text || "";
if (hasAttachments) {
const fileNames = pendingAttachments.map(a => a.filename).join("、");
displayText = (text ? "[附件: " + fileNames + "]\n" + text : "[附件: " + fileNames + "]");
}
const msgsEl = document.getElementById("yuthon_chat_msgs");
if (msgsEl) {
msgsEl.innerHTML += `
<div class="o_ai_msg o_ai_msg_user">
<div class="o_ai_msg_bubble">${escapeHtml(text)}</div>
<div class="o_ai_msg_bubble">${escapeHtml(displayText)}</div>
<div class="o_ai_msg_time">刚刚</div>
</div>
<div id="yuthon_loading" class="o_ai_loading">
@@ -234,6 +367,10 @@ async function sendMsg() {
msgsEl.scrollTop = msgsEl.scrollHeight;
}
// 清空附件列表
pendingAttachments = [];
renderAttachmentPreview();
const loadingTimer = setTimeout(() => {
const el = document.getElementById("yuthon_loading");
if (el) {
@@ -241,11 +378,14 @@ async function sendMsg() {
}
}, 5000);
// 构建请求参数
const params = { conversation_id: id, message: displayText };
if (attIds.length > 0) {
params.attachment_ids = attIds;
}
try {
const res = await rpcCall("/ai/chat/send", {
conversation_id: id,
message: text,
});
const res = await rpcCall("/ai/chat/send", params);
clearTimeout(loadingTimer);
const content = res.error || res.content || "无响应";
document.getElementById("yuthon_loading")?.remove();
@@ -24,12 +24,39 @@
</div>
</div>
<!-- 附件预览 -->
<div t-if="state.attachments.length > 0" class="ai-chat-attach-preview p-2 border-top">
<t t-foreach="state.attachments" t-as="att" t-key="att_index">
<span class="ai-chat-attach-item">
<t t-if="att.mimetype.startsWith('image/')">
<i class="fa fa-file-image-o me-1"/>
</t>
<t t-else="">
<i class="fa fa-file-pdf-o me-1"/>
</t>
<t t-esc="att.filename"/>
<button class="ai-chat-attach-remove" t-on-click="() => removeAttachment(att_index)">
<i class="fa fa-times"/>
</button>
</span>
</t>
</div>
<!-- 输入区 -->
<div class="ai-chat-input-area p-2 border-top d-flex gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm ai-chat-attach-btn"
t-on-click="triggerFileInput" title="上传图片或PDF(支持 Ctrl+V 粘贴图片)">
<i class="fa fa-paperclip"/>
</button>
<input type="file" id="ai_chat_file_input"
accept="image/png,image/jpeg,image/jpg,image/bmp,image/gif,application/pdf"
t-on-change="onFileSelected"
style="display:none"/>
<input type="text" class="form-control"
placeholder="输入消息,按 Enter 发送..."
placeholder="输入消息,按 Enter 发送... 支持粘贴图片"
t-model="state.inputText"
t-on-keydown="onKeydown"
t-on-paste="onPaste"
t-att-disabled="state.loading"/>
<button class="btn btn-primary"
t-on-click="sendMessage"
@@ -117,6 +117,26 @@
绑定的规则将根据用户组自动生效:回复指令 → 注入 AI 系统提示词;限制规则 → 禁止回答特定话题;技能接口 → 注册为 AI 可调用工具。
</p>
</page>
<page string="OCR 配置" name="ocr">
<group string="腾讯云 OCR(附件图片/PDF 文字识别)">
<group>
<field name="ocr_enabled"/>
</group>
<group>
<field name="ocr_region"/>
</group>
</group>
<group string="API 密钥" invisible="not ocr_enabled">
<field name="ocr_secret_id"
placeholder="在 https://console.cloud.tencent.com/cam/capi 获取"/>
<field name="ocr_secret_key" password="True"
placeholder="腾讯云 SecretKey"/>
</group>
<p class="text-muted" invisible="not ocr_enabled">
密钥在 <a href="https://console.cloud.tencent.com/cam/capi" target="_blank">腾讯云 API 密钥管理</a> 获取。
每月免费 1000 次调用,超出按量计费(约 0.15 元/次)。
</p>
</page>
</notebook>
</sheet>
</form>
+1 -17
View File
@@ -223,23 +223,7 @@
</field>
</page>
<page string="图表" name="charts">
<field name="chart_ids" nolabel="1">
<tree string="图表配置" editable="bottom">
<field name="model_id" column_invisible="1"/>
<field name="title" placeholder="图表标题"/>
<field name="chart_type"/>
<field name="label_field_id"
placeholder="分组字段..."
options="{'no_create': True}"/>
<field name="value_field_id"
placeholder="数值字段(可空)..."
options="{'no_create': True}"/>
<field name="calc_method"/>
<field name="sequence" widget="handle"/>
</tree>
</field>
</page>
<!-- 图表 tab 已隐藏:前端H5报表自带快速图表控件,无需后端配置 -->
<page string="AI 参数" name="params" invisible="1">
<field name="param_def_ids" nolabel="1">