diff --git a/yuthon_brain/controllers/main.py b/yuthon_brain/controllers/main.py index b23c255..e153d17 100644 --- a/yuthon_brain/controllers/main.py +++ b/yuthon_brain/controllers/main.py @@ -1,11 +1,18 @@ # -*- coding: utf-8 -*- import json from odoo import http +from odoo.exceptions import AccessError from odoo.http import request, Response +def _json(data, status=200): + """统一的 JSON 响应。""" + return Response(json.dumps(data, ensure_ascii=False), + mimetype='application/json', status=status) + + class BrainController(http.Controller): - """只读控制器:前端脑图页面与查询接口,无任何写操作。""" + """前端脑图页面与数据接口。除“新建节点”外均为只读。""" # ---------------- 只读查询接口(JSON)---------------- @@ -55,6 +62,67 @@ class BrainController(http.Controller): return Response(json.dumps(payload, ensure_ascii=False), mimetype='application/json') + @http.route('/yuthon_brain/api//node', type='http', + auth='user', methods=['POST'], csrf=False) + def api_create_node(self, key, **kw): + """新建脑图节点,body = {"parent_id": , "label": "..."}。 + + 只有系统管理员对 yuthon.brain.node 有 create 权限,其他账号返回 403。 + 前端拖拽端口后调用此接口把新节点落库。 + """ + source = request.env['yuthon.brain.source'].search( + [('key', '=', key), ('active', '=', True)], limit=1) + if not source: + return _json({'error': 'not_found'}, 404) + + try: + payload = json.loads(request.httprequest.get_data(as_text=True) or '{}') + except (ValueError, TypeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + + label = (payload.get('label') or '').strip()[:120] + if not label: + return _json({'error': 'empty_label'}, 400) + + Node = request.env['yuthon.brain.node'] + parent = None + pid = payload.get('parent_id') + if pid is not None and pid != '': + try: + cand = Node.browse(int(pid)) + except (ValueError, TypeError): + cand = Node.browse([]) + # 只允许挂到同一数据源下已存在的节点上 + if cand.exists() and cand.source_id.id == source.id: + parent = cand.id + if parent is None: + parent = source.root_id.id or False + + try: + node = Node.create({ + 'source_id': source.id, + 'parent_id': parent, + 'rel_type': 'child', + 'label': label, + 'auto': False, # 手动节点:重同步时不会被清理 + }) + except AccessError: + return _json({'error': 'forbidden'}, 403) + except Exception as exc: + msg = str(exc).lower() + if 'unique' in msg or 'duplicate' in msg: + return _json({'error': 'duplicate'}, 409) + raise + + return _json({ + 'id': node.id, + 'label': node.label, + 'parent_id': node.parent_id.id or None, + 'rel_type': node.rel_type, + }) + @http.route('/yuthon_brain/api/sources', type='http', auth='user', methods=['GET'], csrf=False) def api_sources(self, **kw): diff --git a/yuthon_brain/static/src/brain_view.html b/yuthon_brain/static/src/brain_view.html index 5315ab5..2a56bb6 100644 --- a/yuthon_brain/static/src/brain_view.html +++ b/yuthon_brain/static/src/brain_view.html @@ -166,6 +166,108 @@ .brain-node:hover text { fill: #05090c; } .brain-node:hover .port { stroke: #31a4e0; opacity: 1; } + /* 可拖拽端口 grip:焦点上下实心蓝点 + 各节点左侧空心圈,拖出即新建分支 */ + .brain-node .grip { pointer-events: none; cursor: crosshair; } + .brain-node.active .grip-top, + .brain-node.active .grip-bottom { pointer-events: all; } + .brain-node .grip-left { pointer-events: all; } + .brain-node .grip-left:hover { stroke: #1f8fd4; opacity: 1; } + .brain-node .grip-top:hover, + .brain-node .grip-bottom:hover { fill: #1a8ed0; } + + /* 拖拽中的橡皮筋曲线 */ + .brain-rubber { + fill: none; + stroke: #7cc0e8; + stroke-width: 1.9; + stroke-linecap: round; + opacity: .95; + } + .brain-rubber-dot { fill: #2f9ee0; opacity: .9; } + + /* 新建节点输入浮层(对齐 TheBrain 的“跳转想法名称”输入条) */ + .brain-creator { + position: absolute; + z-index: 9; + display: flex; + align-items: center; + height: 42px; + background: #ffffff; + border: 1px solid #e6ebf0; + border-radius: 9px; + box-shadow: 0 12px 30px rgba(53, 86, 118, .18); + overflow: hidden; + } + .brain-creator input { + width: 236px; + height: 100%; + border: 0; + outline: 0; + padding: 0 12px; + font-size: 13.5px; + font-family: inherit; + color: #2b3641; + background: transparent; + } + .brain-creator input::placeholder { color: #b9c4cf; } + .brain-creator .cc-add { + width: 36px; + height: 100%; + border: 0; + background: transparent; + color: #b3bfca; + font-size: 19px; + line-height: 1; + cursor: pointer; + transition: color .16s, background .16s; + } + .brain-creator .cc-add:hover { color: #2f9ee0; background: #f6fafd; } + .brain-creator .cc-group { + display: flex; + align-items: center; + height: 100%; + border-left: 1px solid #eef2f6; + } + .brain-creator .cc-tool { + width: 38px; + height: 100%; + border: 0; + border-left: 1px solid #f4f7fa; + background: transparent; + color: #a8b4bf; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color .16s, background .16s; + } + .brain-creator .cc-tool:first-child { border-left: 0; } + .brain-creator .cc-tool:hover { color: #4d5c69; background: #f8fbfd; } + .brain-creator .cc-tool svg { display: block; } + + /* 轻提示条 */ + .brain-toast { + position: absolute; + top: 62px; + left: 50%; + transform: translateX(-50%); + z-index: 10; + max-width: 540px; + padding: 9px 16px; + border-radius: 10px; + font-size: 12.5px; + line-height: 1.6; + color: #6a4a10; + background: rgba(255, 249, 235, .97); + border: 1px solid #f2dfae; + box-shadow: 0 8px 22px rgba(90, 110, 130, .12); + } + .brain-toast.ok { + color: #1f5c38; + background: rgba(240, 251, 244, .97); + border-color: #c7e9d3; + } + /* 连线:浅蓝柔和曲线 */ .brain-edge { fill: none; @@ -282,7 +384,7 @@
点击节点查看详情
-
滚轮缩放 · 拖拽平移 · 点击节点聚焦
+
滚轮缩放 · 拖拽平移 · 点击节点聚焦 · 拖动蓝点新建分支