脑图 #2

Merged
Ghost merged 1 commits from dev_wang into master 2026-09-21 16:27:30 +08:00
2 changed files with 514 additions and 84 deletions
+69 -1
View File
@@ -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/<string:key>/node', type='http',
auth='user', methods=['POST'], csrf=False)
def api_create_node(self, key, **kw):
"""新建脑图节点,body = {"parent_id": <id|null>, "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):
+445 -83
View File
@@ -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 @@
<div class="brain-detail-empty">点击节点查看详情</div>
</aside>
</div>
<div class="brain-hint">滚轮缩放 · 拖拽平移 · 点击节点聚焦</div>
<div class="brain-hint">滚轮缩放 · 拖拽平移 · 点击节点聚焦 · 拖动蓝点新建分支</div>
</div>
<script>
@@ -317,6 +419,8 @@
panX: 0, panY: 0, zoom: 1,
nodeEls: {}, nodeParts: {}, nodeCur: {}, nodeTo: {}, nodeOp: {},
edgeMap: {}, parentSet: {},
manual: {}, // 手动落点(拖拽新建的节点保持在该位置)
idMap: {}, // 临时 id(负数)→ 服务器真实 id
};
function create(tag) { return document.createElementNS(SVGNS, tag); }
@@ -342,8 +446,9 @@
/* ---------------- 画布容器 ---------------- */
var viewport = create('g'); viewport.setAttribute('id', 'brain-viewport');
var gEdges = create('g'); gEdges.setAttribute('id', 'brain-edges');
var gTemp = create('g'); gTemp.setAttribute('id', 'brain-temp'); // 橡皮筋临时层
var gNodes = create('g'); gNodes.setAttribute('id', 'brain-nodes');
viewport.appendChild(gEdges); viewport.appendChild(gNodes);
viewport.appendChild(gEdges); viewport.appendChild(gTemp); viewport.appendChild(gNodes);
svg.appendChild(viewport);
/* ---------------- 提示 ---------------- */
@@ -379,6 +484,8 @@
function build(data) {
state.nodes = {};
state.manual = {}; // 数据全部来自服务器,之前的手动落点作废
state.idMap = {};
(data.nodes || []).forEach(function (n) { state.nodes[n.id] = n; });
// 对称邻接表(父子双向识别,只需写一次 parent_id)
@@ -478,94 +585,108 @@
function buildNodeEls() {
while (gNodes.firstChild) gNodes.removeChild(gNodes.firstChild);
state.nodeEls = {}; state.nodeParts = {}; state.nodeCur = {}; state.nodeTo = {}; state.nodeOp = {};
Object.keys(state.nodes).forEach(function (id) {
var n = state.nodes[id];
var g = create('g');
g.setAttribute('class', 'brain-node lv' + (state.depth[id] || 0));
Object.keys(state.nodes).forEach(function (id) { createNodeEl(id, 0, 0, 0); });
}
var lbl = dispLabel(id);
var fs = fontSizeOf(id);
var tw = lbl.length * fs * 0.96; // 纯文字实际宽度
var w = nodeWidth(id), h = nodeHeight(id);
var hh = hashNum(id);
/* 创建单个节点的 SVG 元素。fromX/fromY/fromOp 指定入场起点,
拖拽新建时传父节点当前位置,新节点就会从父节点“长”出来 */
function createNodeEl(id, fromX, fromY, fromOp) {
var n = state.nodes[id];
if (!n) return null;
var g = create('g');
g.setAttribute('class', 'brain-node lv' + (state.depth[id] || 0));
var ttl = create('title');
ttl.textContent = n.label || ''; // 悬停显示完整名称
var lbl = dispLabel(id);
var fs = fontSizeOf(id);
var tw = lbl.length * fs * 0.96; // 纯文字实际宽度
var w = nodeWidth(id), h = nodeHeight(id);
var hh = hashNum(id);
// 焦点描边框(仅焦点可见)
var frame = create('rect');
frame.setAttribute('class', 'frame');
frame.setAttribute('x', -w / 2);
frame.setAttribute('y', -h / 2);
frame.setAttribute('width', w);
frame.setAttribute('height', h);
frame.setAttribute('rx', 6);
frame.setAttribute('ry', 6);
var ttl = create('title');
ttl.textContent = n.label || ''; // 悬停显示完整名称
g.appendChild(ttl);
g.appendChild(frame);
// 焦点描边框(仅焦点可见)
var frame = create('rect');
frame.setAttribute('class', 'frame');
frame.setAttribute('x', -w / 2);
frame.setAttribute('y', -h / 2);
frame.setAttribute('width', w);
frame.setAttribute('height', h);
frame.setAttribute('rx', 6);
frame.setAttribute('ry', 6);
var c1 = hh % 4, c2 = (hh * 3 + 2) % 4;
if (c2 === c1) c2 = (c1 + 2) % 4;
var picks = ((state.depth[id] || 0) >= 2) ? [c1] : [c1, c2];
var halos = picks.map(function (ci) {
var s = haloSpot(ci, tw, h);
var c = create('circle');
c.setAttribute('class', 'halo');
c.setAttribute('cx', s.dx);
c.setAttribute('cy', s.dy);
c.setAttribute('r', s.r);
g.appendChild(c);
return { ci: ci, el: c };
});
g.appendChild(ttl);
g.appendChild(frame);
// 文字左侧端口空心圈
var port = create('circle');
port.setAttribute('class', 'port');
port.setAttribute('cx', -tw / 2 - 14);
port.setAttribute('cy', 0);
port.setAttribute('r', 4.5);
g.appendChild(port);
// 焦点上下两个实心蓝点(TheBrain 的激活端口)
var bTop = create('circle');
bTop.setAttribute('class', 'bdot');
bTop.setAttribute('cx', 0);
bTop.setAttribute('cy', -h / 2 + 8);
bTop.setAttribute('r', 4.6);
var bBot = create('circle');
bBot.setAttribute('class', 'bdot');
bBot.setAttribute('cx', 0);
bBot.setAttribute('cy', h / 2 + 8);
bBot.setAttribute('r', 4.6);
g.appendChild(bTop); g.appendChild(bBot);
// 上级节点的实心绿点(文字右下)
var gdot = create('circle');
gdot.setAttribute('class', 'gdot');
gdot.setAttribute('cx', tw / 2 + 13);
gdot.setAttribute('cy', h / 2 - 4);
gdot.setAttribute('r', 4.8);
g.appendChild(gdot);
var text = create('text');
text.textContent = lbl;
g.appendChild(text);
(function (nodeId) {
g.addEventListener('click', function (ev) { ev.stopPropagation(); setFocus(nodeId); });
})(id);
gNodes.appendChild(g);
state.nodeEls[id] = g;
state.nodeParts[id] = {
frame: frame, port: port, halos: halos, gdot: gdot,
bTop: bTop, bBot: bBot, text: text
};
// op 必须初始化:否则 _fop=undefined 会让 lerp 算出 NaN,节点透明度失效
state.nodeCur[id] = { x: 0, y: 0, op: 0 };
state.nodeTo[id] = { x: 0, y: 0 };
state.nodeOp[id] = 0;
var c1 = hh % 4, c2 = (hh * 3 + 2) % 4;
if (c2 === c1) c2 = (c1 + 2) % 4;
var picks = ((state.depth[id] || 0) >= 2) ? [c1] : [c1, c2];
var halos = picks.map(function (ci) {
var s = haloSpot(ci, tw, h);
var c = create('circle');
c.setAttribute('class', 'halo');
c.setAttribute('cx', s.dx);
c.setAttribute('cy', s.dy);
c.setAttribute('r', s.r);
g.appendChild(c);
return { ci: ci, el: c };
});
// 文字左侧端口空心圈(可拖拽)
var port = create('circle');
port.setAttribute('class', 'port');
port.setAttribute('cx', -tw / 2 - 14);
port.setAttribute('cy', 0);
port.setAttribute('r', 4.5);
g.appendChild(port);
// 焦点上下两个实心蓝点(TheBrain 的激活端口,可拖拽)
var bTop = create('circle');
bTop.setAttribute('class', 'bdot');
bTop.setAttribute('cx', 0);
bTop.setAttribute('cy', -h / 2 + 8);
bTop.setAttribute('r', 4.6);
var bBot = create('circle');
bBot.setAttribute('class', 'bdot');
bBot.setAttribute('cx', 0);
bBot.setAttribute('cy', h / 2 + 8);
bBot.setAttribute('r', 4.6);
g.appendChild(bTop); g.appendChild(bBot);
// 上级节点的实心绿点(文字右下)
var gdot = create('circle');
gdot.setAttribute('class', 'gdot');
gdot.setAttribute('cx', tw / 2 + 13);
gdot.setAttribute('cy', h / 2 - 4);
gdot.setAttribute('r', 4.8);
g.appendChild(gdot);
var text = create('text');
text.textContent = lbl;
g.appendChild(text);
// 三个拖拽端口:上/下(蓝点)、左(空心圈)
[['top', bTop], ['bottom', bBot], ['left', port]].forEach(function (pair) {
var dot = pair[1], dir = pair[0];
dot.classList.add('grip', 'grip-' + dir);
dot.addEventListener('pointerdown', function (ev) { startGripDrag(id, dir, ev); });
dot.addEventListener('click', function (ev) { ev.stopPropagation(); });
});
(function (nodeId) {
g.addEventListener('click', function (ev) { ev.stopPropagation(); setFocus(nodeId); });
})(id);
gNodes.appendChild(g);
state.nodeEls[id] = g;
state.nodeParts[id] = {
frame: frame, port: port, halos: halos, gdot: gdot,
bTop: bTop, bBot: bBot, text: text
};
// op 必须初始化:否则 _fop=undefined 会让 lerp 算出 NaN,节点透明度失效
state.nodeCur[id] = { x: fromX || 0, y: fromY || 0, op: fromOp || 0 };
state.nodeTo[id] = { x: fromX || 0, y: fromY || 0 };
state.nodeOp[id] = fromOp || 0;
return g;
}
/* 焦点切换会改变字号(焦点放大),此处按新度量刷新该节点的框、装饰点与文字,
@@ -615,6 +736,13 @@
place(children, 'child');
place(jumps, 'jump');
// 拖拽新建的节点保留用户落点(覆盖自动布局计算的位置)
Object.keys(state.manual).forEach(function (id) {
if (state.nodeOp[id] && state.manual[id]) {
state.nodeTo[id] = { x: state.manual[id].x, y: state.manual[id].y };
}
});
// 连线:仅与焦点相连者可见,并按焦点视角着色
Object.keys(state.edgeMap).forEach(function (k) {
var ed = state.edgeMap[k];
@@ -767,6 +895,240 @@
renderDetail();
}
/* ================= 拖拽端口新建节点(对齐 TheBrain) ================= */
var gripDrag = null, rubberEl = null, rubberDot = null;
function screenToWorld(clientX, clientY) {
var r = svg.getBoundingClientRect();
var w = r.width || window.innerWidth, h = r.height || window.innerHeight;
return {
x: (clientX - r.left - w / 2 - state.panX) / state.zoom,
y: (clientY - r.top - h / 2 - state.panY) / state.zoom,
};
}
function worldToScreen(p) {
var r = svg.getBoundingClientRect();
var w = r.width || window.innerWidth, h = r.height || window.innerHeight;
return {
x: w / 2 + state.panX + p.x * state.zoom,
y: h / 2 + state.panY + p.y * state.zoom,
};
}
// 端口在画布中的世界坐标(节点中心 + 端口偏移)
function gripAnchor(id, dir) {
var cur = state.nodeCur[id] || { x: 0, y: 0 };
var ox = 0, oy = 0;
if (dir === 'top') oy = -nodeHeight(id) / 2 + 8;
else if (dir === 'bottom') oy = nodeHeight(id) / 2 + 8;
else ox = -textWidth(id) / 2 - 14;
return { x: cur.x + ox, y: cur.y + oy };
}
function ensureRubber() {
if (!rubberEl) {
rubberEl = create('path');
rubberEl.setAttribute('class', 'brain-rubber');
rubberDot = create('circle');
rubberDot.setAttribute('class', 'brain-rubber-dot');
rubberDot.setAttribute('r', 5);
}
if (!rubberEl.parentNode) gTemp.appendChild(rubberEl);
if (!rubberDot.parentNode) gTemp.appendChild(rubberDot);
}
function clearRubber() {
while (gTemp.firstChild) gTemp.removeChild(gTemp.firstChild);
}
function drawRubber(ax, ay, bx, by, dir) {
var d;
if (dir === 'left') { // 水平出发
var mx = (ax + bx) / 2;
d = 'M ' + ax + ' ' + ay + ' C ' + mx + ' ' + ay + ', ' + mx + ' ' + by + ', ' + bx + ' ' + by;
} else { // 垂直出发
var my = (ay + by) / 2;
d = 'M ' + ax + ' ' + ay + ' C ' + ax + ' ' + my + ', ' + bx + ' ' + my + ', ' + bx + ' ' + by;
}
rubberEl.setAttribute('d', d);
rubberDot.setAttribute('cx', bx);
rubberDot.setAttribute('cy', by);
}
function startGripDrag(id, dir, ev) {
if (ev.button) return; // 只响应左键
ev.stopPropagation(); // 阻止画布平移
ev.preventDefault();
var a = gripAnchor(id, dir);
gripDrag = {
src: id, dir: dir, ax: a.x, ay: a.y, moved: false,
cx0: ev.clientX, cy0: ev.clientY,
};
window.addEventListener('pointermove', onGripMove);
window.addEventListener('pointerup', onGripUp);
}
function onGripMove(ev) {
if (!gripDrag) return;
if (Math.abs(ev.clientX - gripDrag.cx0) + Math.abs(ev.clientY - gripDrag.cy0) > 5) {
gripDrag.moved = true;
}
var w = screenToWorld(ev.clientX, ev.clientY);
ensureRubber();
drawRubber(gripDrag.ax, gripDrag.ay, w.x, w.y, gripDrag.dir);
}
function onGripUp(ev) {
if (!gripDrag) return;
var d = gripDrag; gripDrag = null;
window.removeEventListener('pointermove', onGripMove);
window.removeEventListener('pointerup', onGripUp);
clearRubber();
if (!d.moved) return; // 只是点了一下端口,不弹输入框
openCreator(d.src, screenToWorld(ev.clientX, ev.clientY));
}
/* ---------------- 新建节点输入浮层 ---------------- */
var ICONS = {
type: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25"><circle cx="5" cy="5" r="2.5"/><rect x="9.7" y="2.5" width="4.4" height="4.4" rx="1"/><circle cx="5" cy="12" r="2.5"/><rect x="9.7" y="9.7" width="4.4" height="4.4" rx="1"/></svg>',
tag: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25"><path d="M2.2 7.4V3.6c0-.8.6-1.4 1.4-1.4h3.8l6.2 6.2-5.2 5.2z"/><circle cx="5.2" cy="5.2" r="1.05" fill="currentColor" stroke="none"/></svg>',
clock: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25"><circle cx="8" cy="8" r="5.7"/><path d="M8 5.1V8l2 1.4"/></svg>',
pin: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25"><path d="M6.1 2h3.8l-.7 3.3 2.1 2.1v1.3H4.7V7.4l2.1-2.1z"/><path d="M8 8.7V14"/></svg>',
};
var creatorEl = null, creatorCtx = null;
function openCreator(srcId, worldPos) {
closeCreator();
var box = document.createElement('div');
box.className = 'brain-creator';
box.innerHTML =
'<input type="text" maxlength="60" placeholder="跳转想法名称" />' +
'<button class="cc-add" title="创建(回车)">+</button>' +
'<div class="cc-group">' +
'<button class="cc-tool" title="想法类型(示例,未启用)">' + ICONS.type + '</button>' +
'<button class="cc-tool" title="标签(示例,未启用)">' + ICONS.tag + '</button>' +
'<button class="cc-tool" title="时间(示例,未启用)">' + ICONS.clock + '</button>' +
'<button class="cc-tool" title="引脚(示例,未启用)">' + ICONS.pin + '</button>' +
'</div>';
stage.appendChild(box);
creatorEl = box;
creatorCtx = { src: srcId, world: worldPos };
var r = svg.getBoundingClientRect();
var s = worldToScreen(worldPos);
var bw = box.offsetWidth || 402, bh = box.offsetHeight || 42;
var left = s.x + 16, top = s.y - bh / 2;
if (left + bw > r.width - 14) left = Math.max(12, s.x - bw - 16); // 右侧放不下就翻到左边
top = Math.max(58, Math.min(top, r.height - bh - 20));
box.style.left = left + 'px';
box.style.top = top + 'px';
var input = box.querySelector('input');
box.querySelector('.cc-add').addEventListener('click', function (ev) {
ev.stopPropagation(); submitCreator();
});
Array.prototype.forEach.call(box.querySelectorAll('.cc-tool'), function (b) {
b.addEventListener('click', function (ev) { ev.stopPropagation(); });
});
box.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
input.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') { ev.preventDefault(); submitCreator(); }
else if (ev.key === 'Escape') { ev.preventDefault(); closeCreator(); }
});
document.addEventListener('pointerdown', onDocPointerDown);
input.focus();
}
function onDocPointerDown(ev) {
if (creatorEl && !creatorEl.contains(ev.target)) closeCreator();
}
function closeCreator() {
document.removeEventListener('pointerdown', onDocPointerDown);
if (creatorEl && creatorEl.parentNode) creatorEl.parentNode.removeChild(creatorEl);
creatorEl = null; creatorCtx = null;
}
function submitCreator() {
if (!creatorEl || !creatorCtx) return;
var input = creatorEl.querySelector('input');
var text = (input.value || '').trim();
if (!text) { input.focus(); return; }
var ctx = creatorCtx;
closeCreator();
createChildNode(ctx.src, text, ctx.world);
}
/* ---------------- 本地新建节点 + 落库 ---------------- */
var tmpSeq = 0;
function createChildNode(srcId, label, worldPos) {
var srcNum = toNum(srcId);
var cur = state.nodeCur[srcId] || { x: 0, y: 0 };
// 落点距源节点限制在 150~560,避免拖太远把整张图缩得很小
var dx = worldPos.x - cur.x, dy = worldPos.y - cur.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 560) { dx = dx / dist * 560; dy = dy / dist * 560; }
else if (dist > 0.01 && dist < 150) { dx = dx / dist * 150; dy = dy / dist * 150; }
var tx = cur.x + dx, ty = cur.y + dy;
var tmpId = --tmpSeq; // 负数 = 本地临时 id,落库成功后登记到 idMap
state.nodes[tmpId] = {
id: tmpId, label: label, parent_id: srcNum, rel_type: 'child',
color: '#1E6FFF', icon: '', level: 0, extra: {},
};
state.adj[tmpId] = [{ to: srcNum, rel: 'parent' }];
state.adj[srcNum] = state.adj[srcNum] || [];
state.adj[srcNum].push({ to: tmpId, rel: 'child' });
calcDepth();
buildEdges();
createNodeEl(tmpId, cur.x, cur.y, 0); // 从父节点当前位置飞入
state.manual[tmpId] = { x: tx, y: ty };
layout(state.focus);
applyViewport();
renderDetail();
saveNode(tmpId, srcNum, label);
}
function saveNode(tmpId, parentId, label) {
var realParent = state.idMap[parentId] || parentId;
fetch('/yuthon_brain/api/' + key + '/node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: realParent, label: label }),
}).then(function (r) {
return r.json().then(function (d) { return { ok: r.ok, status: r.status, data: d }; });
}).then(function (res) {
if (res.ok && res.data && res.data.id) {
state.idMap[tmpId] = res.data.id;
toast('已保存到服务器', 'ok');
} else {
toast(saveFailMsg(res));
}
}).catch(function () {
toast('未保存到服务器:无法连接(节点仅在当前页面可见)');
});
}
function saveFailMsg(res) {
var code = res.data && res.data.error;
if (code === 'forbidden') return '未保存到服务器:当前账号没有新建节点权限(需系统管理员)';
if (code === 'duplicate') return '未保存到服务器:同一层级下已有同名节点';
if (code === 'empty_label') return '未保存到服务器:名称不能为空';
if (res.status === 404) return '未保存到服务器:接口未生效,请重启 Odoo 后再试';
return '未保存到服务器:返回状态 ' + res.status + '(节点仅在当前页面可见)';
}
/* ---------------- 轻提示 ---------------- */
var toastTimer = null;
function toast(msg, kind) {
var old = app.querySelector('.brain-toast');
if (old) old.parentNode.removeChild(old);
var d = document.createElement('div');
d.className = 'brain-toast' + (kind === 'ok' ? ' ok' : '');
d.textContent = msg;
app.appendChild(d);
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
if (d.parentNode) d.parentNode.removeChild(d);
}, kind === 'ok' ? 1800 : 4600);
}
/* ---------------- 面包屑 ---------------- */
function renderCrumbs(focus) {
crumbs.innerHTML = '';