This commit is contained in:
李鹏宇
2026-09-23 18:06:45 +08:00
parent 9171023e9e
commit 263b4a1d7f
14 changed files with 670 additions and 16 deletions
+8 -1
View File
@@ -1,10 +1,17 @@
{
'name': 'Yuthon BPD 业务流程图',
'summary': '泳道式业务流程图(可拖拽新建关联流程)',
'version': '18.0.1.0.0',
'version': '18.0.1.1.1',
'category': 'Tools',
'license': 'OPL-1',
'depends': ['base', 'web'],
'assets': {
'web.assets_backend': [
'yuthon_bpd/static/src/js/bpd_visual_action.js',
'yuthon_bpd/static/src/xml/bpd_visual_action.xml',
'yuthon_bpd/static/src/scss/bpd_visual_action.scss',
],
},
'data': [
'security/ir.model.access.csv',
# 注意顺序:menu.xml 里的 <menuitem action="..."> 会在解析时立即
+50 -2
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import json
import logging
import os
from odoo import api, fields, models
@@ -9,9 +10,15 @@ try: # Odoo 各版本导出位置略有差异,双保险
except ImportError: # pragma: no cover
from odoo.tools.misc import file_open
try: # Odoo 18 推荐用 tools.misc.file_path(get_module_resource 已弃用)
from odoo.tools.misc import file_path
except ImportError: # pragma: no cover
file_path = None
_logger = logging.getLogger(__name__)
TEMPLATE_FILE = 'yuthon_bpd/static/src/bpd_default.json'
VIEW_FILE = 'yuthon_bpd/static/src/bpd_view.html'
class BpdDiagram(models.Model):
@@ -146,9 +153,50 @@ class BpdDiagram(models.Model):
or self.search([], limit=1, order='id')
return rec.to_json() if rec else None
# ---------------- 入口:后台跳前端 ----------------
# ---------------- 入口:后台打开前端 ----------------
@api.model
def view_asset_version(self):
"""前端静态页的版本号(取文件 mtime)。
bpd_view.html 的响应头是 Cache-Control: max-age=604800(7 天),
浏览器可能一直用旧副本。拿它拼 ?v=... 可打破缓存。
"""
if not file_path:
return 0
try:
return int(os.path.getmtime(file_path(VIEW_FILE, filter_ext=('.html',))))
except (OSError, ValueError):
return 0
@api.model
def refresh_visual_action_params(self):
"""升级模块时把最新版本号写进 client action 的 params(供菜单入口用)。
数据 XML 里用 <function> 调用,故不受 noupdate 影响,每次升级都会刷新。
"""
action = self.env.ref('yuthon_bpd.yuthon_bpd_action_visual',
raise_if_not_found=False)
if action:
action.params = {'v': self.view_asset_version()}
def action_open_visual(self):
"""后台表单按钮:在新标签打开该图的可视化编辑页(带 diagram id)。"""
"""后台表单按钮:在 Odoo 内部打开该图的可视化视图(不再跳走)。
返回客户端动作 yuthon_bpd.visual,由前端 OWL 组件用 iframe 承载
bpd_view.html 并撑满内容区;顶部面包屑可返回原表单。
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'yuthon_bpd.visual',
'name': self.name,
'params': {'diagram_id': self.id, 'v': self.view_asset_version()},
'target': 'current',
'context': dict(self.env.context, active_id=self.id, active_model=self._name),
}
def action_open_visual_url(self):
"""后台表单按钮:在新标签打开独立页面(多屏对照时用)。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
@@ -0,0 +1,47 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Layout } from "@web/search/layout";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Component } from "@odoo/owl";
/**
* 业务流程图前端页面(静态 HTML,独立实现,保持原样不动)。
*/
const VIEW_URL = "/yuthon_bpd/static/src/bpd_view.html";
/**
* 客户端动作:把业务流程图嵌进 Odoo 后端内容区(不再新开标签页)。
*
* - 图形渲染与拖拽逻辑仍由 bpd_view.html 自己负责,这里只给它一块
* 撑满内容区的容器,并借 Layout 拿到原生面包屑(可点返回回到表单)。
* - 图 id 优先取 action.params.diagram_id(表单按钮传入),
* 其次取 action.context.active_id;都没有时由页面回落默认图。
*/
export class BpdVisualAction extends Component {
static template = "yuthon_bpd.BpdVisualAction";
static components = { Layout };
static props = { ...standardActionServiceProps };
setup() {
const action = this.props.action || {};
const params = action.params || {};
const context = action.context || {};
const rawId = params.diagram_id || context.active_id;
const diagramId = Number.parseInt(rawId, 10);
const query = [];
if (Number.isInteger(diagramId) && diagramId > 0) {
query.push(`id=${diagramId}`);
}
if (params.v) {
// 静态页缓存 7 天,带上服务端给的版本号避免看到旧副本
query.push(`v=${encodeURIComponent(params.v)}`);
}
this.url = query.length ? `${VIEW_URL}?${query.join("&")}` : VIEW_URL;
// 只要面包屑,不要搜索栏等控件
this.display = { controlPanel: {} };
}
}
registry.category("actions").add("yuthon_bpd.visual", BpdVisualAction);
@@ -0,0 +1,21 @@
// 业务流程图嵌入后端时的容器样式。
//
// .o_content 在 Odoo 里是 position: relative(见 web/static/src/webclient/webclient_layout.scss),
// 官方全屏内容(.o_view_controller)正是用 `position: absolute; inset: 0` 吃掉整块高度的。
// 这里沿用同一套路,而不是靠 height: 100% 逐层传递 —— 只要链上有一环高度是 auto
// (例如模板根节点没有 .o_action 时),百分比高度就会失效、iframe 会掉回默认 150px。
.o_bpd_visual {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: #fff;
.o_bpd_visual_frame {
display: block;
width: 100%;
height: 100%;
border: 0;
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="yuthon_bpd.BpdVisualAction">
<!--
根节点必须是 .o_action:ActionContainer 通过 className 传入 'o_action',
只有它落在这一层,才能命中 webclient_layout.scss 的
`.o_action_manager > .o_action { height:100%; display:flex; flex-direction:column }`,
高度链才能往下传(否则 .o_content 退化成 auto,iframe 掉回默认 150px)。
-->
<div t-attf-class="{{props.className || 'o_action'}}">
<Layout display="display">
<div class="o_bpd_visual">
<iframe class="o_bpd_visual_frame"
t-att-src="url"
title="业务流程图"
loading="eager"/>
</div>
</Layout>
</div>
</t>
</templates>
+12 -1
View File
@@ -8,7 +8,9 @@
<form>
<header>
<button name="action_open_visual" type="object"
string="可视化编辑" class="oe_highlight"/>
string="流程图视图" class="oe_highlight"/>
<button name="action_open_visual_url" type="object"
string="在新标签打开"/>
</header>
<sheet>
<group>
@@ -82,4 +84,13 @@
<field name="view_mode">list,form</field>
<field name="view_id" ref="yuthon_bpd_diagram_view_list"/>
</record>
<!-- 嵌入后端的可视化视图:client action,由 OWL 组件承载 bpd_view.html -->
<record id="yuthon_bpd_action_visual" model="ir.actions.client">
<field name="name">流程图视图</field>
<field name="tag">yuthon_bpd.visual</field>
<field name="target">current</field>
</record>
<!-- 升级时刷新该动作的版本号参数(<function> 不受 noupdate 限制) -->
<function model="yuthon.bpd.diagram" name="refresh_visual_action_params"/>
</odoo>
+3 -8
View File
@@ -12,14 +12,9 @@
action="yuthon_bpd_diagram_action"
groups="base.group_system"/>
<!-- 前端可视化入口:新标签打开 bpd_view.html,数据由 REST 接口提供 -->
<record id="action_yuthon_bpd_view" model="ir.actions.act_url">
<field name="name">流程图视图</field>
<field name="url">/yuthon_bpd/static/src/bpd_view.html</field>
<field name="target">new</field>
</record>
<menuitem id="menu_yuthon_bpd_view" name="业务流程度"
<!-- 可视化入口:在 Odoo 内容区嵌入 bpd_view.html(client action 定义在 bpd_views.xml) -->
<menuitem id="menu_yuthon_bpd_view" name="流程图视图"
parent="menu_yuthon_bpd_root"
action="action_yuthon_bpd_view"
action="yuthon_bpd_action_visual"
groups="base.group_user"/>
</odoo>
+8 -1
View File
@@ -1,7 +1,7 @@
{
'name': 'Yuthon Brain',
'summary': 'TheBrain 风格脑图化内容展示',
'version': '18.0.1.0.0',
'version': '18.0.1.0.1',
'category': 'Tools',
'license': 'OPL-1',
'depends': ['base', 'web'],
@@ -13,6 +13,13 @@
'data/brain_data.xml',
'views/menu.xml',
],
'assets': {
'web.assets_backend': [
'yuthon_brain/static/src/js/brain_visual_action.js',
'yuthon_brain/static/src/xml/brain_visual_action.xml',
'yuthon_brain/static/src/scss/brain_visual_action.scss',
],
},
'installable': True,
'application': True,
}
+61
View File
@@ -1,14 +1,22 @@
# -*- coding: utf-8 -*-
import json
import logging
import os
import re
from odoo import _, models, fields, api
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
try: # Odoo 18 推荐用 tools.misc.file_path(get_module_resource 已弃用)
from odoo.tools.misc import file_path
except ImportError: # pragma: no cover
file_path = None
_logger = logging.getLogger(__name__)
VIEW_FILE = 'yuthon_brain/static/src/brain_view.html'
_LI_RE = re.compile(r'<li[^>]*>(.*?)</li>', re.S | re.I)
_STRONG_RE = re.compile(r'<strong[^>]*>(.*?)</strong>', re.S | re.I)
_TAG_RE = re.compile(r'<[^>]+>')
@@ -295,3 +303,56 @@ class BrainSource(models.Model):
})
created += 1
return created, skipped
# ---- 脑图可视化嵌入后端(客户端动作)----
def view_asset_version(self):
"""前端静态页的版本号(取文件 mtime)。
brain_view.html 的响应头是 Cache-Control: max-age=604800(7 天),
浏览器可能一直用旧副本。拿它拼 ?v=... 可打破缓存。
"""
if not file_path:
return 0
try:
return int(os.path.getmtime(file_path(VIEW_FILE, filter_ext=('.html',))))
except (OSError, ValueError):
return 0
@api.model
def refresh_visual_action_params(self):
"""升级模块时把最新版本号写进 client action 的 params(供菜单入口用)。
数据 XML 里用 <function> 调用,故不受 noupdate 影响,每次升级都会刷新。
"""
action = self.env.ref('yuthon_brain.yuthon_brain_action_visual',
raise_if_not_found=False)
if action:
action.params = {'v': self.view_asset_version()}
def action_open_visual(self):
"""后台表单按钮:在 Odoo 内部打开该数据源的脑图(不再跳走)。
返回客户端动作 yuthon_brain.visual,由前端 OWL 组件用 iframe 承载
brain_view.html 并撑满内容区;顶部面包屑可返回原表单。
前端按 ?key=<路由标识> 读取对应数据源。
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'yuthon_brain.visual',
'name': self.name,
'params': {'key': self.key, 'v': self.view_asset_version()},
'target': 'current',
'context': dict(self.env.context, active_id=self.id,
active_model=self._name),
}
def action_open_visual_url(self):
"""后台表单按钮:在新标签打开独立脑图页(多屏对照时用)。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': '/yuthon_brain/static/src/brain_view.html?key=%s' % self.key,
'target': 'new',
}
+335 -3
View File
@@ -35,13 +35,18 @@
z-index: 5;
pointer-events: none; /* 让空白处可拖拽,按钮单独开 */
display: flex;
align-items: flex-start;
gap: 16px;
align-items: center;
gap: 14px;
}
.brain-brand {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
height: 34px;
max-width: 200px;
padding-right: 14px;
border-right: 1px solid #e6eef5;
color: #46535f;
}
.brain-logo { font-size: 17px; opacity: .75; }
@@ -50,10 +55,13 @@
font-weight: 500;
letter-spacing: .6px;
color: #3b4854;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brain-crumbs {
flex: 1;
margin-top: 4px;
min-width: 0;
font-size: 12.5px;
color: #9aa7b4;
line-height: 1.6;
@@ -277,6 +285,144 @@
transition: opacity .3s;
}
/* ===================== 视图切换(网络图 / 树视图)===================== */
.brain-views {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(255, 255, 255, .55);
border: 1px solid #e3ecf4;
box-shadow: 0 1px 2px rgba(70, 100, 130, .04);
pointer-events: auto;
}
.brain-views button {
display: flex;
align-items: center;
gap: 5px;
height: 26px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #7a8896;
font-size: 12.5px;
font-family: inherit;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background .18s, color .18s, box-shadow .18s;
}
.brain-views button svg { display: block; }
.brain-views button:hover { color: #26313a; background: rgba(255, 255, 255, .92); }
.brain-views button.active {
background: #ffffff;
color: #1b7fd4;
font-weight: 500;
box-shadow: 0 1px 3px rgba(60, 110, 160, .16);
}
/* ===================== 树视图(缩进大纲)===================== */
.brain-tree {
position: absolute;
inset: 0;
z-index: 4;
display: none;
overflow: auto;
padding: 70px 320px 40px 26px; /* 右侧留出详情面板的宽度 */
font-size: 14px;
color: #2b3742;
}
.brain-tree.on { display: block; }
.brain-tree .tree-row {
display: flex;
align-items: center;
height: 27px;
padding-right: 10px;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
}
.brain-tree .tree-row:hover { background: rgba(255, 255, 255, .78); }
.brain-tree .tree-row.cur { background: rgba(47, 158, 224, .12); }
.brain-tree .tree-row.cur .tree-label { color: #10699f; font-weight: 600; }
.brain-tree .tree-row.root .tree-label { font-size: 15px; font-weight: 600; color: #1b2732; }
/* 缩进引导线:v = 竖线贯穿;branch / branch-last = 拐角(├ / └) */
.brain-tree .tree-guide { position: relative; flex: 0 0 20px; width: 20px; align-self: stretch; }
.brain-tree .tree-guide.v::before {
content: '';
position: absolute;
left: 9px; top: 0; bottom: 0;
width: 1px;
background: #ccdcec;
}
.brain-tree .tree-guide.branch::before,
.brain-tree .tree-guide.branch-last::before {
content: '';
position: absolute;
left: 9px; top: 0;
width: 1px;
background: #ccdcec;
}
.brain-tree .tree-guide.branch::before { bottom: 0; }
.brain-tree .tree-guide.branch-last::before { height: 50%; }
.brain-tree .tree-guide.branch::after,
.brain-tree .tree-guide.branch-last::after {
content: '';
position: absolute;
left: 9px; top: 50%;
width: 8px; height: 1px;
background: #ccdcec;
}
.brain-tree .tree-twist {
flex: 0 0 16px;
width: 16px; height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #8fa0b0;
transition: transform .16s, color .16s, background .16s;
}
.brain-tree .tree-twist.closed { transform: rotate(-90deg); }
.brain-tree .tree-twist:hover { color: #1b7fd4; background: rgba(47, 158, 224, .12); }
.brain-tree .tree-twist.empty { visibility: hidden; }
.brain-tree .tree-twist svg { display: block; }
.brain-tree .tree-ico {
flex: 0 0 7px;
width: 7px; height: 7px;
margin: 0 8px 0 1px;
border-radius: 50%;
background: #7ec6ea;
box-shadow: 0 0 0 2px rgba(255, 255, 255, .85);
}
.brain-tree .tree-row.cur .tree-ico { background: #2f9ee0; }
.brain-tree .tree-label {
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: .2px;
color: #33414d;
}
.brain-tree .tree-count {
margin-left: 8px;
padding: 1px 7px;
font-size: 11px;
color: #8c9bab;
background: rgba(255, 255, 255, .8);
border: 1px solid #e3ecf4;
border-radius: 9px;
}
@media (max-width: 1080px) {
.brain-tree { padding-right: 26px; }
}
/* ===================== 详情面板 ===================== */
.brain-detail {
position: absolute;
@@ -370,6 +516,16 @@
<span class="brain-logo">🧠</span>
<span class="brain-title" id="brain-title">脑图</span>
</div>
<div class="brain-views" role="group" aria-label="视图模式">
<button id="brain-view-network" class="bv-btn active" data-view="network" title="网络图:以焦点为中心展开关联节点">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.9v2.4M8 11.7v2.4M1.9 8h2.4M11.7 8h2.4"/></svg>
<span>网络图</span>
</button>
<button id="brain-view-tree" class="bv-btn" data-view="tree" title="树视图:按层级展开的缩进大纲">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><path d="M2.6 2.7v10.6"/><path d="M2.6 3.7h3.2M2.6 8h3.2M2.6 12.9h3.2"/><path d="M9.2 3.7h4.2M9.2 8h4.2M9.2 12.9h4.2"/></svg>
<span>树视图</span>
</button>
</div>
<nav class="brain-crumbs" id="brain-crumbs"></nav>
<div class="brain-tools">
<button id="brain-refresh" title="重新加载数据">&#8635;</button>
@@ -380,6 +536,7 @@
</header>
<div class="brain-stage" id="brain-stage">
<svg id="brain-svg" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="brain-tree" id="brain-tree"></div>
<aside class="brain-detail" id="brain-detail">
<div class="brain-detail-empty">点击节点查看详情</div>
</aside>
@@ -403,6 +560,7 @@
var svg = document.getElementById('brain-svg');
var stage = document.getElementById('brain-stage');
var detail = document.getElementById('brain-detail');
var treeEl = document.getElementById('brain-tree');
var crumbs = document.getElementById('brain-crumbs');
var titleEl = document.getElementById('brain-title');
var SVGNS = 'http://www.w3.org/2000/svg';
@@ -421,6 +579,8 @@
edgeMap: {}, parentSet: {},
manual: {}, // 手动落点(拖拽新建的节点保持在该位置)
idMap: {}, // 临时 id(负数)→ 服务器真实 id
view: 'network', // 当前视图:network 网络图 / tree 树视图
collapsed: {}, // 树视图折叠状态(键统一用 String(id),避免数字/字符串错位)
};
function create(tag) { return document.createElementNS(SVGNS, tag); }
@@ -476,6 +636,7 @@
layout(state.focus);
applyViewport();
renderDetail();
if (state.view === 'tree') renderTree();
// 布局稳定后再校准一次中心(首帧尺寸可能未定)
window.requestAnimationFrame(applyViewport);
})
@@ -893,6 +1054,7 @@
layout(nid);
applyViewport();
renderDetail();
if (state.view === 'tree') renderTree();
}
/* ================= 拖拽端口新建节点(对齐 TheBrain) ================= */
@@ -1082,6 +1244,7 @@
layout(state.focus);
applyViewport();
renderDetail();
if (state.view === 'tree') renderTree();
saveNode(tmpId, srcNum, label);
}
@@ -1171,8 +1334,166 @@
detail.innerHTML = html;
}
/* ================= 树视图(缩进大纲,对齐 TheBrain 的树视图)================= */
var CHEV = '<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"'
+ ' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'
+ '<path d="M4 6.4L8 10.4l4-4"/></svg>';
function childrenOf(id) {
return (state.adj[id] || [])
.filter(function (x) { return x.rel === 'child'; })
.map(function (x) { return x.to; });
}
// 折叠表统一用字符串键:adj 里的是数字、Object.keys 出的是字符串,混用会永远读不到
function isCollapsed(id) { return !!state.collapsed[String(id)]; }
function setCollapsed(id, v) {
if (v) state.collapsed[String(id)] = true;
else delete state.collapsed[String(id)];
}
// 展开焦点所在整条路径,保证焦点行在树里可见
function expandPathTo(id) {
var cur = toNum(id), guard = 0;
while (cur !== null && state.nodes[cur] && guard++ < 100) {
setCollapsed(cur, false);
var p = (state.adj[cur] || []).filter(function (x) { return x.rel === 'parent'; })[0];
cur = p ? p.to : null;
}
}
/* 深度优先收集可见行。
guides[i] = true 表示第 i 层祖先是“还有后续兄弟”,该列要画贯穿竖线;
hasNext 表示本行在兄弟中是否还有下一个(决定拐角是 ├ 还是 └) */
function walkTree(id, depth, guides, isLast, hasNext, out) {
if (!state.nodes[id] || out.length > 1500) return;
out.push({ id: id, depth: depth, guides: guides, hasNext: hasNext });
if (isCollapsed(id)) return;
var kids = childrenOf(id);
var childGuides = guides.concat([!isLast]);
for (var i = 0; i < kids.length; i++) {
walkTree(kids[i], depth + 1, childGuides, i === kids.length - 1, i < kids.length - 1, out);
}
}
function renderTree() {
if (!treeEl || state.view !== 'tree') return;
treeEl.innerHTML = '';
var ids = Object.keys(state.nodes);
if (!ids.length) return;
expandPathTo(state.focus);
// 树的根:优先数据源根节点,否则找第一个没有父级的节点
var rootId = state.root;
if (rootId === null || !state.nodes[rootId]) {
for (var i = 0; i < ids.length; i++) {
var hasParent = (state.adj[ids[i]] || []).some(function (x) { return x.rel === 'parent'; });
if (!hasParent) { rootId = toNum(ids[i]); break; }
}
}
if (rootId === null || !state.nodes[rootId]) return;
var rows = [];
walkTree(rootId, 0, [], true, false, rows);
var frag = document.createDocumentFragment();
rows.forEach(function (r) {
var kids = childrenOf(r.id);
var row = document.createElement('div');
row.className = 'tree-row'
+ (toNum(r.id) === state.focus ? ' cur' : '')
+ (r.depth === 0 ? ' root' : '');
row.setAttribute('data-id', r.id);
for (var i = 0; i < r.depth; i++) {
var g = document.createElement('span');
g.className = 'tree-guide' + (r.guides[i] ? ' v' : '');
row.appendChild(g);
}
if (r.depth > 0) { // 本层拐角
var e = document.createElement('span');
e.className = 'tree-guide ' + (r.hasNext ? 'branch' : 'branch-last');
row.appendChild(e);
}
var tw = document.createElement('span');
if (kids.length) {
tw.className = 'tree-twist' + (isCollapsed(r.id) ? ' closed' : '');
tw.innerHTML = CHEV;
tw.addEventListener('click', function (ev) {
ev.stopPropagation();
setCollapsed(r.id, !isCollapsed(r.id));
renderTree();
});
} else {
tw.className = 'tree-twist empty';
}
row.appendChild(tw);
var ico = document.createElement('span');
ico.className = 'tree-ico';
var col = state.nodes[r.id] && state.nodes[r.id].color;
if (col) ico.style.background = col;
row.appendChild(ico);
var lb = document.createElement('span');
lb.className = 'tree-label';
lb.textContent = (state.nodes[r.id] && state.nodes[r.id].label) || '';
row.appendChild(lb);
if (kids.length && isCollapsed(r.id)) { // 折叠时提示还有几个下级
var ct = document.createElement('span');
ct.className = 'tree-count';
ct.textContent = kids.length;
row.appendChild(ct);
}
row.addEventListener('click', function () { setFocus(r.id); });
frag.appendChild(row);
});
treeEl.appendChild(frag);
// 焦点行不在可视区时才滚动,避免每次点击都跳一下
var cur = treeEl.querySelector('.tree-row.cur');
if (cur) {
var top = cur.offsetTop, ch = treeEl.clientHeight;
if (top < treeEl.scrollTop + 40 || top + cur.offsetHeight > treeEl.scrollTop + ch - 40) {
treeEl.scrollTop = Math.max(0, top - ch / 2);
}
}
}
function setMode(mode) {
if (mode !== 'network' && mode !== 'tree') return;
if (mode === state.view) return;
state.view = mode;
try { localStorage.setItem('brain_view_mode', mode); } catch (e) { /* 隐私模式忽略 */ }
Array.prototype.forEach.call(app.querySelectorAll('.bv-btn'), function (b) {
b.classList.toggle('active', b.getAttribute('data-view') === mode);
});
var isTree = mode === 'tree';
if (treeEl) treeEl.classList.toggle('on', isTree);
svg.style.display = isTree ? 'none' : ''; // 隐藏后 SVG 不再接收指针事件,平移/拖拽自然停用
var hint = app.querySelector('.brain-hint');
if (hint) hint.style.display = isTree ? 'none' : '';
var lg = app.querySelector('.brain-legend');
if (lg) lg.style.display = isTree ? 'none' : '';
if (isTree) {
closeCreator();
clearMsg();
clearEmpty();
renderTree();
} else {
if (treeEl) treeEl.innerHTML = '';
if (state.focus !== null) { layout(state.focus); applyViewport(); }
}
}
function renderEmptyHint(focus) {
clearEmpty();
if (state.view === 'tree') return; // 树视图是看得见层级的,不需要浮层引导
var ns = state.adj[focus] || [];
if (ns.length) return;
var n = state.nodes[focus];
@@ -1238,6 +1559,8 @@
bind('brain-zoom-out', function () { state.zoom = Math.max(0.3, state.zoom / 1.15); applyViewport(); });
bind('brain-home', function () { setFocus(state.root); }); // 回首页:布局里已自带居中自适应
bind('brain-refresh', function () { state.focus = null; load(); });
bind('brain-view-network', function () { setMode('network'); });
bind('brain-view-tree', function () { setMode('tree'); });
window.addEventListener('resize', function () { applyViewport(); });
window.addEventListener('load', function () { applyViewport(); });
@@ -1253,6 +1576,15 @@
app.appendChild(lg);
})();
/* ---------------- 初始视图模式:URL ?view= > 上次记住的 > 默认网络图 ---------------- */
(function initViewMode() {
var want = new URLSearchParams(location.search).get('view');
if (!want) {
try { want = localStorage.getItem('brain_view_mode'); } catch (e) { want = null; }
}
if (want === 'tree') setMode('tree');
})();
load();
})();
</script>
@@ -0,0 +1,45 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Layout } from "@web/search/layout";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Component } from "@odoo/owl";
/**
* 脑图前端页面(静态 HTML,独立实现,保持原样不动)。
*/
const VIEW_URL = "/yuthon_brain/static/src/brain_view.html";
/**
* 客户端动作:把脑图嵌进 Odoo 后端内容区(不再新开标签页)。
*
* - 图形渲染与拖拽逻辑仍由 brain_view.html 自己负责,这里只给它一块
* 撑满内容区的容器,并借 Layout 拿到原生面包屑(可点返回回到表单)。
* - 数据源用 action.params.key(表单按钮传入的「路由标识」);
* 前端按 ?key=<路由标识> 读取对应脑图。
*/
export class BrainVisualAction extends Component {
static template = "yuthon_brain.BrainVisualAction";
static components = { Layout };
static props = { ...standardActionServiceProps };
setup() {
const action = this.props.action || {};
const params = action.params || {};
const key = params.key;
const query = [];
if (key) {
query.push(`key=${encodeURIComponent(key)}`);
}
if (params.v) {
// 静态页缓存 7 天,带上服务端给的版本号避免看到旧副本
query.push(`v=${encodeURIComponent(params.v)}`);
}
this.url = query.length ? `${VIEW_URL}?${query.join("&")}` : VIEW_URL;
// 只要面包屑,不要搜索栏等控件
this.display = { controlPanel: {} };
}
}
registry.category("actions").add("yuthon_brain.visual", BrainVisualAction);
@@ -0,0 +1,21 @@
// 脑图嵌入后端时的容器样式。
//
// .o_content 在 Odoo 里是 position: relative(见 web/static/src/webclient/webclient_layout.scss),
// 官方全屏内容(.o_view_controller)正是用 `position: absolute; inset: 0` 吃掉整块高度的。
// 这里沿用同一套路,而不是靠 height: 100% 逐层传递 —— 只要链上有一环高度是 auto
// (例如模板根节点没有 .o_action 时),百分比高度就会失效、iframe 会掉回默认 150px。
.o_brain_visual {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: #fff;
.o_brain_visual_frame {
display: block;
width: 100%;
height: 100%;
border: 0;
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="yuthon_brain.BrainVisualAction">
<!--
根节点必须是 .o_action:ActionContainer 通过 className 传入 'o_action',
只有它落在这一层,才能命中 webclient_layout.scss 的
`.o_action_manager > .o_action { height:100%; display:flex; flex-direction:column }`,
高度链才能往下传(否则 .o_content 退化成 auto,iframe 掉回默认 150px)。
-->
<div t-attf-class="{{props.className || 'o_action'}}">
<Layout display="display">
<div class="o_brain_visual">
<iframe class="o_brain_visual_frame"
t-att-src="url"
title="脑图"
loading="eager"/>
</div>
</Layout>
</div>
</t>
</templates>
+13
View File
@@ -26,6 +26,11 @@
<button name="action_sync" type="object" string="同步数据"
class="oe_highlight"
help="从「同步模型」读取记录生成脑图节点(不改动对方模块)"/>
<button name="action_open_visual" type="object" string="脑图视图"
class="oe_highlight"
help="在 Odoo 内部打开该数据源的脑图(带面包屑可返回)"/>
<button name="action_open_visual_url" type="object" string="在新标签打开"
help="在新浏览器标签打开独立脑图页(多屏对照)"/>
</header>
<sheet>
<group>
@@ -83,6 +88,14 @@
<field name="view_mode">list,form</field>
</record>
<!-- 脑图可视化:用客户端动作把 brain_view.html 嵌进内容区(替代新标签) -->
<record id="yuthon_brain_action_visual" model="ir.actions.client">
<field name="name">脑图视图</field>
<field name="tag">yuthon_brain.visual</field>
<field name="target">current</field>
</record>
<function model="yuthon.brain.source" name="refresh_visual_action_params"/>
<!-- ===================== 节点 ===================== -->
<record id="yuthon_brain_node_list" model="ir.ui.view">
<field name="name">yuthon.brain.node.list</field>