diff --git a/yuthon_brain/__init__.py b/yuthon_brain/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/yuthon_brain/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/yuthon_brain/__manifest__.py b/yuthon_brain/__manifest__.py new file mode 100644 index 0000000..53b53d4 --- /dev/null +++ b/yuthon_brain/__manifest__.py @@ -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 里的 会在解析时立即 + # env.ref() 解析动作的 xmlid,因此定义动作的 brain_views.xml 必须在前。 + 'views/brain_views.xml', + 'data/brain_data.xml', + 'views/menu.xml', + ], + 'installable': True, + 'application': True, +} diff --git a/yuthon_brain/controllers/__init__.py b/yuthon_brain/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/yuthon_brain/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/yuthon_brain/controllers/main.py b/yuthon_brain/controllers/main.py new file mode 100644 index 0000000..b23c255 --- /dev/null +++ b/yuthon_brain/controllers/main.py @@ -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//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') diff --git a/yuthon_brain/data/brain_data.xml b/yuthon_brain/data/brain_data.xml new file mode 100644 index 0000000..9152927 --- /dev/null +++ b/yuthon_brain/data/brain_data.xml @@ -0,0 +1,66 @@ + + + + + shcool 文档脑图 + shcool_doc + light + radial + 3 + 10 + yuthon.about.page + name + category + [('active', '=', True)] + + + + + + shcool 文档 + child + #0ea5e9 + + + + + + + + + + shcool 功能与应用 + shcool_module + light + radial + 3 + 20 + feature + ir.module.module + shortdesc + features + [('features', '!=', False)] + + + + + shcool 功能与应用 + child + #0ea5e9 + + + + + + + + + diff --git a/yuthon_brain/models/__init__.py b/yuthon_brain/models/__init__.py new file mode 100644 index 0000000..48290d6 --- /dev/null +++ b/yuthon_brain/models/__init__.py @@ -0,0 +1,3 @@ +from . import brain_source +from . import brain_node +from . import brain_edge diff --git a/yuthon_brain/models/brain_edge.py b/yuthon_brain/models/brain_edge.py new file mode 100644 index 0000000..d20b66b --- /dev/null +++ b/yuthon_brain/models/brain_edge.py @@ -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) diff --git a/yuthon_brain/models/brain_node.py b/yuthon_brain/models/brain_node.py new file mode 100644 index 0000000..ef36149 --- /dev/null +++ b/yuthon_brain/models/brain_node.py @@ -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)', + '同一数据源内、同一父节点下显示文字不能重复'), + ] diff --git a/yuthon_brain/models/brain_source.py b/yuthon_brain/models/brain_source.py new file mode 100644 index 0000000..16c8f19 --- /dev/null +++ b/yuthon_brain/models/brain_source.py @@ -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']*>(.*?)', re.S | re.I) +_STRONG_RE = re.compile(r']*>(.*?)', 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 种): +
  • 师生管理:说明文字
  • → ('师生管理', '说明文字') +
  • 预置三组权限
  • → ('预置三组权限', '') strong 不在行首 +
  • 整句说明
  • → ('整句说明', '') + """ + 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 的每个
  • → 一个子节点。 + + 用于把 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 diff --git a/yuthon_brain/security/ir.model.access.csv b/yuthon_brain/security/ir.model.access.csv new file mode 100644 index 0000000..d05e00c --- /dev/null +++ b/yuthon_brain/security/ir.model.access.csv @@ -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 \ No newline at end of file diff --git a/yuthon_brain/static/src/brain_view.html b/yuthon_brain/static/src/brain_view.html new file mode 100644 index 0000000..5315ab5 --- /dev/null +++ b/yuthon_brain/static/src/brain_view.html @@ -0,0 +1,898 @@ + + + + + + 脑图 + + + +
    +
    +
    + + 脑图 +
    + +
    + + + + +
    +
    +
    + + +
    +
    滚轮缩放 · 拖拽平移 · 点击节点聚焦
    +
    + + + + diff --git a/yuthon_brain/static/src/img/the_brain.png b/yuthon_brain/static/src/img/the_brain.png new file mode 100644 index 0000000..ff48c13 Binary files /dev/null and b/yuthon_brain/static/src/img/the_brain.png differ diff --git a/yuthon_brain/views/brain_views.xml b/yuthon_brain/views/brain_views.xml new file mode 100644 index 0000000..fbbeceb --- /dev/null +++ b/yuthon_brain/views/brain_views.xml @@ -0,0 +1,158 @@ + + + + + yuthon.brain.source.list + yuthon.brain.source + + + + + + + + + + + + + + + yuthon.brain.source.form + yuthon.brain.source + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + + 脑图数据源 + yuthon.brain.source + list,form + + + + + yuthon.brain.node.list + yuthon.brain.node + + + + + + + + + + + + + + yuthon.brain.node.form + yuthon.brain.node + +
    + + + + + + + + + + + + + + + +
    +
    +
    + + + + yuthon.brain.edge.list + yuthon.brain.edge + + + + + + + + + + + + + yuthon.brain.edge.form + yuthon.brain.edge + +
    + + + + + + + + + +
    +
    +
    +
    diff --git a/yuthon_brain/views/menu.xml b/yuthon_brain/views/menu.xml new file mode 100644 index 0000000..b0ac57e --- /dev/null +++ b/yuthon_brain/views/menu.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + shcool 文档脑图 + /yuthon_brain/static/src/brain_view.html?key=shcool_doc + new + + + + + + shcool 功能与应用 + /yuthon_brain/static/src/brain_view.html?key=shcool_module + new + + +