Dev wang #1
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
'name': 'Yuthon Brain',
|
||||
'summary': 'TheBrain 风格脑图化内容展示',
|
||||
'version': '18.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['base', 'web'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
# 注意顺序:menu.xml 里的 <menuitem action="..."> 会在解析时立即
|
||||
# env.ref() 解析动作的 xmlid,因此定义动作的 brain_views.xml 必须在前。
|
||||
'views/brain_views.xml',
|
||||
'data/brain_data.xml',
|
||||
'views/menu.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
|
||||
class BrainController(http.Controller):
|
||||
"""只读控制器:前端脑图页面与查询接口,无任何写操作。"""
|
||||
|
||||
# ---------------- 只读查询接口(JSON)----------------
|
||||
|
||||
@http.route('/yuthon_brain/api/<string:key>/graph', type='http',
|
||||
auth='user', methods=['GET'], csrf=False)
|
||||
def api_graph(self, key, **kw):
|
||||
"""返回某数据源的脑图结构:{root, nodes, edges, theme, layout}。"""
|
||||
source = request.env['yuthon.brain.source'].search(
|
||||
[('key', '=', key), ('active', '=', True)], limit=1)
|
||||
if not source:
|
||||
return Response(json.dumps({'error': 'not_found'}, ensure_ascii=False),
|
||||
mimetype='application/json', status=404)
|
||||
|
||||
nodes = []
|
||||
for n in source.node_ids:
|
||||
try:
|
||||
extra = json.loads(n.extra or '{}') if n.extra else {}
|
||||
except (ValueError, TypeError):
|
||||
extra = {}
|
||||
nodes.append({
|
||||
'id': n.id,
|
||||
'label': n.label,
|
||||
'parent_id': n.parent_id.id if n.parent_id else None,
|
||||
'rel_type': n.rel_type,
|
||||
'color': n.color or '#1E6FFF',
|
||||
'icon': n.icon or '',
|
||||
'level': n.level,
|
||||
'extra': extra,
|
||||
})
|
||||
|
||||
edges = [{
|
||||
'id': e.id,
|
||||
'from': e.from_id.id,
|
||||
'to': e.to_id.id,
|
||||
'rel_type': e.rel_type,
|
||||
'label': e.label or '',
|
||||
} for e in source.edge_ids]
|
||||
|
||||
payload = {
|
||||
'root': source.root_id.id if source.root_id else None,
|
||||
'theme': source.theme,
|
||||
'layout': source.layout,
|
||||
'source_name': source.name,
|
||||
'nodes': nodes,
|
||||
'edges': edges,
|
||||
}
|
||||
return Response(json.dumps(payload, ensure_ascii=False),
|
||||
mimetype='application/json')
|
||||
|
||||
@http.route('/yuthon_brain/api/sources', type='http',
|
||||
auth='user', methods=['GET'], csrf=False)
|
||||
def api_sources(self, **kw):
|
||||
"""已启用数据源列表。"""
|
||||
sources = request.env['yuthon.brain.source'].search(
|
||||
[('active', '=', True)], order='sequence, id')
|
||||
data = [{'key': s.key, 'name': s.name} for s in sources]
|
||||
return Response(json.dumps(data, ensure_ascii=False),
|
||||
mimetype='application/json')
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================
|
||||
测试数据源:读取 shcool 的 yuthon.about.page(不改 shcool 代码)
|
||||
安装后到「脑图管理」打开该记录,点「同步数据」即可生成脑图节点。
|
||||
============================================================ -->
|
||||
<record id="brain_source_shcool" model="yuthon.brain.source">
|
||||
<field name="name">shcool 文档脑图</field>
|
||||
<field name="key">shcool_doc</field>
|
||||
<field name="theme">light</field>
|
||||
<field name="layout">radial</field>
|
||||
<field name="max_levels">3</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="sync_model">yuthon.about.page</field>
|
||||
<field name="sync_name_field">name</field>
|
||||
<field name="sync_group_field">category</field>
|
||||
<field name="sync_domain">[('active', '=', True)]</field>
|
||||
</record>
|
||||
|
||||
<!-- 根节点(稳定契约,子模块可 ref 它挂接;非 auto,同步时不会被清理)-->
|
||||
<record id="brain_root_shcool" model="yuthon.brain.node">
|
||||
<field name="source_id" ref="brain_source_shcool"/>
|
||||
<field name="label">shcool 文档</field>
|
||||
<field name="rel_type">child</field>
|
||||
<field name="color">#0ea5e9</field>
|
||||
</record>
|
||||
|
||||
<!-- 把根节点指回 source.root_id(同名 id 合并写入)-->
|
||||
<record id="brain_source_shcool" model="yuthon.brain.source">
|
||||
<field name="root_id" ref="brain_root_shcool"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
数据源 2:shcool 功能与应用
|
||||
读取 ir.module.module(Odoo「应用」表)中带「功能亮点」的模块,
|
||||
把每个模块的 features HTML 拆成功能点,形成 模块 → 功能点 两层脑图。
|
||||
只读外部模型,不改 shcool 任何代码。
|
||||
============================================================ -->
|
||||
<record id="brain_source_shcool_module" model="yuthon.brain.source">
|
||||
<field name="name">shcool 功能与应用</field>
|
||||
<field name="key">shcool_module</field>
|
||||
<field name="theme">light</field>
|
||||
<field name="layout">radial</field>
|
||||
<field name="max_levels">3</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="sync_mode">feature</field>
|
||||
<field name="sync_model">ir.module.module</field>
|
||||
<field name="sync_name_field">shortdesc</field>
|
||||
<field name="sync_detail_field">features</field>
|
||||
<field name="sync_domain">[('features', '!=', False)]</field>
|
||||
</record>
|
||||
|
||||
<record id="brain_root_shcool_module" model="yuthon.brain.node">
|
||||
<field name="source_id" ref="brain_source_shcool_module"/>
|
||||
<field name="label">shcool 功能与应用</field>
|
||||
<field name="rel_type">child</field>
|
||||
<field name="color">#0ea5e9</field>
|
||||
</record>
|
||||
|
||||
<record id="brain_source_shcool_module" model="yuthon.brain.source">
|
||||
<field name="root_id" ref="brain_root_shcool_module"/>
|
||||
</record>
|
||||
|
||||
<!-- 安装 / 升级时自动同步一次,开箱即有节点;失败不阻断安装 -->
|
||||
<function model="yuthon.brain.source" name="action_sync_all" eval="[]"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import brain_source
|
||||
from . import brain_node
|
||||
from . import brain_edge
|
||||
@@ -0,0 +1,24 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class BrainEdge(models.Model):
|
||||
"""脑图连线:表达非树状的 jump / 跨模块引用。普通父子关系由 node.parent_id 承载,无需 edge。"""
|
||||
_name = 'yuthon.brain.edge'
|
||||
_description = '脑图连线'
|
||||
_order = 'sequence, id'
|
||||
|
||||
source_id = fields.Many2one(
|
||||
'yuthon.brain.source', string='数据源', required=True, ondelete='cascade',
|
||||
)
|
||||
from_id = fields.Many2one(
|
||||
'yuthon.brain.node', string='起点', required=True, ondelete='cascade',
|
||||
)
|
||||
to_id = fields.Many2one(
|
||||
'yuthon.brain.node', string='终点', required=True, ondelete='cascade',
|
||||
)
|
||||
rel_type = fields.Selection(
|
||||
[('jump', '跳转'), ('ref', '引用')],
|
||||
string='关系', default='jump',
|
||||
)
|
||||
label = fields.Char(string='连线说明')
|
||||
sequence = fields.Integer(default=10)
|
||||
@@ -0,0 +1,39 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class BrainNode(models.Model):
|
||||
"""脑图节点。父子关系通过 parent_id 自引用表达(等价于 menu.parent_id)。"""
|
||||
_name = 'yuthon.brain.node'
|
||||
_description = '脑图节点'
|
||||
_rec_name = 'label'
|
||||
_order = 'sequence, id'
|
||||
|
||||
source_id = fields.Many2one(
|
||||
'yuthon.brain.source', string='数据源', required=True, ondelete='cascade',
|
||||
)
|
||||
parent_id = fields.Many2one(
|
||||
'yuthon.brain.node', string='父节点', ondelete='cascade',
|
||||
)
|
||||
rel_type = fields.Selection(
|
||||
[('parent', '父级'), ('child', '子级'), ('jump', '跳转')],
|
||||
string='关系类型', default='child',
|
||||
)
|
||||
label = fields.Char(string='显示文字', required=True)
|
||||
color = fields.Char(string='颜色', default='#1E6FFF', help='节点主题色,十六进制')
|
||||
icon = fields.Char(string='图标', help='可选,前端展示用')
|
||||
extra = fields.Text(string='扩展数据(JSON)', help='前端自由展示的额外字段,JSON 字符串')
|
||||
auto = fields.Boolean(
|
||||
string='自动同步', default=False,
|
||||
help='由 source.action_sync() 生成;重同步时会被清理,手动维护的节点不会被清。',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
level = fields.Integer(string='层级', default=0, help='可选;前端按 parent_id 链推导真实深度')
|
||||
|
||||
child_ids = fields.One2many('yuthon.brain.node', 'parent_id', string='子节点')
|
||||
edge_from_ids = fields.One2many('yuthon.brain.edge', 'from_id', string='出边')
|
||||
edge_to_ids = fields.One2many('yuthon.brain.edge', 'to_id', string='入边')
|
||||
|
||||
_sql_constraints = [
|
||||
('src_parent_label_uniq', 'unique(source_id, parent_id, label)',
|
||||
'同一数据源内、同一父节点下显示文字不能重复'),
|
||||
]
|
||||
@@ -0,0 +1,297 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_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'<[^>]+>')
|
||||
_ENTITIES = (
|
||||
(' ', ' '), ('&', '&'), ('<', '<'),
|
||||
('>', '>'), ('"', '"'), (''', "'"),
|
||||
)
|
||||
|
||||
|
||||
def _plain(text):
|
||||
"""去掉 HTML 标签与常见实体,压平空白并剥掉首尾标点。"""
|
||||
s = _TAG_RE.sub('', text or '')
|
||||
for ent, ch in _ENTITIES:
|
||||
s = s.replace(ent, ch)
|
||||
return ' '.join(s.split()).strip(' \u3000::;;,,。')
|
||||
|
||||
|
||||
def parse_feature_html(html):
|
||||
"""把 features 这类 HTML 列表解析成 [(标题, 说明), ...]。
|
||||
|
||||
兼容三种写法(shcool 用的是第 1、3 种):
|
||||
<li><strong>师生管理</strong>:说明文字</li> → ('师生管理', '说明文字')
|
||||
<li>预置<strong>三组</strong>权限</li> → ('预置三组权限', '') strong 不在行首
|
||||
<li>整句说明</li> → ('整句说明', '')
|
||||
"""
|
||||
items = []
|
||||
seen = set()
|
||||
for raw in _LI_RE.findall(html or ''):
|
||||
m = _STRONG_RE.search(raw)
|
||||
if m and not _plain(raw[:m.start()]):
|
||||
# strong 在行首:标题取 strong 内容,其余作为说明
|
||||
title = _plain(m.group(1))
|
||||
desc = _plain(raw[m.end():])
|
||||
else:
|
||||
# 无 strong,或 strong 在句中(此时整句才是语义完整的标题)
|
||||
title, desc = _plain(raw), ''
|
||||
if not title or title in seen:
|
||||
continue
|
||||
seen.add(title)
|
||||
items.append((title, desc))
|
||||
return items
|
||||
|
||||
|
||||
class BrainSource(models.Model):
|
||||
"""脑图数据源:一个 source 对应一张可查看的脑图。
|
||||
|
||||
节点数据有两种来源,互不冲突:
|
||||
1) 数据继承:子模块在自己的 data/*.xml 里用 parent_id ref 挂接(推荐,零代码);
|
||||
2) ORM 同步:本模型的 action_sync() 在运行时读取某个业务模型(如 shcool 的
|
||||
yuthon.about.page)记录,按分组字段生成节点,用于「不改动对方模块」的测试/导入。
|
||||
"""
|
||||
|
||||
_name = 'yuthon.brain.source'
|
||||
_description = '脑图数据源'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='名称', required=True)
|
||||
key = fields.Char(
|
||||
string='路由标识',
|
||||
required=True,
|
||||
help='前端接口路由用,如 product / org。需唯一且只含字母数字下划线。',
|
||||
)
|
||||
root_id = fields.Many2one('yuthon.brain.node', string='根节点', ondelete='set null')
|
||||
layout = fields.Selection(
|
||||
[('radial', '放射状'), ('tree', '树状')],
|
||||
string='布局', default='radial',
|
||||
)
|
||||
theme = fields.Selection(
|
||||
[('dark', '深色'), ('light', '浅色')],
|
||||
string='主题', default='light',
|
||||
)
|
||||
max_levels = fields.Integer(string='最大展开层级', default=3)
|
||||
active = fields.Boolean(string='启用', default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
# ---- ORM 同步配置(可选;用于从外部模块读取数据,不改对方代码)----
|
||||
sync_mode = fields.Selection(
|
||||
[('record', '按记录分组'), ('feature', '解析功能亮点')],
|
||||
string='同步方式', default='record', required=True,
|
||||
help='按记录分组:每条记录生成一个节点;'
|
||||
'解析功能亮点:把每条记录的 HTML 详情字段(如模块的「功能亮点」)'
|
||||
'拆成若干功能点,作为该记录节点的子节点。',
|
||||
)
|
||||
sync_model = fields.Char(
|
||||
string='同步模型',
|
||||
help='目标业务模型技术名,如 yuthon.about.page、ir.module.module。留空则不使用 ORM 同步。',
|
||||
)
|
||||
sync_name_field = fields.Char(
|
||||
string='名称字段', default='name',
|
||||
help='作为节点显示文字的字段,默认 name;读模块时可用 shortdesc(中文显示名)。',
|
||||
)
|
||||
sync_group_field = fields.Char(
|
||||
string='分组字段',
|
||||
help='仅在「按记录分组」模式生效:按该字段值分组生成父节点(如 category)。留空则全部直接挂在根下。',
|
||||
)
|
||||
sync_detail_field = fields.Char(
|
||||
string='详情字段',
|
||||
help='仅在「解析功能亮点」模式生效:要拆解的 HTML 字段技术名,如 features。',
|
||||
)
|
||||
sync_domain = fields.Char(
|
||||
string='过滤条件', default='[]',
|
||||
help='Odoo domain 字符串,如 [(\'active\',\'=\',True)]。',
|
||||
)
|
||||
|
||||
node_ids = fields.One2many('yuthon.brain.node', 'source_id', string='节点')
|
||||
edge_ids = fields.One2many('yuthon.brain.edge', 'source_id', string='连线')
|
||||
|
||||
_sql_constraints = [
|
||||
('uniq_key', 'unique(key)', '路由标识(key)必须唯一'),
|
||||
]
|
||||
|
||||
def action_sync(self):
|
||||
"""从 sync_model 读取记录生成脑图节点(标记 auto,便于重同步清理)。
|
||||
|
||||
不改任何外部模块代码,仅在 yuthon_brain 内创建节点。
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.sync_model:
|
||||
raise UserError(_('请先设置「同步模型」'))
|
||||
if self.sync_model not in self.env:
|
||||
raise UserError(_(
|
||||
'目标模型 %s 未安装,无法同步(请确认对应模块已安装)'
|
||||
) % self.sync_model)
|
||||
|
||||
Model = self.env[self.sync_model]
|
||||
try:
|
||||
domain = safe_eval(self.sync_domain or '[]')
|
||||
except Exception:
|
||||
domain = []
|
||||
recs = Model.search(domain)
|
||||
|
||||
# 清掉上次自动同步的节点,保留手动维护的
|
||||
self.node_ids.filtered(lambda n: n.auto).unlink()
|
||||
|
||||
root = self.root_id
|
||||
if not root:
|
||||
root = self.env['yuthon.brain.node'].create({
|
||||
'source_id': self.id,
|
||||
'label': self.name,
|
||||
'rel_type': 'child',
|
||||
'color': '#0ea5e9',
|
||||
'auto': True,
|
||||
})
|
||||
self.root_id = root
|
||||
|
||||
name_field = self.sync_name_field or 'name'
|
||||
group_field = self.sync_group_field
|
||||
|
||||
if self.sync_mode == 'feature':
|
||||
created, skipped = self._sync_features(Model, recs, root, name_field)
|
||||
return self._done(created, recs, skipped)
|
||||
|
||||
created = 0
|
||||
if group_field and group_field in Model._fields:
|
||||
f = Model._fields[group_field]
|
||||
selection = dict(f.selection) if f.type == 'selection' else {}
|
||||
groups = {}
|
||||
for r in recs:
|
||||
gv = r[group_field]
|
||||
groups.setdefault(gv, []).append(r)
|
||||
for gv, grp in groups.items():
|
||||
glabel = selection.get(gv, gv) if gv else '未分类'
|
||||
gnode = self.env['yuthon.brain.node'].create({
|
||||
'source_id': self.id,
|
||||
'parent_id': root.id,
|
||||
'label': glabel,
|
||||
'rel_type': 'child',
|
||||
'color': '#2563eb',
|
||||
'auto': True,
|
||||
'extra': json.dumps({'group': True, 'value': gv},
|
||||
ensure_ascii=False),
|
||||
})
|
||||
for r in grp:
|
||||
self.env['yuthon.brain.node'].create({
|
||||
'source_id': self.id,
|
||||
'parent_id': gnode.id,
|
||||
'label': r[name_field] or ('#%d' % r.id),
|
||||
'rel_type': 'child',
|
||||
'color': '#1E6FFF',
|
||||
'auto': True,
|
||||
'extra': json.dumps(
|
||||
{'model': self.sync_model, 'res_id': r.id},
|
||||
ensure_ascii=False),
|
||||
})
|
||||
created += 1
|
||||
else:
|
||||
for r in recs:
|
||||
self.env['yuthon.brain.node'].create({
|
||||
'source_id': self.id,
|
||||
'parent_id': root.id,
|
||||
'label': r[name_field] or ('#%d' % r.id),
|
||||
'rel_type': 'child',
|
||||
'color': '#1E6FFF',
|
||||
'auto': True,
|
||||
'extra': json.dumps(
|
||||
{'model': self.sync_model, 'res_id': r.id},
|
||||
ensure_ascii=False),
|
||||
})
|
||||
created += 1
|
||||
|
||||
return self._done(created, recs)
|
||||
|
||||
@api.model
|
||||
def action_sync_all(self):
|
||||
"""安装/升级时自动同步所有已配置 ORM 的数据源,让脑图开箱即有内容。
|
||||
|
||||
单个数据源失败(例如目标模块未安装)只记日志、不阻断安装。
|
||||
"""
|
||||
for src in self.search([('sync_model', '!=', False)]):
|
||||
try:
|
||||
src.action_sync()
|
||||
_logger.info('yuthon_brain: 数据源 %s 自动同步完成', src.key)
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
'yuthon_brain: 数据源 %s 自动同步失败(已跳过)', src.key,
|
||||
exc_info=True)
|
||||
return True
|
||||
|
||||
def _done(self, created, recs, skipped=None):
|
||||
"""统一的同步结果提示。"""
|
||||
msg = _('已同步 %(rec)d 条记录、生成 %(n)d 个节点') % {
|
||||
'rec': len(recs), 'n': created,
|
||||
}
|
||||
if skipped:
|
||||
msg += _(';%d 条记录无功能亮点已跳过') % len(skipped)
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': msg,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def _sync_features(self, Model, recs, root, name_field):
|
||||
"""解析功能亮点模式:每条记录 → 一个父节点,其详情 HTML 的每个 <li> → 一个子节点。
|
||||
|
||||
用于把 ir.module.module 的「功能亮点」这类富文本清单摊平成脑图分支,
|
||||
全程只读外部模型,不修改对方任何代码或数据。
|
||||
"""
|
||||
detail_field = self.sync_detail_field
|
||||
if not detail_field:
|
||||
raise UserError(_('「解析功能亮点」模式需要先设置「详情字段」,例如 features'))
|
||||
if detail_field not in Model._fields:
|
||||
raise UserError(_(
|
||||
'模型 %s 上不存在字段 %s,请检查「详情字段」设置'
|
||||
) % (self.sync_model, detail_field))
|
||||
|
||||
Node = self.env['yuthon.brain.node']
|
||||
created = 0
|
||||
skipped = []
|
||||
for r in recs:
|
||||
items = parse_feature_html(r[detail_field])
|
||||
if not items:
|
||||
skipped.append(r[name_field] or r.display_name)
|
||||
continue
|
||||
label = (r[name_field] if name_field in Model._fields else '') \
|
||||
or r.display_name or ('#%d' % r.id)
|
||||
rec_node = Node.create({
|
||||
'source_id': self.id,
|
||||
'parent_id': root.id,
|
||||
'label': label,
|
||||
'rel_type': 'child',
|
||||
'color': '#2563eb',
|
||||
'auto': True,
|
||||
'extra': json.dumps(
|
||||
{'model': self.sync_model, 'res_id': r.id,
|
||||
'count': len(items)},
|
||||
ensure_ascii=False),
|
||||
})
|
||||
created += 1
|
||||
for title, desc in items:
|
||||
Node.create({
|
||||
'source_id': self.id,
|
||||
'parent_id': rec_node.id,
|
||||
'label': title,
|
||||
'rel_type': 'child',
|
||||
'color': '#1E6FFF',
|
||||
'auto': True,
|
||||
'extra': json.dumps(
|
||||
{'model': self.sync_model, 'res_id': r.id, 'desc': desc},
|
||||
ensure_ascii=False),
|
||||
})
|
||||
created += 1
|
||||
return created, skipped
|
||||
@@ -0,0 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_yuthon_brain_source_admin,yuthon.brain.source.admin,model_yuthon_brain_source,base.group_system,1,1,1,1
|
||||
access_yuthon_brain_source_user,yuthon.brain.source.user,model_yuthon_brain_source,base.group_user,1,0,0,0
|
||||
access_yuthon_brain_node_admin,yuthon.brain.node.admin,model_yuthon_brain_node,base.group_system,1,1,1,1
|
||||
access_yuthon_brain_node_user,yuthon.brain.node.user,model_yuthon_brain_node,base.group_user,1,0,0,0
|
||||
access_yuthon_brain_edge_admin,yuthon.brain.edge.admin,model_yuthon_brain_edge,base.group_system,1,1,1,1
|
||||
access_yuthon_brain_edge_user,yuthon.brain.edge.user,model_yuthon_brain_edge,base.group_user,1,0,0,0
|
||||
|
@@ -0,0 +1,898 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>脑图</title>
|
||||
<style>
|
||||
/* yuthon_brain 脑图前端 —— TheBrain 桌面版风格
|
||||
浅色纸感背景 / 无框纯文字节点 / 圆点装饰 / 浅蓝 S 形曲线 */
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-family: "Helvetica Neue", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
background: #e4f2ff;
|
||||
}
|
||||
|
||||
#brain-app {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: #3a4652;
|
||||
/* 淡天蓝底 + 中心白色光斑(取色对齐 TheBrain 参考图:四角 #E2F1FF 系,中心近白) */
|
||||
background:
|
||||
radial-gradient(62% 78% at 50% 42%, rgba(255, 255, 255, .92) 0%, rgba(255, 255, 255, 0) 100%),
|
||||
linear-gradient(152deg, #e1f0ff 0%, #e5f4ff 50%, #e1f0ff 100%);
|
||||
}
|
||||
|
||||
/* ===================== 顶栏 ===================== */
|
||||
.brain-topbar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 22px;
|
||||
right: 22px;
|
||||
z-index: 5;
|
||||
pointer-events: none; /* 让空白处可拖拽,按钮单独开 */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.brain-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #46535f;
|
||||
}
|
||||
.brain-logo { font-size: 17px; opacity: .75; }
|
||||
.brain-title {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .6px;
|
||||
color: #3b4854;
|
||||
}
|
||||
.brain-crumbs {
|
||||
flex: 1;
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #9aa7b4;
|
||||
line-height: 1.6;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.brain-crumbs .crumb {
|
||||
cursor: pointer;
|
||||
color: #77868f;
|
||||
transition: color .18s;
|
||||
}
|
||||
.brain-crumbs .crumb:hover { color: #1f2a33; }
|
||||
.brain-crumbs .sep { margin: 0 6px; color: #c3ced8; }
|
||||
|
||||
.brain-tools {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.brain-tools button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #e0e8ef;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, .82);
|
||||
color: #61707d;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(70, 100, 130, .05);
|
||||
transition: background .18s, color .18s, border-color .18s, transform .18s;
|
||||
}
|
||||
.brain-tools button:hover {
|
||||
background: #ffffff;
|
||||
color: #1f2a33;
|
||||
border-color: #c8d8e4;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ===================== 画布 ===================== */
|
||||
.brain-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
#brain-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
/* 默认用标准箭头(带黑边,浅底上最清晰);抓取时再切换 */
|
||||
cursor: default;
|
||||
}
|
||||
#brain-svg.grabbing { cursor: grabbing; }
|
||||
|
||||
/* 节点:默认纯文字无框(TheBrain 观感) */
|
||||
.brain-node { cursor: pointer; }
|
||||
.brain-node text {
|
||||
fill: #26313a;
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
letter-spacing: .2px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: fill .18s;
|
||||
}
|
||||
.brain-node.lv0 text { font-size: 18px; fill: #202a33; }
|
||||
.brain-node.lv2 text { font-size: 14px; fill: #4f5c68; }
|
||||
/* 焦点:字号放大 + 细描边圆角框(规则须排在层级规则之后) */
|
||||
.brain-node.active text { font-size: 22px; font-weight: 500; fill: #0d1318; }
|
||||
|
||||
.brain-node .frame {
|
||||
fill: rgba(255, 255, 255, .72);
|
||||
stroke: #bac9b7;
|
||||
stroke-width: 1;
|
||||
opacity: 0;
|
||||
transition: opacity .25s;
|
||||
}
|
||||
.brain-node.active .frame { opacity: 1; }
|
||||
|
||||
/* 端口空心圈(每个节点文字左侧) */
|
||||
.brain-node .port {
|
||||
fill: #ffffff;
|
||||
stroke: #7ec6ea;
|
||||
stroke-width: 1.4;
|
||||
opacity: .85;
|
||||
transition: stroke .18s, opacity .18s;
|
||||
}
|
||||
/* 散列装饰空心圈(云团感) */
|
||||
.brain-node .halo {
|
||||
fill: none;
|
||||
stroke: #9ed3ee;
|
||||
stroke-width: 1.2;
|
||||
opacity: .45;
|
||||
}
|
||||
/* 焦点上下实心蓝点 */
|
||||
.brain-node .bdot {
|
||||
fill: #2f9ee0;
|
||||
opacity: 0;
|
||||
transition: opacity .25s;
|
||||
}
|
||||
.brain-node.active .bdot { opacity: 1; }
|
||||
/* 上级节点的实心绿点 */
|
||||
.brain-node .gdot {
|
||||
fill: #3ec25f;
|
||||
opacity: 0;
|
||||
transition: opacity .25s;
|
||||
}
|
||||
.brain-node.is-parent .gdot { opacity: 1; }
|
||||
|
||||
.brain-node:hover text { fill: #05090c; }
|
||||
.brain-node:hover .port { stroke: #31a4e0; opacity: 1; }
|
||||
|
||||
/* 连线:浅蓝柔和曲线 */
|
||||
.brain-edge {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
opacity: 0;
|
||||
transition: opacity .3s;
|
||||
}
|
||||
|
||||
/* ===================== 详情面板 ===================== */
|
||||
.brain-detail {
|
||||
position: absolute;
|
||||
top: 64px; /* 让开右上角工具栏,避免按钮被面板压住 */
|
||||
right: 18px;
|
||||
width: 264px;
|
||||
max-height: calc(100vh - 190px);
|
||||
overflow: auto;
|
||||
background: rgba(255, 255, 255, .88);
|
||||
border: 1px solid #e4ecf2;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 28px rgba(62, 92, 122, .10);
|
||||
padding: 16px;
|
||||
font-size: 12.5px;
|
||||
color: #55636f;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 5;
|
||||
}
|
||||
.brain-detail-empty { color: #a4b1bd; }
|
||||
.brain-detail h3 { margin: 0 0 9px; font-size: 15px; font-weight: 600; color: #18222b; }
|
||||
.brain-detail .row { margin: 5px 0; line-height: 1.55; }
|
||||
.brain-detail .k { color: #93a1ae; }
|
||||
.brain-detail .desc {
|
||||
margin-top: 11px;
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid #edf3f8;
|
||||
color: #3d4a56;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===================== 图例 ===================== */
|
||||
.brain-legend {
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
left: 22px;
|
||||
z-index: 5;
|
||||
color: #7b8a97;
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, .68);
|
||||
border: 1px solid #e8eff5;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
line-height: 1.85;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.brain-legend .dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 7px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ===================== 底部提示 ===================== */
|
||||
.brain-hint {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #a8b5c1;
|
||||
font-size: 12px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 加载 / 空状态提示 */
|
||||
.brain-msg, .brain-empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 460px;
|
||||
text-align: center;
|
||||
color: #6b7a87;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.8;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid #e6eef5;
|
||||
box-shadow: 0 8px 26px rgba(62, 92, 122, .10);
|
||||
padding: 14px 20px;
|
||||
border-radius: 12px;
|
||||
z-index: 6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="brain-app" data-key="shcool_module">
|
||||
<header class="brain-topbar">
|
||||
<div class="brain-brand">
|
||||
<span class="brain-logo">🧠</span>
|
||||
<span class="brain-title" id="brain-title">脑图</span>
|
||||
</div>
|
||||
<nav class="brain-crumbs" id="brain-crumbs"></nav>
|
||||
<div class="brain-tools">
|
||||
<button id="brain-refresh" title="重新加载数据">↻</button>
|
||||
<button id="brain-zoom-in" title="放大">+</button>
|
||||
<button id="brain-zoom-out" title="缩小">−</button>
|
||||
<button id="brain-home" title="回到首页">⌂</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="brain-stage" id="brain-stage">
|
||||
<svg id="brain-svg" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<aside class="brain-detail" id="brain-detail">
|
||||
<div class="brain-detail-empty">点击节点查看详情</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="brain-hint">滚轮缩放 · 拖拽平移 · 点击节点聚焦</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* yuthon_brain 脑图前端:TheBrain 风格径向聚焦脑图(浅色 / 纯文字 / S 形曲线) */
|
||||
(function () {
|
||||
'use strict';
|
||||
var app = document.getElementById('brain-app');
|
||||
if (!app) return;
|
||||
|
||||
// 数据源 key:优先取自 URL ?key=,其次 data-key,再兜底默认值
|
||||
var key = new URLSearchParams(location.search).get('key')
|
||||
|| app.dataset.key
|
||||
|| 'shcool_module';
|
||||
app.dataset.key = key;
|
||||
|
||||
var svg = document.getElementById('brain-svg');
|
||||
var stage = document.getElementById('brain-stage');
|
||||
var detail = document.getElementById('brain-detail');
|
||||
var crumbs = document.getElementById('brain-crumbs');
|
||||
var titleEl = document.getElementById('brain-title');
|
||||
var SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// 连线:浅天蓝系(父级略淡 / 子级主蓝 / 跳转浅紫),整体柔和
|
||||
var COLORS = { parent: '#a6cbe4', child: '#8ac3e8', jump: '#c3c0e8' };
|
||||
var R = 240; // 邻居到中心的距离
|
||||
var rafId = null, animStart = 0, animDur = 460;
|
||||
var easeInOut = function (t) { return t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; };
|
||||
var lerp = function (a, b, t) { return a + (b - a) * t; };
|
||||
|
||||
var state = {
|
||||
nodes: {}, adj: {}, depth: {}, root: null, focus: null,
|
||||
panX: 0, panY: 0, zoom: 1,
|
||||
nodeEls: {}, nodeParts: {}, nodeCur: {}, nodeTo: {}, nodeOp: {},
|
||||
edgeMap: {}, parentSet: {},
|
||||
};
|
||||
|
||||
function create(tag) { return document.createElementNS(SVGNS, tag); }
|
||||
function toNum(v) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
return typeof v === 'number' ? v : parseInt(v, 10);
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function relLabel(r) {
|
||||
return { parent: '父级', child: '子级', jump: '跳转', ref: '引用' }[r] || r;
|
||||
}
|
||||
// 稳定散列:同一节点每次刷新装饰点位置一致,避免动画中抖动
|
||||
function hashNum(id) {
|
||||
var s = String(id), h = 0;
|
||||
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 997;
|
||||
return h;
|
||||
}
|
||||
|
||||
/* ---------------- 画布容器 ---------------- */
|
||||
var viewport = create('g'); viewport.setAttribute('id', 'brain-viewport');
|
||||
var gEdges = create('g'); gEdges.setAttribute('id', 'brain-edges');
|
||||
var gNodes = create('g'); gNodes.setAttribute('id', 'brain-nodes');
|
||||
viewport.appendChild(gEdges); viewport.appendChild(gNodes);
|
||||
svg.appendChild(viewport);
|
||||
|
||||
/* ---------------- 提示 ---------------- */
|
||||
function showMsg(text) {
|
||||
clearMsg();
|
||||
var d = document.createElement('div');
|
||||
d.className = 'brain-msg';
|
||||
d.textContent = text;
|
||||
stage.appendChild(d);
|
||||
}
|
||||
function clearMsg() {
|
||||
var old = stage.querySelector('.brain-msg');
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
function load() {
|
||||
fetch('/yuthon_brain/api/' + key + '/graph')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.error) { showMsg('数据源不存在或未启用'); return; }
|
||||
if (data && data.source_name && titleEl) titleEl.textContent = data.source_name;
|
||||
clearMsg();
|
||||
build(data);
|
||||
layout(state.focus);
|
||||
applyViewport();
|
||||
renderDetail();
|
||||
// 布局稳定后再校准一次中心(首帧尺寸可能未定)
|
||||
window.requestAnimationFrame(applyViewport);
|
||||
})
|
||||
.catch(function () { showMsg('加载失败,请确认已登录且模块已安装'); });
|
||||
}
|
||||
|
||||
function build(data) {
|
||||
state.nodes = {};
|
||||
(data.nodes || []).forEach(function (n) { state.nodes[n.id] = n; });
|
||||
|
||||
// 对称邻接表(父子双向识别,只需写一次 parent_id)
|
||||
var adj = {};
|
||||
Object.keys(state.nodes).forEach(function (id) { adj[id] = []; });
|
||||
(data.nodes || []).forEach(function (n) {
|
||||
if (n.parent_id && state.nodes[n.parent_id]) {
|
||||
adj[n.parent_id].push({ to: n.id, rel: 'child' });
|
||||
adj[n.id].push({ to: n.parent_id, rel: 'parent' });
|
||||
}
|
||||
});
|
||||
(data.edges || []).forEach(function (e) {
|
||||
var rel = e.rel_type || 'jump';
|
||||
adj[e.from] = adj[e.from] || [];
|
||||
adj[e.to] = adj[e.to] || [];
|
||||
adj[e.from].push({ to: e.to, rel: rel });
|
||||
adj[e.to].push({ to: e.from, rel: rel });
|
||||
});
|
||||
state.adj = adj;
|
||||
|
||||
var ids = Object.keys(state.nodes);
|
||||
state.root = data.root || (ids.length ? toNum(ids[0]) : null);
|
||||
if (state.focus === null || !state.nodes[state.focus]) state.focus = state.root;
|
||||
|
||||
calcDepth();
|
||||
buildEdges();
|
||||
buildNodeEls();
|
||||
}
|
||||
|
||||
// 每个节点距根节点的层级(root=0 → 一级=1 → 二级=2),用于区分字号与尺寸
|
||||
function calcDepth() {
|
||||
state.depth = {};
|
||||
Object.keys(state.nodes).forEach(function (id) {
|
||||
var d = 0, cur = toNum(id), guard = 0;
|
||||
while (guard++ < 60) {
|
||||
var p = (state.adj[cur] || []).filter(function (x) { return x.rel === 'parent'; })[0];
|
||||
if (!p) break;
|
||||
cur = p.to; d++;
|
||||
}
|
||||
state.depth[id] = d;
|
||||
});
|
||||
}
|
||||
|
||||
function buildEdges() {
|
||||
while (gEdges.firstChild) gEdges.removeChild(gEdges.firstChild);
|
||||
state.edgeMap = {};
|
||||
Object.keys(state.adj).forEach(function (id) {
|
||||
state.adj[id].forEach(function (nb) {
|
||||
var k = [String(id), String(nb.to)].sort().join('|');
|
||||
if (!state.edgeMap[k]) {
|
||||
var p = create('path');
|
||||
p.setAttribute('class', 'brain-edge');
|
||||
gEdges.appendChild(p);
|
||||
// s/t 统一存数字:状态里的 focus/邻接 id 都是数字,避免 === 永远为假
|
||||
state.edgeMap[k] = { s: toNum(id), t: toNum(nb.to), el: p, visible: false, curRel: 'jump' };
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 节点尺寸(布局与渲染共用,保证避让准确)---------------- */
|
||||
var LABEL_MAX = 14; // 标签显示上限,超出省略;完整文字见右侧详情面板
|
||||
function dispLabel(id) {
|
||||
var n = state.nodes[id];
|
||||
var s = (n && n.label) || '';
|
||||
return s.length > LABEL_MAX ? s.slice(0, LABEL_MAX - 1) + '…' : s;
|
||||
}
|
||||
function fontSizeOf(id) {
|
||||
if (toNum(id) === state.focus) return 22;
|
||||
var d = state.depth[id] || 0;
|
||||
return d === 0 ? 18 : (d === 1 ? 17 : 14);
|
||||
}
|
||||
function textWidth(id) {
|
||||
return dispLabel(id).length * fontSizeOf(id) * 0.96;
|
||||
}
|
||||
// 节点占位尺寸:焦点含描边框留白,非焦点只留端口圈/装饰圈的位置
|
||||
function nodeWidth(id) {
|
||||
var w = textWidth(id);
|
||||
return toNum(id) === state.focus ? w + 52 : w + 34;
|
||||
}
|
||||
function nodeHeight(id) {
|
||||
var fs = fontSizeOf(id);
|
||||
return toNum(id) === state.focus ? fs + 24 : fs + 10;
|
||||
}
|
||||
|
||||
// 装饰空心圈的 4 个候选位(相对文字中心),散列挑选营造 TheBrain 的云团感
|
||||
function haloSpot(i, tw, h) {
|
||||
var spots = [
|
||||
{ dx: -tw / 2 - 30, dy: -h / 2 - 10, r: 4.4 },
|
||||
{ dx: tw / 2 + 28, dy: -h / 2 - 6, r: 4.8 },
|
||||
{ dx: tw / 2 + 44, dy: h / 2 + 9, r: 4.0 },
|
||||
{ dx: -tw / 2 - 22, dy: h / 2 + 12, r: 4.2 }
|
||||
];
|
||||
return spots[i];
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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 ttl = create('title');
|
||||
ttl.textContent = n.label || ''; // 悬停显示完整名称
|
||||
|
||||
// 焦点描边框(仅焦点可见)
|
||||
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);
|
||||
|
||||
g.appendChild(ttl);
|
||||
g.appendChild(frame);
|
||||
|
||||
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);
|
||||
|
||||
(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;
|
||||
});
|
||||
}
|
||||
|
||||
/* 焦点切换会改变字号(焦点放大),此处按新度量刷新该节点的框、装饰点与文字,
|
||||
不重建元素 —— 位置动画因此保持平滑移动,而非从中心重新飞出 */
|
||||
function refreshNodeMetrics(id) {
|
||||
var parts = state.nodeParts[id];
|
||||
if (!parts) return;
|
||||
var lbl = dispLabel(id);
|
||||
var fs = fontSizeOf(id);
|
||||
var tw = lbl.length * fs * 0.96;
|
||||
var w = nodeWidth(id), h = nodeHeight(id);
|
||||
parts.text.textContent = lbl;
|
||||
parts.frame.setAttribute('x', -w / 2);
|
||||
parts.frame.setAttribute('y', -h / 2);
|
||||
parts.frame.setAttribute('width', w);
|
||||
parts.frame.setAttribute('height', h);
|
||||
parts.port.setAttribute('cx', -tw / 2 - 14);
|
||||
parts.halos.forEach(function (o) {
|
||||
var s = haloSpot(o.ci, tw, h);
|
||||
o.el.setAttribute('cx', s.dx);
|
||||
o.el.setAttribute('cy', s.dy);
|
||||
});
|
||||
parts.bTop.setAttribute('cy', -h / 2 + 8);
|
||||
parts.bBot.setAttribute('cy', h / 2 + 8);
|
||||
parts.gdot.setAttribute('cx', tw / 2 + 13);
|
||||
parts.gdot.setAttribute('cy', h / 2 - 4);
|
||||
}
|
||||
|
||||
/* ---------------- 布局(径向聚焦)---------------- */
|
||||
function layout(focus) {
|
||||
var ns = state.adj[focus] || [];
|
||||
var parents = ns.filter(function (x) { return x.rel === 'parent'; });
|
||||
var children = ns.filter(function (x) { return x.rel === 'child'; });
|
||||
var jumps = ns.filter(function (x) { return x.rel === 'jump'; });
|
||||
|
||||
// 上级节点集合(供 step 打 is-parent 类,显示绿点)
|
||||
state.parentSet = {};
|
||||
parents.forEach(function (x) { state.parentSet[x.to] = true; });
|
||||
|
||||
// 默认全部回到中心并隐藏,只展开焦点及其直接邻居
|
||||
Object.keys(state.nodes).forEach(function (id) {
|
||||
if (id !== focus) { state.nodeTo[id] = { x: 0, y: 0 }; state.nodeOp[id] = 0; }
|
||||
});
|
||||
state.nodeTo[focus] = { x: 0, y: 0 }; state.nodeOp[focus] = 1;
|
||||
|
||||
place(parents, 'parent');
|
||||
place(children, 'child');
|
||||
place(jumps, 'jump');
|
||||
|
||||
// 连线:仅与焦点相连者可见,并按焦点视角着色
|
||||
Object.keys(state.edgeMap).forEach(function (k) {
|
||||
var ed = state.edgeMap[k];
|
||||
if (ed.s === focus || ed.t === focus) {
|
||||
var other = ed.s === focus ? ed.t : ed.s;
|
||||
var hit = (state.adj[focus] || []).filter(function (x) { return x.to === other; })[0];
|
||||
ed.curRel = hit ? hit.rel : 'jump';
|
||||
ed.visible = true;
|
||||
} else {
|
||||
ed.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
fitView();
|
||||
renderCrumbs(focus);
|
||||
renderEmptyHint(focus);
|
||||
startAnim();
|
||||
}
|
||||
|
||||
function place(list, kind) {
|
||||
var n = list.length;
|
||||
if (!n) return;
|
||||
if (kind === 'parent' || kind === 'child') {
|
||||
// 张角随数量增大(最多 160°),节点多时不至于挤成一条直线
|
||||
var span = Math.min(160, 70 + n * 11);
|
||||
// 反推半径:保证弧长摆得下这组节点的全部宽度,避免长标签互相压盖
|
||||
var need = 0;
|
||||
list.forEach(function (nb) { need += nodeWidth(nb.to) + 26; });
|
||||
var minR = need / (span * Math.PI / 180);
|
||||
var r = Math.max(R, Math.min(820, minR));
|
||||
list.forEach(function (nb, i) {
|
||||
var off = n <= 1 ? 0 : (-span / 2 + span * i / (n - 1));
|
||||
var a = off * Math.PI / 180;
|
||||
state.nodeTo[nb.to] = {
|
||||
x: r * Math.sin(a),
|
||||
y: (kind === 'parent' ? -1 : 1) * r * Math.cos(a),
|
||||
};
|
||||
state.nodeOp[nb.to] = 1;
|
||||
});
|
||||
} else { // 跳转放两侧
|
||||
list.forEach(function (nb, i) {
|
||||
var side = i % 2 === 0 ? 1 : -1;
|
||||
var row = Math.floor(i / 2);
|
||||
state.nodeTo[nb.to] = {
|
||||
x: side * R * 1.15,
|
||||
y: (row - (Math.ceil(n / 2) - 1) / 2) * 96,
|
||||
};
|
||||
state.nodeOp[nb.to] = 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* 自适应:把当前这一屏的节点整体缩放并居中,保证全部落在可视区内 */
|
||||
function fitView() {
|
||||
var r = svg.getBoundingClientRect();
|
||||
var w = r.width || svg.clientWidth || window.innerWidth;
|
||||
var h = r.height || svg.clientHeight || window.innerHeight;
|
||||
var minX = 0, maxX = 0, minY = 0, maxY = 0, any = false;
|
||||
Object.keys(state.nodeTo).forEach(function (id) {
|
||||
if (!state.nodeOp[id]) return;
|
||||
var p = state.nodeTo[id];
|
||||
var hw = nodeWidth(id) / 2 + 22, hhh = nodeHeight(id) / 2 + 22;
|
||||
any = true;
|
||||
minX = Math.min(minX, p.x - hw); maxX = Math.max(maxX, p.x + hw);
|
||||
minY = Math.min(minY, p.y - hhh); maxY = Math.max(maxY, p.y + hhh);
|
||||
});
|
||||
state.panX = 0; state.panY = 0;
|
||||
if (!any) { state.zoom = 1; return; }
|
||||
var sx = (w - 90) / Math.max(1, maxX - minX);
|
||||
var sy = (h - 220) / Math.max(1, maxY - minY); // 顶栏 + 底部图例/提示留白
|
||||
state.zoom = Math.max(0.3, Math.min(1, Math.min(sx, sy)));
|
||||
// 内容中心对齐屏幕中心
|
||||
state.panX = -((minX + maxX) / 2) * state.zoom;
|
||||
state.panY = -((minY + maxY) / 2) * state.zoom;
|
||||
}
|
||||
|
||||
/* ---------------- 平滑动画 ---------------- */
|
||||
function startAnim() {
|
||||
Object.keys(state.nodeCur).forEach(function (id) {
|
||||
var c = state.nodeCur[id];
|
||||
c._fx = c.x; c._fy = c.y; c._fop = c.op;
|
||||
});
|
||||
animStart = performance.now();
|
||||
if (!rafId) rafId = requestAnimationFrame(step);
|
||||
}
|
||||
function step(now) {
|
||||
var t = Math.min(1, (now - animStart) / animDur);
|
||||
var e = easeInOut(t);
|
||||
Object.keys(state.nodes).forEach(function (id) {
|
||||
var c = state.nodeCur[id];
|
||||
c.x = lerp(c._fx, state.nodeTo[id].x, e);
|
||||
c.y = lerp(c._fy, state.nodeTo[id].y, e);
|
||||
var op = lerp(c._fop, state.nodeOp[id], e);
|
||||
c.op = op; // 回写,供下一次动画作为起点
|
||||
var g = state.nodeEls[id];
|
||||
if (!g) return;
|
||||
g.setAttribute('transform', 'translate(' + c.x + ',' + c.y + ')');
|
||||
g.setAttribute('opacity', op);
|
||||
var isFocus = toNum(id) === state.focus;
|
||||
if (isFocus) g.classList.add('active'); else g.classList.remove('active');
|
||||
if (state.parentSet[toNum(id)]) g.classList.add('is-parent');
|
||||
else g.classList.remove('is-parent');
|
||||
});
|
||||
Object.keys(state.edgeMap).forEach(function (k) {
|
||||
var ed = state.edgeMap[k];
|
||||
if (ed.visible) {
|
||||
var a = state.nodeCur[ed.s], b = state.nodeCur[ed.t];
|
||||
ed.el.setAttribute('d', curvePath(ed.s, ed.t, a, b));
|
||||
ed.el.setAttribute('stroke', COLORS[ed.curRel] || COLORS.jump);
|
||||
ed.el.style.opacity = (state.nodeOp[ed.s] > .5 && state.nodeOp[ed.t] > .5) ? 1 : 0;
|
||||
} else {
|
||||
ed.el.style.opacity = 0;
|
||||
}
|
||||
});
|
||||
if (t < 1) rafId = requestAnimationFrame(step); else rafId = null;
|
||||
}
|
||||
|
||||
/* S 形贝塞尔:沿主轴向(水平/垂直)进出节点边缘,贴 TheBrain 的柔和曲线观感 */
|
||||
function curvePath(idA, idB, a, b) {
|
||||
var dx = b.x - a.x, dy = b.y - a.y;
|
||||
var ewA = nodeWidth(idA) / 2, ehA = nodeHeight(idA) / 2;
|
||||
var ewB = nodeWidth(idB) / 2, ehB = nodeHeight(idB) / 2;
|
||||
var horiz = Math.abs(dx) >= Math.abs(dy);
|
||||
var sx, sy, ex, ey, mx, my, d;
|
||||
if (horiz) {
|
||||
sx = a.x + (dx >= 0 ? ewA : -ewA); sy = a.y;
|
||||
ex = b.x + (dx >= 0 ? -ewB : ewB); ey = b.y;
|
||||
mx = (sx + ex) / 2;
|
||||
d = 'M ' + sx + ' ' + sy + ' C ' + mx + ' ' + sy + ', ' + mx + ' ' + ey + ', ' + ex + ' ' + ey;
|
||||
} else {
|
||||
sx = a.x; sy = a.y + (dy >= 0 ? ehA : -ehA);
|
||||
ex = b.x; ey = b.y + (dy >= 0 ? -ehB : ehB);
|
||||
my = (sy + ey) / 2;
|
||||
d = 'M ' + sx + ' ' + sy + ' C ' + sx + ' ' + my + ', ' + ex + ' ' + my + ', ' + ex + ' ' + ey;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function setFocus(id) {
|
||||
var nid = toNum(id);
|
||||
if (nid === null || !state.nodes[nid]) return;
|
||||
if (nid === state.focus) return;
|
||||
var prev = state.focus;
|
||||
state.focus = nid;
|
||||
// 焦点切换会改变字号与描边框,刷新新旧两个节点的度量即可
|
||||
refreshNodeMetrics(prev);
|
||||
refreshNodeMetrics(nid);
|
||||
layout(nid);
|
||||
applyViewport();
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
/* ---------------- 面包屑 ---------------- */
|
||||
function renderCrumbs(focus) {
|
||||
crumbs.innerHTML = '';
|
||||
var chain = [];
|
||||
var cur = focus;
|
||||
var guard = 0;
|
||||
while (cur !== null && state.nodes[cur] && guard++ < 100) {
|
||||
chain.unshift(cur);
|
||||
var p = (state.adj[cur] || []).filter(function (x) { return x.rel === 'parent'; })[0];
|
||||
cur = p ? p.to : null;
|
||||
}
|
||||
chain.forEach(function (id, i) {
|
||||
if (i > 0) {
|
||||
var sep = document.createElement('span');
|
||||
sep.className = 'sep'; sep.textContent = '›';
|
||||
crumbs.appendChild(sep);
|
||||
}
|
||||
var a = document.createElement('span');
|
||||
a.className = 'crumb';
|
||||
a.textContent = state.nodes[id].label;
|
||||
(function (nodeId) { a.addEventListener('click', function () { setFocus(nodeId); }); })(id);
|
||||
crumbs.appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 详情面板 ---------------- */
|
||||
function renderDetail() {
|
||||
var n = state.nodes[state.focus];
|
||||
if (!n) { detail.innerHTML = '<div class="brain-detail-empty">点击节点查看详情</div>'; return; }
|
||||
var html = '<h3>' + escapeHtml(n.label) + '</h3>';
|
||||
var ex = n.extra || {};
|
||||
if (ex.group) html += '<div class="row"><span class="k">类型:</span>分组节点</div>';
|
||||
if (ex.model) html += '<div class="row"><span class="k">来源模型:</span>' + escapeHtml(ex.model) + ' #' + escapeHtml(String(ex.res_id || '')) + '</div>';
|
||||
if (ex.value || ex.value === 0) html += '<div class="row"><span class="k">分组值:</span>' + escapeHtml(String(ex.value)) + '</div>';
|
||||
if (ex.count) html += '<div class="row"><span class="k">功能点:</span>' + escapeHtml(String(ex.count)) + ' 项</div>';
|
||||
html += '<div class="row"><span class="k">关系:</span>' + relLabel(n.rel_type) + '</div>';
|
||||
var nb = (state.adj[state.focus] || []).filter(function (x) { return x.rel === 'child'; });
|
||||
html += '<div class="row"><span class="k">下级节点:</span>' + nb.length + ' 个</div>';
|
||||
if (ex.desc) html += '<div class="desc">' + escapeHtml(ex.desc) + '</div>';
|
||||
detail.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderEmptyHint(focus) {
|
||||
clearEmpty();
|
||||
var ns = state.adj[focus] || [];
|
||||
if (ns.length) return;
|
||||
var n = state.nodes[focus];
|
||||
if (n && n.extra && n.extra.desc) return; // 有说明文字则不需要引导
|
||||
var d = document.createElement('div');
|
||||
d.className = 'brain-empty';
|
||||
d.textContent = '「' + (n ? n.label : '') + '」暂无关联节点。'
|
||||
+ '如果脑图是空的,请到「脑图管理」打开该数据源并点击顶部「同步数据」生成节点,'
|
||||
+ '然后点右上角 ↻ 重新加载。';
|
||||
stage.appendChild(d);
|
||||
}
|
||||
function clearEmpty() {
|
||||
var old = stage.querySelector('.brain-empty');
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
}
|
||||
|
||||
/* ---------------- 视口变换(缩放/平移)---------------- */
|
||||
function applyViewport() {
|
||||
var r = svg.getBoundingClientRect();
|
||||
var w = r.width || svg.clientWidth || window.innerWidth;
|
||||
var h = r.height || svg.clientHeight || window.innerHeight;
|
||||
viewport.setAttribute('transform',
|
||||
'translate(' + (w / 2 + state.panX) + ',' + (h / 2 + state.panY) +
|
||||
') scale(' + state.zoom + ')');
|
||||
}
|
||||
|
||||
// 拖拽平移(空白处)
|
||||
var dragging = false, sx = 0, sy = 0, spx = 0, spy = 0;
|
||||
svg.addEventListener('pointerdown', function (e) {
|
||||
dragging = true; sx = e.clientX; sy = e.clientY; spx = state.panX; spy = state.panY;
|
||||
svg.classList.add('grabbing');
|
||||
});
|
||||
window.addEventListener('pointermove', function (e) {
|
||||
if (!dragging) return;
|
||||
state.panX = spx + (e.clientX - sx);
|
||||
state.panY = spy + (e.clientY - sy);
|
||||
applyViewport();
|
||||
});
|
||||
window.addEventListener('pointerup', function () {
|
||||
dragging = false; svg.classList.remove('grabbing');
|
||||
});
|
||||
|
||||
// 滚轮缩放(以光标为中心)
|
||||
svg.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
var r = svg.getBoundingClientRect();
|
||||
var mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
var nz = Math.max(0.3, Math.min(2.5, state.zoom * factor));
|
||||
var wx = (mx - r.width / 2 - state.panX) / state.zoom;
|
||||
var wy = (my - r.height / 2 - state.panY) / state.zoom;
|
||||
state.panX = mx - r.width / 2 - nz * wx;
|
||||
state.panY = my - r.height / 2 - nz * wy;
|
||||
state.zoom = nz;
|
||||
applyViewport();
|
||||
}, { passive: false });
|
||||
|
||||
function bind(id, fn) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener('click', fn);
|
||||
}
|
||||
bind('brain-zoom-in', function () { state.zoom = Math.min(2.5, 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-refresh', function () { state.focus = null; load(); });
|
||||
|
||||
window.addEventListener('resize', function () { applyViewport(); });
|
||||
window.addEventListener('load', function () { applyViewport(); });
|
||||
|
||||
/* ---------------- 图例 ---------------- */
|
||||
(function injectLegend() {
|
||||
var lg = document.createElement('div');
|
||||
lg.className = 'brain-legend';
|
||||
lg.innerHTML =
|
||||
'<div><span class="dot" style="background:#2f9ee0"></span>当前焦点</div>' +
|
||||
'<div><span class="dot" style="background:#3ec25f"></span>上级节点</div>' +
|
||||
'<div><span class="dot" style="background:#8cc4e6"></span>关联连线</div>';
|
||||
app.appendChild(lg);
|
||||
})();
|
||||
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ===================== 数据源 ===================== -->
|
||||
<record id="yuthon_brain_source_list" model="ir.ui.view">
|
||||
<field name="name">yuthon.brain.source.list</field>
|
||||
<field name="model">yuthon.brain.source</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="key"/>
|
||||
<field name="theme"/>
|
||||
<field name="layout"/>
|
||||
<field name="sync_model"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="yuthon_brain_source_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.brain.source.form</field>
|
||||
<field name="model">yuthon.brain.source</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_sync" type="object" string="同步数据"
|
||||
class="oe_highlight"
|
||||
help="从「同步模型」读取记录生成脑图节点(不改动对方模块)"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="key"/>
|
||||
<field name="active"/>
|
||||
<field name="theme"/>
|
||||
<field name="layout"/>
|
||||
<field name="max_levels"/>
|
||||
<field name="root_id"/>
|
||||
</group>
|
||||
<group string="ORM 同步配置(可选 · 用于不改对方模块读取数据)">
|
||||
<field name="sync_mode"/>
|
||||
<field name="sync_model"/>
|
||||
<field name="sync_name_field"/>
|
||||
<field name="sync_group_field"
|
||||
invisible="sync_mode != 'record'"/>
|
||||
<field name="sync_detail_field"
|
||||
invisible="sync_mode != 'feature'"
|
||||
required="sync_mode == 'feature'"/>
|
||||
<field name="sync_domain"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="节点">
|
||||
<field name="node_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="label"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="rel_type"/>
|
||||
<field name="color"/>
|
||||
<field name="auto"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="连线">
|
||||
<field name="edge_ids">
|
||||
<list editable="bottom">
|
||||
<field name="from_id"/>
|
||||
<field name="to_id"/>
|
||||
<field name="rel_type"/>
|
||||
<field name="label"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="yuthon_brain_source_action" model="ir.actions.act_window">
|
||||
<field name="name">脑图数据源</field>
|
||||
<field name="res_model">yuthon.brain.source</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 节点 ===================== -->
|
||||
<record id="yuthon_brain_node_list" model="ir.ui.view">
|
||||
<field name="name">yuthon.brain.node.list</field>
|
||||
<field name="model">yuthon.brain.node</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="source_id"/>
|
||||
<field name="label"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="rel_type"/>
|
||||
<field name="sequence"/>
|
||||
<field name="auto"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="yuthon_brain_node_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.brain.node.form</field>
|
||||
<field name="model">yuthon.brain.node</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="source_id"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="label"/>
|
||||
<field name="rel_type"/>
|
||||
<field name="color"/>
|
||||
<field name="icon"/>
|
||||
<field name="sequence"/>
|
||||
<field name="auto"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="extra" placeholder='{"k":"v"}'/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================== 连线 ===================== -->
|
||||
<record id="yuthon_brain_edge_list" model="ir.ui.view">
|
||||
<field name="name">yuthon.brain.edge.list</field>
|
||||
<field name="model">yuthon.brain.edge</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="source_id"/>
|
||||
<field name="from_id"/>
|
||||
<field name="to_id"/>
|
||||
<field name="rel_type"/>
|
||||
<field name="label"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="yuthon_brain_edge_form" model="ir.ui.view">
|
||||
<field name="name">yuthon.brain.edge.form</field>
|
||||
<field name="model">yuthon.brain.edge</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="source_id"/>
|
||||
<field name="from_id"/>
|
||||
<field name="to_id"/>
|
||||
<field name="rel_type"/>
|
||||
<field name="label"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- ===================== 顶层菜单 ===================== -->
|
||||
<menuitem id="menu_yuthon_brain_root"
|
||||
name="脑图"
|
||||
web_icon="yuthon_brain,static/src/img/the_brain.png"
|
||||
sequence="90"/>
|
||||
|
||||
<!-- 后台配置入口:仅系统管理员可见 -->
|
||||
<menuitem id="menu_yuthon_brain_source"
|
||||
name="脑图管理"
|
||||
parent="menu_yuthon_brain_root"
|
||||
action="yuthon_brain_source_action"
|
||||
sequence="90"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<!-- ===================== 只读展示入口 ===================== -->
|
||||
<!-- 以新标签打开脑图页;数据由 /yuthon_brain/api/<key>/graph 只读接口提供 -->
|
||||
<record id="action_yuthon_brain_view_shcool" model="ir.actions.act_url">
|
||||
<field name="name">shcool 文档脑图</field>
|
||||
<field name="url">/yuthon_brain/static/src/brain_view.html?key=shcool_doc</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<menuitem id="menu_yuthon_brain_view_shcool"
|
||||
name="shcool 文档脑图"
|
||||
parent="menu_yuthon_brain_root"
|
||||
action="action_yuthon_brain_view_shcool"
|
||||
sequence="10"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<!-- shcool 各模块的功能与应用(读 ir.module.module 的「功能亮点」) -->
|
||||
<record id="action_yuthon_brain_view_shcool_module" model="ir.actions.act_url">
|
||||
<field name="name">shcool 功能与应用</field>
|
||||
<field name="url">/yuthon_brain/static/src/brain_view.html?key=shcool_module</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<menuitem id="menu_yuthon_brain_view_shcool_module"
|
||||
name="shcool 功能与应用"
|
||||
parent="menu_yuthon_brain_root"
|
||||
action="action_yuthon_brain_view_shcool_module"
|
||||
sequence="20"
|
||||
groups="base.group_user"/>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user