菜单跳转

This commit is contained in:
李鹏宇
2026-07-01 17:39:55 +08:00
parent 5b071cc856
commit b40234a30e
3 changed files with 178 additions and 50 deletions
+157 -43
View File
@@ -245,7 +245,10 @@ class AiConversation(models.Model):
'description': (
'引导用户跳转到 Odoo 指定页面(菜单/列表/表单/看板)。\n'
'当用户说"打开XX页面"/"帮我打开XX"/"跳转到XX"/"去XX"/"查看XX列表"时调用。\n'
'调用后聊天框会显示一个可点击的操作按钮,用户点击即可跳转。'
'调用后聊天框会显示一个可点击的操作按钮,用户点击即可跳转。\n'
'【精确跳转】如果用户说出了菜单层级(如"会议日程下的日程管理"),'
'请使用 menu_path 参数,格式为"父菜单/子菜单";'
'如果只是模糊说"日程管理",请使用 menu_name 参数。'
),
'parameters': {
'type': 'object',
@@ -272,6 +275,18 @@ class AiConversation(models.Model):
'description': '可选,列表视图的筛选条件,如 [["state","=","draft"]]',
'items': {},
},
'menu_path': {
'type': 'string',
'description': '【精确跳转】菜单层级路径,如"会议日程/日程管理"、"销售/销售订单/报价单"',
},
'menu_name': {
'type': 'string',
'description': '【模糊匹配】目标菜单名称,如"日程管理"、"销售订单"',
},
'menu_xmlid': {
'type': 'string',
'description': '目标菜单的完整 XML ID(如 meeting_schedule.menu_schedule)'
},
},
'required': ['action_type'],
},
@@ -534,6 +549,85 @@ class AiConversation(models.Model):
return rule.execute_skill(args)
return None
def _get_menu_action_by_path_or_name(self, menu_path=None, menu_name=None, menu_xmlid=None):
"""根据菜单路径/名称/xmlid 查找对应的 ir.ui.menu 及其 action,返回 (action_dict, menu_path_str)。
匹配优先级:
1. menu_xmlid 精确匹配
2. menu_path 按层级匹配,如 "会议日程/日程管理"
3. menu_name 模糊匹配
"""
Menu = self.env['ir.ui.menu'].sudo()
menu = None
path_str = menu_name or menu_path or ''
# 1) 按 xmlid 精确匹配
if menu_xmlid:
try:
menu = self.env.ref(menu_xmlid, raise_if_not_found=False)
if menu and menu._name == 'ir.ui.menu':
path_parts = []
m = menu
while m:
path_parts.append(m.name)
m = m.parent_id
path_str = ' > '.join(reversed(path_parts))
except Exception as e:
_logger.warning('[AI-NAV] 解析 menu_xmlid %s 失败: %s', menu_xmlid, e)
menu = None
# 2) 按 menu_path 层级匹配
if not menu and menu_path:
parts = [p.strip() for p in menu_path.replace('>', '/').split('/') if p.strip()]
if parts:
target_name = parts[-1]
candidates = Menu.search([('name', 'ilike', target_name)])
for cand in candidates:
cand_parts = []
m = cand
while m:
cand_parts.append(m.name)
m = m.parent_id
full_path = ' > '.join(reversed(cand_parts))
# 所有给定层级名称都出现在完整路径里即可
if all(p in full_path for p in parts):
menu = cand
path_str = full_path
break
# 3) 按 menu_name 模糊匹配
if not menu and menu_name:
# 优先匹配叶子菜单,再按 sequence/id 排序取第一条
menu = Menu.search([('name', 'ilike', menu_name)], limit=1, order='sequence,id')
if menu:
cand_parts = []
m = menu
while m:
cand_parts.append(m.name)
m = m.parent_id
path_str = ' > '.join(reversed(cand_parts))
if not menu or not menu.action:
return None, path_str
# 读取 action 字典并清理前端不需要的字段,只保留 doAction 必需字段
action_ref = menu.action
raw_action = action_ref.sudo().read()[0]
keep_keys = {
'name', 'type', 'target', 'res_model', 'res_id', 'view_mode', 'views',
'domain', 'context', 'search_view_id', 'limit', 'binding_type',
}
action = {k: raw_action[k] for k in keep_keys if k in raw_action and raw_action[k] not in (None, False, [])}
# 处理 context 字符串
if 'context' in action and isinstance(action['context'], str):
try:
from ast import literal_eval
action['context'] = literal_eval(action['context'])
except Exception:
action['context'] = {}
action['type'] = action_ref._name
return action, path_str
def _execute_navigate_to(self, args):
"""构建 Odoo action 字典,供前端调用 doAction 跳转。
返回 {'__action__': True, 'action': {...}, 'message': '...'},
@@ -543,49 +637,62 @@ class AiConversation(models.Model):
res_id = args.get('res_id')
name = args.get('name', '')
domain = args.get('domain') or []
menu_path = args.get('menu_path', '')
menu_name = args.get('menu_name', '')
menu_xmlid = args.get('menu_xmlid', '')
action = {
'type': 'ir.actions.act_window',
'name': name or (model and f'{model} 列表'),
'target': 'current',
}
action = None
menu_path_str = ''
if action_type == 'menu':
# 菜单跳转:直接用菜单的 xmlid 或 action id
menu_xmlid = args.get('menu_xmlid', '')
if menu_xmlid:
action = {
'type': 'ir.actions.client',
'tag': 'menu',
'params': {'menu_xmlid': menu_xmlid},
}
# 优先按菜单路径/名称/xmlid 精确解析
if menu_path or menu_name or menu_xmlid:
action, menu_path_str = self._get_menu_action_by_path_or_name(
menu_path=menu_path, menu_name=menu_name, menu_xmlid=menu_xmlid
)
if not action:
# 如果是按菜单路径没找到,不要直接失败,降级到按 model 匹配
_logger.warning(
'[AI-NAV] 未找到菜单 path=%s name=%s xmlid=%s,降级到 model=%s',
menu_path, menu_name, menu_xmlid, model
)
# 按 model 构建通用 action(兜底)
if not action:
action = {
'type': 'ir.actions.act_window',
'name': name or (model and f'{model} 列表'),
'target': 'current',
}
if action_type == 'menu':
# menu 类型但没提供 menu_xmlid 且没有按路径匹配到
if not model:
return {'error': 'menu 类型需要提供 menu_xmlid/menu_path,或提供 model 作为兜底'}
action['res_model'] = model
action['view_mode'] = 'list,kanban,form'
if domain:
action['domain'] = domain
elif action_type == 'kanban':
if not model:
return {'error': 'kanban 类型需要提供 model 参数'}
action['res_model'] = model
action['view_mode'] = 'kanban,list,form'
if domain:
action['domain'] = domain
elif action_type == 'form':
if not model or not res_id:
return {'error': 'form 类型需要提供 model 和 res_id 参数'}
action['res_model'] = model
action['res_id'] = res_id
action['view_mode'] = 'form'
action['views'] = [(False, 'form')]
else:
return {'error': 'menu 类型需要提供 menu_xmlid 参数'}
elif action_type == 'kanban':
if not model:
return {'error': 'kanban 类型需要提供 model 参数'}
action['res_model'] = model
action['view_mode'] = 'kanban,list,form'
if domain:
action['domain'] = domain
elif action_type == 'form':
if not model or not res_id:
return {'error': 'form 类型需要提供 model 和 res_id 参数'}
action['res_model'] = model
action['res_id'] = res_id
action['view_mode'] = 'form'
action['views'] = [(False, 'form')]
else:
# list(默认)
if not model:
return {'error': 'list 类型需要提供 model 参数'}
action['res_model'] = model
action['view_mode'] = 'list,kanban,form'
if domain:
action['domain'] = domain
# list(默认)
if not model:
return {'error': 'list 类型需要提供 model 参数'}
action['res_model'] = model
action['view_mode'] = 'list,kanban,form'
if domain:
action['domain'] = domain
# 获取模型的显示名称,用于默认 name
if not action.get('name') and model and model in self.env:
@@ -594,7 +701,11 @@ class AiConversation(models.Model):
if model_desc:
action['name'] = model_desc.name
msg = name or f'已为您打开{action.get("name", model)}'
# 按钮文案:优先真实菜单名(action.name),不用 LLM 传的 name(LLM 可能自己编一个)
btn_label = action.get('name') or name or '打开页面'
msg = btn_label
if menu_path_str and menu_path_str not in btn_label:
msg = f'{btn_label}\n菜单路径:{menu_path_str}'
return {
'__action__': True,
'action': action,
@@ -983,7 +1094,10 @@ class AiConversation(models.Model):
'主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n'
'称呼用户时使用其设定的称呼,不要说"您"、"亲"等通用称谓。\n'
'【页面跳转】当用户要求"打开XX页面"/"跳转到XX"/"帮我看XX列表"/"打开XX的详情"时,'
'调用 navigate_to 工具。调用后对话窗口会显示操作按钮,用户可点击跳转。\n'
'调用 navigate_to 工具。调用前:如果用户说出了菜单层级,请使用 menu_path(如"会议日程/日程管理");'
'如果只说名称,请使用 menu_name。调用后回复里请列出完整菜单路径,如:'
'「菜单路径:会议日程 > 日程管理」。'
'对话窗口会显示操作按钮,用户点击即可跳转。\n'
)
# —— 注入 AI 规则 ——
+13 -4
View File
@@ -61,18 +61,27 @@
/* AI 导航按钮(菜单跳转) */
.ai-chat-action-wrap {
margin-top: 8px;
margin-top: 10px;
}
.ai-chat-action-btn {
border-radius: 6px;
font-size: 13px;
padding: 5px 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
padding: 8px 18px;
line-height: 1.4;
}
.ai-chat-action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,123,255,0.3);
transition: all 0.2s ease;
}
.ai-chat-action-btn i {
font-size: 13px;
}
.ai-chat-input-area input {
flex: 1;
@@ -196,14 +196,16 @@
.o_ai_action_btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
justify-content: center;
gap: 8px;
padding: 9px 18px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -212,6 +214,9 @@
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124,58,237,0.35);
}
.o_ai_action_btn i {
font-size: 13px;
}
.o_ai_loading {
display: flex;
align-items: center;