脑图 #2
@@ -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):
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user