嵌入
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
{
|
{
|
||||||
'name': 'Yuthon BPD 业务流程图',
|
'name': 'Yuthon BPD 业务流程图',
|
||||||
'summary': '泳道式业务流程图(可拖拽新建关联流程)',
|
'summary': '泳道式业务流程图(可拖拽新建关联流程)',
|
||||||
'version': '18.0.1.0.0',
|
'version': '18.0.1.1.1',
|
||||||
'category': 'Tools',
|
'category': 'Tools',
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'depends': ['base', 'web'],
|
'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': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
# 注意顺序:menu.xml 里的 <menuitem action="..."> 会在解析时立即
|
# 注意顺序:menu.xml 里的 <menuitem action="..."> 会在解析时立即
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from odoo import api, fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
@@ -9,9 +10,15 @@ try: # Odoo 各版本导出位置略有差异,双保险
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
from odoo.tools.misc import file_open
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TEMPLATE_FILE = 'yuthon_bpd/static/src/bpd_default.json'
|
TEMPLATE_FILE = 'yuthon_bpd/static/src/bpd_default.json'
|
||||||
|
VIEW_FILE = 'yuthon_bpd/static/src/bpd_view.html'
|
||||||
|
|
||||||
|
|
||||||
class BpdDiagram(models.Model):
|
class BpdDiagram(models.Model):
|
||||||
@@ -146,9 +153,50 @@ class BpdDiagram(models.Model):
|
|||||||
or self.search([], limit=1, order='id')
|
or self.search([], limit=1, order='id')
|
||||||
return rec.to_json() if rec else None
|
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):
|
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()
|
self.ensure_one()
|
||||||
return {
|
return {
|
||||||
'type': 'ir.actions.act_url',
|
'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>
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
<form>
|
<form>
|
||||||
<header>
|
<header>
|
||||||
<button name="action_open_visual" type="object"
|
<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>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
@@ -82,4 +84,13 @@
|
|||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
<field name="view_id" ref="yuthon_bpd_diagram_view_list"/>
|
<field name="view_id" ref="yuthon_bpd_diagram_view_list"/>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -12,14 +12,9 @@
|
|||||||
action="yuthon_bpd_diagram_action"
|
action="yuthon_bpd_diagram_action"
|
||||||
groups="base.group_system"/>
|
groups="base.group_system"/>
|
||||||
|
|
||||||
<!-- 前端可视化入口:新标签打开 bpd_view.html,数据由 REST 接口提供 -->
|
<!-- 可视化入口:在 Odoo 内容区嵌入 bpd_view.html(client action 定义在 bpd_views.xml) -->
|
||||||
<record id="action_yuthon_bpd_view" model="ir.actions.act_url">
|
<menuitem id="menu_yuthon_bpd_view" name="流程图视图"
|
||||||
<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="业务流程度"
|
|
||||||
parent="menu_yuthon_bpd_root"
|
parent="menu_yuthon_bpd_root"
|
||||||
action="action_yuthon_bpd_view"
|
action="yuthon_bpd_action_visual"
|
||||||
groups="base.group_user"/>
|
groups="base.group_user"/>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
'name': 'Yuthon Brain',
|
'name': 'Yuthon Brain',
|
||||||
'summary': 'TheBrain 风格脑图化内容展示',
|
'summary': 'TheBrain 风格脑图化内容展示',
|
||||||
'version': '18.0.1.0.0',
|
'version': '18.0.1.0.1',
|
||||||
'category': 'Tools',
|
'category': 'Tools',
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'depends': ['base', 'web'],
|
'depends': ['base', 'web'],
|
||||||
@@ -13,6 +13,13 @@
|
|||||||
'data/brain_data.xml',
|
'data/brain_data.xml',
|
||||||
'views/menu.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,
|
'installable': True,
|
||||||
'application': True,
|
'application': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from odoo import _, models, fields, api
|
from odoo import _, models, fields, api
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
from odoo.tools.safe_eval import safe_eval
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VIEW_FILE = 'yuthon_brain/static/src/brain_view.html'
|
||||||
|
|
||||||
_LI_RE = re.compile(r'<li[^>]*>(.*?)</li>', re.S | re.I)
|
_LI_RE = re.compile(r'<li[^>]*>(.*?)</li>', re.S | re.I)
|
||||||
_STRONG_RE = re.compile(r'<strong[^>]*>(.*?)</strong>', re.S | re.I)
|
_STRONG_RE = re.compile(r'<strong[^>]*>(.*?)</strong>', re.S | re.I)
|
||||||
_TAG_RE = re.compile(r'<[^>]+>')
|
_TAG_RE = re.compile(r'<[^>]+>')
|
||||||
@@ -295,3 +303,56 @@ class BrainSource(models.Model):
|
|||||||
})
|
})
|
||||||
created += 1
|
created += 1
|
||||||
return created, skipped
|
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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,13 +35,18 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
pointer-events: none; /* 让空白处可拖拽,按钮单独开 */
|
pointer-events: none; /* 让空白处可拖拽,按钮单独开 */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.brain-brand {
|
.brain-brand {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
height: 34px;
|
||||||
|
max-width: 200px;
|
||||||
|
padding-right: 14px;
|
||||||
|
border-right: 1px solid #e6eef5;
|
||||||
color: #46535f;
|
color: #46535f;
|
||||||
}
|
}
|
||||||
.brain-logo { font-size: 17px; opacity: .75; }
|
.brain-logo { font-size: 17px; opacity: .75; }
|
||||||
@@ -50,10 +55,13 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: .6px;
|
letter-spacing: .6px;
|
||||||
color: #3b4854;
|
color: #3b4854;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.brain-crumbs {
|
.brain-crumbs {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-top: 4px;
|
min-width: 0;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: #9aa7b4;
|
color: #9aa7b4;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -277,6 +285,144 @@
|
|||||||
transition: opacity .3s;
|
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 {
|
.brain-detail {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -370,6 +516,16 @@
|
|||||||
<span class="brain-logo">🧠</span>
|
<span class="brain-logo">🧠</span>
|
||||||
<span class="brain-title" id="brain-title">脑图</span>
|
<span class="brain-title" id="brain-title">脑图</span>
|
||||||
</div>
|
</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>
|
<nav class="brain-crumbs" id="brain-crumbs"></nav>
|
||||||
<div class="brain-tools">
|
<div class="brain-tools">
|
||||||
<button id="brain-refresh" title="重新加载数据">↻</button>
|
<button id="brain-refresh" title="重新加载数据">↻</button>
|
||||||
@@ -380,6 +536,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="brain-stage" id="brain-stage">
|
<div class="brain-stage" id="brain-stage">
|
||||||
<svg id="brain-svg" xmlns="http://www.w3.org/2000/svg"></svg>
|
<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">
|
<aside class="brain-detail" id="brain-detail">
|
||||||
<div class="brain-detail-empty">点击节点查看详情</div>
|
<div class="brain-detail-empty">点击节点查看详情</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -403,6 +560,7 @@
|
|||||||
var svg = document.getElementById('brain-svg');
|
var svg = document.getElementById('brain-svg');
|
||||||
var stage = document.getElementById('brain-stage');
|
var stage = document.getElementById('brain-stage');
|
||||||
var detail = document.getElementById('brain-detail');
|
var detail = document.getElementById('brain-detail');
|
||||||
|
var treeEl = document.getElementById('brain-tree');
|
||||||
var crumbs = document.getElementById('brain-crumbs');
|
var crumbs = document.getElementById('brain-crumbs');
|
||||||
var titleEl = document.getElementById('brain-title');
|
var titleEl = document.getElementById('brain-title');
|
||||||
var SVGNS = 'http://www.w3.org/2000/svg';
|
var SVGNS = 'http://www.w3.org/2000/svg';
|
||||||
@@ -421,6 +579,8 @@
|
|||||||
edgeMap: {}, parentSet: {},
|
edgeMap: {}, parentSet: {},
|
||||||
manual: {}, // 手动落点(拖拽新建的节点保持在该位置)
|
manual: {}, // 手动落点(拖拽新建的节点保持在该位置)
|
||||||
idMap: {}, // 临时 id(负数)→ 服务器真实 id
|
idMap: {}, // 临时 id(负数)→ 服务器真实 id
|
||||||
|
view: 'network', // 当前视图:network 网络图 / tree 树视图
|
||||||
|
collapsed: {}, // 树视图折叠状态(键统一用 String(id),避免数字/字符串错位)
|
||||||
};
|
};
|
||||||
|
|
||||||
function create(tag) { return document.createElementNS(SVGNS, tag); }
|
function create(tag) { return document.createElementNS(SVGNS, tag); }
|
||||||
@@ -476,6 +636,7 @@
|
|||||||
layout(state.focus);
|
layout(state.focus);
|
||||||
applyViewport();
|
applyViewport();
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
if (state.view === 'tree') renderTree();
|
||||||
// 布局稳定后再校准一次中心(首帧尺寸可能未定)
|
// 布局稳定后再校准一次中心(首帧尺寸可能未定)
|
||||||
window.requestAnimationFrame(applyViewport);
|
window.requestAnimationFrame(applyViewport);
|
||||||
})
|
})
|
||||||
@@ -893,6 +1054,7 @@
|
|||||||
layout(nid);
|
layout(nid);
|
||||||
applyViewport();
|
applyViewport();
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
if (state.view === 'tree') renderTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= 拖拽端口新建节点(对齐 TheBrain) ================= */
|
/* ================= 拖拽端口新建节点(对齐 TheBrain) ================= */
|
||||||
@@ -1082,6 +1244,7 @@
|
|||||||
layout(state.focus);
|
layout(state.focus);
|
||||||
applyViewport();
|
applyViewport();
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
if (state.view === 'tree') renderTree();
|
||||||
saveNode(tmpId, srcNum, label);
|
saveNode(tmpId, srcNum, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,8 +1334,166 @@
|
|||||||
detail.innerHTML = html;
|
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) {
|
function renderEmptyHint(focus) {
|
||||||
clearEmpty();
|
clearEmpty();
|
||||||
|
if (state.view === 'tree') return; // 树视图是看得见层级的,不需要浮层引导
|
||||||
var ns = state.adj[focus] || [];
|
var ns = state.adj[focus] || [];
|
||||||
if (ns.length) return;
|
if (ns.length) return;
|
||||||
var n = state.nodes[focus];
|
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-zoom-out', function () { state.zoom = Math.max(0.3, state.zoom / 1.15); applyViewport(); });
|
||||||
bind('brain-home', function () { setFocus(state.root); }); // 回首页:布局里已自带居中自适应
|
bind('brain-home', function () { setFocus(state.root); }); // 回首页:布局里已自带居中自适应
|
||||||
bind('brain-refresh', function () { state.focus = null; load(); });
|
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('resize', function () { applyViewport(); });
|
||||||
window.addEventListener('load', function () { applyViewport(); });
|
window.addEventListener('load', function () { applyViewport(); });
|
||||||
@@ -1253,6 +1576,15 @@
|
|||||||
app.appendChild(lg);
|
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();
|
load();
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
||||||
@@ -26,6 +26,11 @@
|
|||||||
<button name="action_sync" type="object" string="同步数据"
|
<button name="action_sync" type="object" string="同步数据"
|
||||||
class="oe_highlight"
|
class="oe_highlight"
|
||||||
help="从「同步模型」读取记录生成脑图节点(不改动对方模块)"/>
|
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>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
@@ -83,6 +88,14 @@
|
|||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
</record>
|
</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">
|
<record id="yuthon_brain_node_list" model="ir.ui.view">
|
||||||
<field name="name">yuthon.brain.node.list</field>
|
<field name="name">yuthon.brain.node.list</field>
|
||||||
|
|||||||
Reference in New Issue
Block a user