Merge pull request '流程图' (#3) from dev_wang into master
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|
||||||
|
|
||||||
|
def post_init_hook(env):
|
||||||
|
"""安装后自动建一张示例流程图(数据来自 static/src/bpd_default.json)。"""
|
||||||
|
env['yuthon.bpd.diagram']._ensure_default_diagram()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
'name': 'Yuthon BPD 业务流程图',
|
||||||
|
'summary': '泳道式业务流程图(可拖拽新建关联流程)',
|
||||||
|
'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,因此定义动作的 bpd_views.xml 必须在前。
|
||||||
|
'views/bpd_views.xml',
|
||||||
|
'views/menu.xml',
|
||||||
|
],
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import main
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.exceptions import AccessError
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(json.dumps(data, ensure_ascii=False),
|
||||||
|
mimetype='application/json', status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class BpdController(http.Controller):
|
||||||
|
"""业务流程图前端的数据接口。
|
||||||
|
|
||||||
|
读图对所有登录用户开放;新建节点 / 连线 / 移动 / 改名 / 删除 等写操作
|
||||||
|
只有系统管理员有 write/create 权限(权限由 ir.model.access.csv 控制),
|
||||||
|
普通用户会收到 403,前端据此提示「需管理员权限」。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------- 只读:拿单张图 / 图列表 ----------------
|
||||||
|
@http.route('/yuthon_bpd/api/diagram/data', type='http',
|
||||||
|
auth='user', methods=['GET'], csrf=False)
|
||||||
|
def api_diagram_data(self, **kw):
|
||||||
|
"""不带 id:返回默认图(key=erp_mto);带 ?id=N:返回指定图。"""
|
||||||
|
Diagram = request.env['yuthon.bpd.diagram']
|
||||||
|
did = kw.get('id')
|
||||||
|
if did not in (None, '', '0'):
|
||||||
|
try:
|
||||||
|
did = int(did)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _json({'error': 'bad_id'}, 400)
|
||||||
|
rec = Diagram.browse(did).exists()
|
||||||
|
if not rec:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
return _json({'diagram': rec.to_json()})
|
||||||
|
data = Diagram.get_default()
|
||||||
|
if not data:
|
||||||
|
return _json({'error': 'no_diagram'}, 404)
|
||||||
|
return _json({'diagram': data})
|
||||||
|
|
||||||
|
@http.route('/yuthon_bpd/api/diagram/list', type='http',
|
||||||
|
auth='user', methods=['GET'], csrf=False)
|
||||||
|
def api_diagram_list(self, **kw):
|
||||||
|
"""供前端下拉切换:返回所有流程图的 id / 名称 / key。"""
|
||||||
|
recs = request.env['yuthon.bpd.diagram'].search([], order='id')
|
||||||
|
return _json({'diagrams': [
|
||||||
|
{'id': r.id, 'name': r.name, 'key': r.key or ''} for r in recs
|
||||||
|
]})
|
||||||
|
|
||||||
|
# ---------------- 写:节点 ----------------
|
||||||
|
@http.route('/yuthon_bpd/api/diagram/<int:did>/node', type='http',
|
||||||
|
auth='user', methods=['POST'], csrf=False)
|
||||||
|
def api_create_node(self, did, **kw):
|
||||||
|
diagram = request.env['yuthon.bpd.diagram'].browse(did).exists()
|
||||||
|
if not diagram:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
p = _body()
|
||||||
|
label = (p.get('label') or '').strip()[:120]
|
||||||
|
if not label:
|
||||||
|
return _json({'error': 'empty_label'}, 400)
|
||||||
|
lane_id = p.get('lane_id') or False
|
||||||
|
try:
|
||||||
|
node = request.env['yuthon.bpd.node'].create({
|
||||||
|
'diagram_id': did,
|
||||||
|
'lane_id': int(lane_id) if lane_id else False,
|
||||||
|
'label': label,
|
||||||
|
'sub': (p.get('sub') or '').strip()[:60],
|
||||||
|
'x': int(p.get('x', 0) or 0),
|
||||||
|
'y': int(p.get('y', 0) or 0),
|
||||||
|
'w': int(p.get('w', 96) or 96),
|
||||||
|
'h': int(p.get('h', 34) or 34),
|
||||||
|
'shape': p.get('shape') or 'box',
|
||||||
|
'variant': p.get('variant') or 'doc',
|
||||||
|
})
|
||||||
|
except AccessError:
|
||||||
|
return _json({'error': 'forbidden'}, 403)
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
return _json({'error': 'duplicate'}, 409)
|
||||||
|
raise
|
||||||
|
return _json({'node': node.to_json()})
|
||||||
|
|
||||||
|
@http.route('/yuthon_bpd/api/node/<int:nid>/move', type='http',
|
||||||
|
auth='user', methods=['POST'], csrf=False)
|
||||||
|
def api_move_node(self, nid, **kw):
|
||||||
|
node = request.env['yuthon.bpd.node'].browse(nid).exists()
|
||||||
|
if not node:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
p = _body()
|
||||||
|
try:
|
||||||
|
node.write({
|
||||||
|
'x': int(p.get('x', node.x)),
|
||||||
|
'y': int(p.get('y', node.y)),
|
||||||
|
})
|
||||||
|
except AccessError:
|
||||||
|
return _json({'error': 'forbidden'}, 403)
|
||||||
|
return _json({'ok': True})
|
||||||
|
|
||||||
|
@http.route('/yuthon_bpd/api/node/<int:nid>/rename', type='http',
|
||||||
|
auth='user', methods=['POST'], csrf=False)
|
||||||
|
def api_rename_node(self, nid, **kw):
|
||||||
|
node = request.env['yuthon.bpd.node'].browse(nid).exists()
|
||||||
|
if not node:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
p = _body()
|
||||||
|
label = (p.get('label') or '').strip()[:120]
|
||||||
|
if not label:
|
||||||
|
return _json({'error': 'empty_label'}, 400)
|
||||||
|
try:
|
||||||
|
node.write({'label': label})
|
||||||
|
except AccessError:
|
||||||
|
return _json({'error': 'forbidden'}, 403)
|
||||||
|
return _json({'ok': True, 'label': node.label})
|
||||||
|
|
||||||
|
# ---------------- 写:连线 ----------------
|
||||||
|
@http.route('/yuthon_bpd/api/diagram/<int:did>/edge', type='http',
|
||||||
|
auth='user', methods=['POST'], csrf=False)
|
||||||
|
def api_create_edge(self, did, **kw):
|
||||||
|
diagram = request.env['yuthon.bpd.diagram'].browse(did).exists()
|
||||||
|
if not diagram:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
p = _body()
|
||||||
|
try:
|
||||||
|
frm = int(p.get('from'))
|
||||||
|
to = int(p.get('to'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _json({'error': 'bad_params'}, 400)
|
||||||
|
if frm == to:
|
||||||
|
return _json({'error': 'same_node'}, 400)
|
||||||
|
Node = request.env['yuthon.bpd.node']
|
||||||
|
if not (Node.browse(frm).exists() and Node.browse(to).exists()):
|
||||||
|
return _json({'error': 'node_missing'}, 404)
|
||||||
|
try:
|
||||||
|
edge = request.env['yuthon.bpd.edge'].create({
|
||||||
|
'diagram_id': did,
|
||||||
|
'from_id': frm,
|
||||||
|
'to_id': to,
|
||||||
|
'label': (p.get('label') or '').strip()[:60],
|
||||||
|
'style': p.get('style') or 'solid',
|
||||||
|
})
|
||||||
|
except AccessError:
|
||||||
|
return _json({'error': 'forbidden'}, 403)
|
||||||
|
except Exception as exc:
|
||||||
|
if 'unique' in str(exc).lower():
|
||||||
|
return _json({'error': 'duplicate'}, 409)
|
||||||
|
raise
|
||||||
|
return _json({'edge': edge.to_json()})
|
||||||
|
|
||||||
|
@http.route('/yuthon_bpd/api/edge/<int:eid>/delete', type='http',
|
||||||
|
auth='user', methods=['POST'], csrf=False)
|
||||||
|
def api_delete_edge(self, eid, **kw):
|
||||||
|
edge = request.env['yuthon.bpd.edge'].browse(eid).exists()
|
||||||
|
if not edge:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
try:
|
||||||
|
edge.unlink()
|
||||||
|
except AccessError:
|
||||||
|
return _json({'error': 'forbidden'}, 403)
|
||||||
|
return _json({'ok': True})
|
||||||
|
|
||||||
|
# ---------------- 写:泳道 ----------------
|
||||||
|
@http.route('/yuthon_bpd/api/diagram/<int:did>/lane', type='http',
|
||||||
|
auth='user', methods=['POST'], csrf=False)
|
||||||
|
def api_create_lane(self, did, **kw):
|
||||||
|
diagram = request.env['yuthon.bpd.diagram'].browse(did).exists()
|
||||||
|
if not diagram:
|
||||||
|
return _json({'error': 'not_found'}, 404)
|
||||||
|
p = _body()
|
||||||
|
name = (p.get('name') or '新泳道').strip()[:60]
|
||||||
|
try:
|
||||||
|
lane = request.env['yuthon.bpd.lane'].create({
|
||||||
|
'diagram_id': did,
|
||||||
|
'name': name,
|
||||||
|
'kind': p.get('kind') or 'column',
|
||||||
|
'x': int(p.get('x', 24) or 24),
|
||||||
|
'y': int(p.get('y', 86) or 86),
|
||||||
|
'w': int(p.get('w', 300) or 300),
|
||||||
|
'h': int(p.get('h', 600) or 600),
|
||||||
|
'head_h': int(p.get('head_h', 34) or 34),
|
||||||
|
'label_pos': p.get('label_pos') or 'top-center',
|
||||||
|
})
|
||||||
|
except AccessError:
|
||||||
|
return _json({'error': 'forbidden'}, 403)
|
||||||
|
return _json({'lane': {
|
||||||
|
'id': lane.id, 'name': lane.name, 'kind': lane.kind,
|
||||||
|
'x': lane.x, 'y': lane.y, 'w': lane.w, 'h': lane.h,
|
||||||
|
'head_h': lane.head_h, 'label_pos': lane.label_pos,
|
||||||
|
}})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import bpd_diagram
|
||||||
|
from . import bpd_lane
|
||||||
|
from . import bpd_node
|
||||||
|
from . import bpd_edge
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
try: # Odoo 各版本导出位置略有差异,双保险
|
||||||
|
from odoo.tools import file_open
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
from odoo.tools.misc import file_open
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TEMPLATE_FILE = 'yuthon_bpd/static/src/bpd_default.json'
|
||||||
|
|
||||||
|
|
||||||
|
class BpdDiagram(models.Model):
|
||||||
|
"""业务流程图主模型。
|
||||||
|
|
||||||
|
一张图 = 若干泳道 + 若干节点 + 若干连线。坐标全部使用画布逻辑单位,
|
||||||
|
与前端 SVG 的 viewBox 一致,因此后端只存「逻辑坐标」,渲染缩放交给前端。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = 'yuthon.bpd.diagram'
|
||||||
|
_description = '业务流程图'
|
||||||
|
_order = 'id'
|
||||||
|
|
||||||
|
name = fields.Char(string='名称', required=True)
|
||||||
|
key = fields.Char(string='关键字', index=True)
|
||||||
|
canvas_w = fields.Integer(string='画布宽', default=1560)
|
||||||
|
canvas_h = fields.Integer(string='画布高', default=980)
|
||||||
|
theme = fields.Text(
|
||||||
|
string='主题配色',
|
||||||
|
default='{}',
|
||||||
|
help='JSON 格式配色主题,前端读取后直接映射到节点/泳道/连线',
|
||||||
|
)
|
||||||
|
|
||||||
|
lane_ids = fields.One2many('yuthon.bpd.lane', 'diagram_id', string='泳道')
|
||||||
|
node_ids = fields.One2many('yuthon.bpd.node', 'diagram_id', string='节点')
|
||||||
|
edge_ids = fields.One2many('yuthon.bpd.edge', 'diagram_id', string='连线')
|
||||||
|
|
||||||
|
# ---------------- 模板装载 ----------------
|
||||||
|
@api.model
|
||||||
|
def _ensure_default_diagram(self):
|
||||||
|
"""安装后确保存在一张示例流程图(数据来自 bpd_default.json)。"""
|
||||||
|
if self.search_count([('key', '=', 'erp_mto')]):
|
||||||
|
return None
|
||||||
|
return self._load_template(TEMPLATE_FILE)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _load_template(self, path):
|
||||||
|
try:
|
||||||
|
with file_open(path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (IOError, OSError, ValueError) as exc:
|
||||||
|
_logger.warning('加载 BPD 模板失败: %s', exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
theme = data.get('theme') or {}
|
||||||
|
diagram = self.create({
|
||||||
|
'name': data.get('name') or '业务流程图',
|
||||||
|
'key': data.get('key'),
|
||||||
|
'canvas_w': (data.get('canvas') or {}).get('width', 1560),
|
||||||
|
'canvas_h': (data.get('canvas') or {}).get('height', 980),
|
||||||
|
'theme': json.dumps(theme, ensure_ascii=False),
|
||||||
|
})
|
||||||
|
|
||||||
|
lane_map = {}
|
||||||
|
for i, l in enumerate(data.get('lanes', [])):
|
||||||
|
lane = self.env['yuthon.bpd.lane'].create({
|
||||||
|
'diagram_id': diagram.id,
|
||||||
|
'name': l.get('name', '泳道'),
|
||||||
|
'kind': l.get('kind', 'column'),
|
||||||
|
'x': l.get('x', 0),
|
||||||
|
'y': l.get('y', 0),
|
||||||
|
'w': l.get('w', 300),
|
||||||
|
'h': l.get('h', 600),
|
||||||
|
'head_h': l.get('head_h', 34),
|
||||||
|
'label_pos': l.get('label_pos', 'top-center'),
|
||||||
|
'sequence': i,
|
||||||
|
})
|
||||||
|
lane_map[l.get('name')] = lane.id
|
||||||
|
|
||||||
|
node_map = {}
|
||||||
|
for i, n in enumerate(data.get('nodes', [])):
|
||||||
|
node = self.env['yuthon.bpd.node'].create({
|
||||||
|
'diagram_id': diagram.id,
|
||||||
|
'lane_id': lane_map.get(n.get('lane')) or False,
|
||||||
|
'label': n.get('label', ''),
|
||||||
|
'sub': n.get('sub', ''),
|
||||||
|
'x': n.get('x', 0),
|
||||||
|
'y': n.get('y', 0),
|
||||||
|
'w': n.get('w', 96),
|
||||||
|
'h': n.get('h', 34),
|
||||||
|
'shape': n.get('shape', 'box'),
|
||||||
|
'variant': n.get('variant', 'doc'),
|
||||||
|
'sequence': i,
|
||||||
|
})
|
||||||
|
node_map[n.get('id')] = node.id
|
||||||
|
|
||||||
|
for e in data.get('edges', []):
|
||||||
|
frm = node_map.get(e.get('from'))
|
||||||
|
to = node_map.get(e.get('to'))
|
||||||
|
if not frm or not to:
|
||||||
|
continue
|
||||||
|
self.env['yuthon.bpd.edge'].create({
|
||||||
|
'diagram_id': diagram.id,
|
||||||
|
'from_id': frm,
|
||||||
|
'to_id': to,
|
||||||
|
'label': e.get('label', ''),
|
||||||
|
'style': e.get('style', 'solid'),
|
||||||
|
})
|
||||||
|
|
||||||
|
_logger.info('已装载 BPD 示例流程图 key=%s', data.get('key'))
|
||||||
|
return diagram
|
||||||
|
|
||||||
|
# ---------------- 序列化 ----------------
|
||||||
|
def to_json(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
theme = json.loads(self.theme or '{}') if self.theme else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
theme = {}
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'key': self.key or '',
|
||||||
|
'canvas': {'width': self.canvas_w, 'height': self.canvas_h},
|
||||||
|
'theme': theme,
|
||||||
|
'lanes': [{
|
||||||
|
'id': l.id,
|
||||||
|
'name': l.name,
|
||||||
|
'kind': l.kind,
|
||||||
|
'x': l.x, 'y': l.y, 'w': l.w, 'h': l.h,
|
||||||
|
'head_h': l.head_h,
|
||||||
|
'label_pos': l.label_pos,
|
||||||
|
} for l in self.lane_ids],
|
||||||
|
'nodes': [n.to_json() for n in self.node_ids],
|
||||||
|
'edges': [e.to_json() for e in self.edge_ids],
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_default(self):
|
||||||
|
"""前端拿图:优先 key=erp_mto,否则取第一张。"""
|
||||||
|
rec = self.search([('key', '=', 'erp_mto')], limit=1) \
|
||||||
|
or self.search([], limit=1, order='id')
|
||||||
|
return rec.to_json() if rec else None
|
||||||
|
|
||||||
|
# ---------------- 入口:后台跳前端 ----------------
|
||||||
|
def action_open_visual(self):
|
||||||
|
"""后台表单按钮:在新标签打开该图的可视化编辑页(带 diagram id)。"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': '/yuthon_bpd/static/src/bpd_view.html?id=%s' % self.id,
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class BpdEdge(models.Model):
|
||||||
|
"""流程图连线(流程走向 / 关联流程)。"""
|
||||||
|
_name = 'yuthon.bpd.edge'
|
||||||
|
_description = '流程连线'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
diagram_id = fields.Many2one(
|
||||||
|
'yuthon.bpd.diagram', string='流程图', required=True,
|
||||||
|
ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
from_id = fields.Many2one(
|
||||||
|
'yuthon.bpd.node', string='起点', required=True, ondelete='cascade',
|
||||||
|
)
|
||||||
|
to_id = fields.Many2one(
|
||||||
|
'yuthon.bpd.node', string='终点', required=True, ondelete='cascade',
|
||||||
|
)
|
||||||
|
label = fields.Char(string='线标签')
|
||||||
|
style = fields.Selection(
|
||||||
|
[('solid', '实线'), ('dashed', '虚线')],
|
||||||
|
string='线型', default='solid', required=True,
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('bpd_edge_uniq', 'unique(diagram_id, from_id, to_id)',
|
||||||
|
'同一条流程连线不能重复'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'from': self.from_id.id,
|
||||||
|
'to': self.to_id.id,
|
||||||
|
'label': self.label or '',
|
||||||
|
'style': self.style,
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class BpdLane(models.Model):
|
||||||
|
"""流程图泳道 / 分组框。
|
||||||
|
|
||||||
|
泳道就是画布上的一个矩形,坐标用画布逻辑单位(默认 1560 x 980)。
|
||||||
|
这样参考图那种"手工排版的泳道图"可以被完整表达,而不必受自动布局约束。
|
||||||
|
"""
|
||||||
|
_name = 'yuthon.bpd.lane'
|
||||||
|
_description = '流程图泳道'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
_rec_name = 'name'
|
||||||
|
|
||||||
|
diagram_id = fields.Many2one(
|
||||||
|
'yuthon.bpd.diagram', string='流程图', required=True,
|
||||||
|
ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
name = fields.Char(string='名称', required=True)
|
||||||
|
kind = fields.Selection(
|
||||||
|
[('column', '纵向泳道'), ('band', '横向泳道'), ('group', '分组框')],
|
||||||
|
string='类型', default='column', required=True,
|
||||||
|
help='纵向泳道 = 一列(如销售管理);横向泳道 = 一条横带(如仓库管理、财务管理)',
|
||||||
|
)
|
||||||
|
x = fields.Integer(string='X', default=0, help='矩形左上角 X(画布逻辑单位)')
|
||||||
|
y = fields.Integer(string='Y', default=0, help='矩形左上角 Y(画布逻辑单位)')
|
||||||
|
w = fields.Integer(string='宽', default=300)
|
||||||
|
h = fields.Integer(string='高', default=600)
|
||||||
|
head_h = fields.Integer(
|
||||||
|
string='标题高', default=34,
|
||||||
|
help='仅纵向泳道使用:顶部标题框的高度;横向泳道标题直接写在带子里',
|
||||||
|
)
|
||||||
|
label_pos = fields.Selection(
|
||||||
|
[('top-center', '顶部居中'), ('bottom-center', '底部居中'), ('left', '左侧')],
|
||||||
|
string='标题位置', default='top-center',
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
note = fields.Char(string='备注')
|
||||||
|
|
||||||
|
node_ids = fields.One2many('yuthon.bpd.node', 'lane_id', string='节点')
|
||||||
|
node_count = fields.Integer(string='节点数', compute='_compute_node_count')
|
||||||
|
|
||||||
|
def _compute_node_count(self):
|
||||||
|
for lane in self:
|
||||||
|
lane.node_count = len(lane.node_ids)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class BpdNode(models.Model):
|
||||||
|
"""流程图节点(一个业务动作 / 单据 / 角色)。
|
||||||
|
|
||||||
|
x / y 是节点**中心**坐标,单位与泳道一致。拖动节点时前端只改这两个值,
|
||||||
|
所以画布上任何手工微调都能持久化。
|
||||||
|
"""
|
||||||
|
_name = 'yuthon.bpd.node'
|
||||||
|
_description = '流程节点'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
_rec_name = 'label'
|
||||||
|
|
||||||
|
diagram_id = fields.Many2one(
|
||||||
|
'yuthon.bpd.diagram', string='流程图', required=True,
|
||||||
|
ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
lane_id = fields.Many2one(
|
||||||
|
'yuthon.bpd.lane', string='所属泳道', ondelete='set null', index=True,
|
||||||
|
)
|
||||||
|
label = fields.Char(string='名称', required=True)
|
||||||
|
sub = fields.Char(string='副标题', help='节点下方的小字,如「批次」')
|
||||||
|
x = fields.Integer(string='X', default=0)
|
||||||
|
y = fields.Integer(string='Y', default=0)
|
||||||
|
w = fields.Integer(string='宽', default=96)
|
||||||
|
h = fields.Integer(string='高', default=34)
|
||||||
|
shape = fields.Selection(
|
||||||
|
[('box', '矩形'), ('diamond', '菱形'), ('pill', '圆角胶囊'), ('text', '纯文字')],
|
||||||
|
string='形状', default='box', required=True,
|
||||||
|
)
|
||||||
|
variant = fields.Selection(
|
||||||
|
[
|
||||||
|
('doc', '普通单据'),
|
||||||
|
('start', '起点 / 外部角色'),
|
||||||
|
('primary', '核心(仓库 / 财务)'),
|
||||||
|
('gateway', '判断(核销)'),
|
||||||
|
('ghost', '虚化(任务池)'),
|
||||||
|
],
|
||||||
|
string='样式', default='doc', required=True,
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
note = fields.Text(string='说明')
|
||||||
|
|
||||||
|
edge_out_ids = fields.One2many('yuthon.bpd.edge', 'from_id', string='出线')
|
||||||
|
edge_in_ids = fields.One2many('yuthon.bpd.edge', 'to_id', string='入线')
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'lane_id': self.lane_id.id or None,
|
||||||
|
'lane': self.lane_id.name or '',
|
||||||
|
'label': self.label or '',
|
||||||
|
'sub': self.sub or '',
|
||||||
|
'x': self.x, 'y': self.y, 'w': self.w, 'h': self.h,
|
||||||
|
'shape': self.shape,
|
||||||
|
'variant': self.variant,
|
||||||
|
'note': self.note or '',
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_yuthon_bpd_diagram_admin,yuthon.bpd.diagram.admin,model_yuthon_bpd_diagram,base.group_system,1,1,1,1
|
||||||
|
access_yuthon_bpd_diagram_user,yuthon.bpd.diagram.user,model_yuthon_bpd_diagram,base.group_user,1,0,0,0
|
||||||
|
access_yuthon_bpd_lane_admin,yuthon.bpd.lane.admin,model_yuthon_bpd_lane,base.group_system,1,1,1,1
|
||||||
|
access_yuthon_bpd_lane_user,yuthon.bpd.lane.user,model_yuthon_bpd_lane,base.group_user,1,0,0,0
|
||||||
|
access_yuthon_bpd_node_admin,yuthon.bpd.node.admin,model_yuthon_bpd_node,base.group_system,1,1,1,1
|
||||||
|
access_yuthon_bpd_node_user,yuthon.bpd.node.user,model_yuthon_bpd_node,base.group_user,1,0,0,0
|
||||||
|
access_yuthon_bpd_edge_admin,yuthon.bpd.edge.admin,model_yuthon_bpd_edge,base.group_system,1,1,1,1
|
||||||
|
access_yuthon_bpd_edge_user,yuthon.bpd.edge.user,model_yuthon_bpd_edge,base.group_user,1,0,0,0
|
||||||
|
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"key": "erp_mto",
|
||||||
|
"name": "ERP 业务流程(离散制造-MTO)",
|
||||||
|
"title": "ERP(离散制造-MTO)",
|
||||||
|
"canvas": { "width": 1560, "height": 980 },
|
||||||
|
"theme": {
|
||||||
|
"pageBg": "#EFF7FD",
|
||||||
|
"canvasBg": "#FFFFFF",
|
||||||
|
"canvasBorder": "#CFE6F5",
|
||||||
|
"bannerFrom": "#1DA48E",
|
||||||
|
"bannerTo": "#31C3AA",
|
||||||
|
"laneBorder": "#8ADACB",
|
||||||
|
"laneHeadBorder": "#6ACDBB",
|
||||||
|
"laneHeadText": "#0F9E87",
|
||||||
|
"bandBg": "#F0FBF8",
|
||||||
|
"bandText": "#0F9E87",
|
||||||
|
"nodeFill": "#E8F2FB",
|
||||||
|
"nodeBorder": "#A9CFE9",
|
||||||
|
"nodeText": "#5B7B94",
|
||||||
|
"nodeSubText": "#93AFC3",
|
||||||
|
"startFill": "#EAF4FD",
|
||||||
|
"primaryFill": "#C6E1F8",
|
||||||
|
"primaryBorder": "#93C6EA",
|
||||||
|
"primaryText": "#2F6D92",
|
||||||
|
"ghostFill": "#F5FAFE",
|
||||||
|
"ghostBorder": "#D5E8F5",
|
||||||
|
"ghostText": "#A2BCCE",
|
||||||
|
"gatewayFill": "#E5F6F1",
|
||||||
|
"gatewayBorder": "#98D3C2",
|
||||||
|
"gatewayText": "#3E8F81",
|
||||||
|
"flow": "#4FC0A8"
|
||||||
|
},
|
||||||
|
"lanes": [
|
||||||
|
{ "name": "销售管理", "kind": "column", "x": 24, "y": 86, "w": 326, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "技术管理", "kind": "column", "x": 350, "y": 86, "w": 223, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "生产管理", "kind": "column", "x": 573, "y": 86, "w": 725, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "采购管理", "kind": "column", "x": 1298, "y": 86, "w": 238, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "仓库管理", "kind": "band", "x": 573, "y": 470, "w": 725, "h": 140, "label_pos": "bottom-center" },
|
||||||
|
{ "name": "财务管理", "kind": "band", "x": 24, "y": 906, "w": 1512, "h": 62, "label_pos": "bottom-center" }
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "n_customer", "lane": "销售管理", "label": "客户", "x": 235, "y": 130, "w": 96, "h": 34, "variant": "start" },
|
||||||
|
{ "id": "n_quote", "lane": "销售管理", "label": "报价单", "x": 235, "y": 196, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_so", "lane": "销售管理", "label": "销售订单", "x": 235, "y": 262, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_deliver", "lane": "销售管理", "label": "销售出库/退货", "sub": "批次", "x": 235, "y": 372, "w": 116, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ar", "lane": "销售管理", "label": "应收账款明细", "x": 235, "y": 690, "w": 120, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_bill", "lane": "销售管理", "label": "开票", "x": 124, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_recon1", "lane": "销售管理", "label": "对账", "x": 235, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_bad", "lane": "销售管理", "label": "坏账发票", "x": 235, "y": 840, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ver1", "lane": "财务管理", "label": "收款单核销", "x": 235, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
|
||||||
|
|
||||||
|
{ "id": "n_design", "lane": "技术管理", "label": "方案设计", "x": 388, "y": 229, "w": 104, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_route", "lane": "技术管理", "label": "生产工序", "x": 508, "y": 160, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_product", "lane": "技术管理", "label": "产品信息", "x": 508, "y": 229, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_bom", "lane": "技术管理", "label": "产品BOM", "x": 508, "y": 298, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
|
||||||
|
{ "id": "n_shop", "lane": "生产管理", "label": "车间", "x": 660, "y": 130, "w": 96, "h": 34, "variant": "start" },
|
||||||
|
{ "id": "n_plan", "lane": "生产管理", "label": "生产计划", "x": 639, "y": 328, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_pool", "lane": "生产管理", "label": "生产任务池", "x": 774, "y": 238, "w": 100, "h": 30, "variant": "ghost" },
|
||||||
|
{ "id": "n_wo", "lane": "生产管理", "label": "生产工单", "sub": "批次", "x": 774, "y": 328, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_issue", "lane": "生产管理", "label": "生产领/退料", "x": 919, "y": 328, "w": 120, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_qc", "lane": "生产管理", "label": "生产报工/质检", "x": 1080, "y": 328, "w": 132, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_receipt", "lane": "生产管理", "label": "生产入库", "x": 1231, "y": 328, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_wh", "lane": "仓库管理", "label": "仓库", "x": 838, "y": 400, "w": 100, "h": 34, "variant": "primary" },
|
||||||
|
{ "id": "n_out_oth", "lane": "仓库管理", "label": "其他出库单", "x": 660, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_transfer", "lane": "仓库管理", "label": "库存调拨", "x": 838, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_count", "lane": "仓库管理", "label": "库存盘点", "x": 1080, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_in_oth", "lane": "仓库管理", "label": "其他入库单", "x": 1226, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
|
||||||
|
{ "id": "n_vendor", "lane": "采购管理", "label": "供应商", "x": 1360, "y": 130, "w": 96, "h": 34, "variant": "start" },
|
||||||
|
{ "id": "n_preq", "lane": "采购管理", "label": "采购申请", "x": 1360, "y": 196, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_po", "lane": "采购管理", "label": "采购订单", "x": 1360, "y": 262, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_grn", "lane": "采购管理", "label": "采购入库/退货", "sub": "批次", "x": 1360, "y": 372, "w": 116, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ap", "lane": "采购管理", "label": "应付账款明细", "x": 1360, "y": 690, "w": 120, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_recon2", "lane": "采购管理", "label": "对账", "x": 1350, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_getinv", "lane": "采购管理", "label": "收票", "x": 1478, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ininv", "lane": "采购管理", "label": "进项发票", "x": 1350, "y": 840, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ver2", "lane": "财务管理", "label": "付款单核销", "x": 1430, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
|
||||||
|
|
||||||
|
{ "id": "n_cash", "lane": "财务管理", "label": "收款单", "x": 520, "y": 937, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_fin", "lane": "财务管理", "label": "财务", "x": 790, "y": 937, "w": 120, "h": 34, "variant": "primary" },
|
||||||
|
{ "id": "n_pay", "lane": "财务管理", "label": "付款单", "x": 1050, "y": 937, "w": 96, "h": 34, "variant": "doc" }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "n_customer", "to": "n_quote" },
|
||||||
|
{ "from": "n_quote", "to": "n_so" },
|
||||||
|
{ "from": "n_so", "to": "n_deliver" },
|
||||||
|
{ "from": "n_deliver", "to": "n_ar" },
|
||||||
|
{ "from": "n_ar", "to": "n_bill" },
|
||||||
|
{ "from": "n_ar", "to": "n_recon1" },
|
||||||
|
{ "from": "n_bill", "to": "n_bad" },
|
||||||
|
{ "from": "n_recon1", "to": "n_bad" },
|
||||||
|
{ "from": "n_bad", "to": "n_ver1" },
|
||||||
|
{ "from": "n_deliver", "to": "n_wh" },
|
||||||
|
{ "from": "n_so", "to": "n_plan", "style": "dashed" },
|
||||||
|
|
||||||
|
{ "from": "n_quote", "to": "n_design" },
|
||||||
|
{ "from": "n_design", "to": "n_route" },
|
||||||
|
{ "from": "n_design", "to": "n_product" },
|
||||||
|
{ "from": "n_design", "to": "n_bom" },
|
||||||
|
|
||||||
|
{ "from": "n_shop", "to": "n_plan" },
|
||||||
|
{ "from": "n_plan", "to": "n_wo" },
|
||||||
|
{ "from": "n_wo", "to": "n_pool" },
|
||||||
|
{ "from": "n_wo", "to": "n_issue" },
|
||||||
|
{ "from": "n_issue", "to": "n_qc" },
|
||||||
|
{ "from": "n_qc", "to": "n_receipt" },
|
||||||
|
{ "from": "n_wo", "to": "n_wh" },
|
||||||
|
{ "from": "n_issue", "to": "n_wh" },
|
||||||
|
{ "from": "n_receipt", "to": "n_wh" },
|
||||||
|
{ "from": "n_wh", "to": "n_out_oth" },
|
||||||
|
{ "from": "n_wh", "to": "n_transfer" },
|
||||||
|
{ "from": "n_wh", "to": "n_count" },
|
||||||
|
{ "from": "n_wh", "to": "n_in_oth" },
|
||||||
|
|
||||||
|
{ "from": "n_vendor", "to": "n_preq" },
|
||||||
|
{ "from": "n_preq", "to": "n_po" },
|
||||||
|
{ "from": "n_po", "to": "n_grn" },
|
||||||
|
{ "from": "n_grn", "to": "n_ap" },
|
||||||
|
{ "from": "n_grn", "to": "n_wh" },
|
||||||
|
{ "from": "n_ap", "to": "n_recon2" },
|
||||||
|
{ "from": "n_ap", "to": "n_ininv" },
|
||||||
|
{ "from": "n_recon2", "to": "n_getinv" },
|
||||||
|
{ "from": "n_ininv", "to": "n_getinv" },
|
||||||
|
{ "from": "n_ininv", "to": "n_ver2" },
|
||||||
|
{ "from": "n_getinv", "to": "n_ver2" },
|
||||||
|
|
||||||
|
{ "from": "n_fin", "to": "n_cash" },
|
||||||
|
{ "from": "n_cash", "to": "n_ver1" },
|
||||||
|
{ "from": "n_fin", "to": "n_pay" },
|
||||||
|
{ "from": "n_pay", "to": "n_ver2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>业务流程图</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; }
|
||||||
|
#app { display: flex; flex-direction: column; height: 100vh; background: #EFF7FD; }
|
||||||
|
|
||||||
|
/* 顶部工具栏 */
|
||||||
|
#toolbar {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 8px 14px;
|
||||||
|
background: linear-gradient(90deg, #1DA48E, #31C3AA); color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.12); z-index: 10; flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
#toolbar .title { font-size: 16px; font-weight: 600; letter-spacing: 1px; }
|
||||||
|
#toolbar .spacer { flex: 1; }
|
||||||
|
#toolbar input.search {
|
||||||
|
width: 180px; padding: 5px 10px; border: none; border-radius: 14px;
|
||||||
|
outline: none; font-size: 13px; color: #0F9E87; background: rgba(255,255,255,.9);
|
||||||
|
}
|
||||||
|
#toolbar button {
|
||||||
|
border: none; background: rgba(255,255,255,.22); color: #fff; cursor: pointer;
|
||||||
|
border-radius: 6px; padding: 5px 10px; font-size: 13px; transition: background .15s;
|
||||||
|
}
|
||||||
|
#toolbar button:hover { background: rgba(255,255,255,.38); }
|
||||||
|
#modeTag {
|
||||||
|
font-size: 12px; background: rgba(255,255,255,.22); padding: 3px 9px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
#toolbar select.ds {
|
||||||
|
border: none; border-radius: 6px; padding: 5px 8px; font-size: 13px;
|
||||||
|
color: #0F9E87; background: rgba(255,255,255,.94); outline: none;
|
||||||
|
max-width: 240px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 画布 */
|
||||||
|
#stage { position: relative; flex: 1; overflow: hidden; cursor: grab; }
|
||||||
|
#stage.panning { cursor: grabbing; }
|
||||||
|
#svg { width: 100%; height: 100%; display: block; user-select: none; }
|
||||||
|
|
||||||
|
/* 泳道 */
|
||||||
|
.lane-rect { fill: #fff; stroke: #CFE6F5; stroke-width: 1; }
|
||||||
|
.lane-head { fill: url(#laneHeadGrad); }
|
||||||
|
.lane-head-text { fill: #0F9E87; font-size: 14px; font-weight: 600; text-anchor: middle; }
|
||||||
|
.band-rect { fill: #F0FBF8; stroke: #8ADACB; stroke-width: 1; }
|
||||||
|
.band-text { fill: #0F9E87; font-size: 13px; font-weight: 600; text-anchor: middle; }
|
||||||
|
|
||||||
|
/* 节点 */
|
||||||
|
.bpd-node { cursor: move; }
|
||||||
|
.bpd-node .node-box { transition: filter .12s; }
|
||||||
|
.bpd-node:hover .node-box { filter: brightness(1.04); }
|
||||||
|
.bpd-node.selected .node-box { stroke-width: 2.4 !important; }
|
||||||
|
.bpd-node.hl .node-box { stroke: #FFB020 !important; stroke-width: 2.4 !important; }
|
||||||
|
.node-label { font-size: 12px; text-anchor: middle; pointer-events: none; }
|
||||||
|
.node-sub { font-size: 10px; text-anchor: middle; pointer-events: none; }
|
||||||
|
.node-port {
|
||||||
|
fill: #fff; stroke: #31C3AA; stroke-width: 1.6; cursor: crosshair; opacity: 0;
|
||||||
|
transition: opacity .12s;
|
||||||
|
}
|
||||||
|
.bpd-node:hover .node-port, .node-port.show { opacity: 1; }
|
||||||
|
|
||||||
|
/* 连线 */
|
||||||
|
.edge-path { fill: none; stroke: #4FC0A8; stroke-width: 1.6; pointer-events: none; }
|
||||||
|
.edge-path.dashed { stroke-dasharray: 5 4; }
|
||||||
|
.edge-hit { fill: none; stroke: transparent; stroke-width: 10; cursor: pointer; }
|
||||||
|
.edge-label { font-size: 11px; fill: #5B7B94; text-anchor: middle; pointer-events: none; }
|
||||||
|
|
||||||
|
/* 橡皮筋 */
|
||||||
|
.rubber { fill: none; stroke: #31C3AA; stroke-width: 2; stroke-dasharray: 6 4; }
|
||||||
|
|
||||||
|
/* 详情面板 */
|
||||||
|
#detail {
|
||||||
|
position: absolute; right: 12px; top: 12px; width: 230px; background: #fff;
|
||||||
|
border: 1px solid #CFE6F5; border-radius: 10px; padding: 12px; display: none;
|
||||||
|
box-shadow: 0 6px 18px rgba(20,80,120,.15); z-index: 9; font-size: 13px; color: #3a5a70;
|
||||||
|
}
|
||||||
|
#detail h4 { margin: 0 0 8px; font-size: 14px; color: #0F9E87; }
|
||||||
|
#detail input {
|
||||||
|
width: 100%; padding: 6px 8px; border: 1px solid #CFE6F5; border-radius: 6px;
|
||||||
|
outline: none; font-size: 13px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#detail .row { margin-bottom: 6px; color: #93AFC3; }
|
||||||
|
#detail button {
|
||||||
|
width: 100%; border: none; background: #1DA48E; color: #fff; padding: 6px;
|
||||||
|
border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
#detail button:hover { background: #178d7a; }
|
||||||
|
|
||||||
|
/* 新建节点输入框 */
|
||||||
|
#newNodeInput {
|
||||||
|
position: fixed; display: none; z-index: 20; width: 160px; padding: 6px 8px;
|
||||||
|
border: 2px solid #31C3AA; border-radius: 6px; outline: none; font-size: 13px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toast */
|
||||||
|
#toast {
|
||||||
|
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
|
||||||
|
background: rgba(30,40,50,.9); color: #fff; padding: 9px 16px; border-radius: 20px;
|
||||||
|
font-size: 13px; opacity: 0; transition: opacity .25s; z-index: 30; pointer-events: none;
|
||||||
|
}
|
||||||
|
#toast.show { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="toolbar">
|
||||||
|
<span class="title" id="diagramTitle">业务流程图</span>
|
||||||
|
<span id="modeTag">加载中…</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<select id="diagramSelect" class="ds" title="切换流程图" style="display:none"></select>
|
||||||
|
<input class="search" id="searchInput" placeholder="搜索节点…" autocomplete="off">
|
||||||
|
<button id="btnZoomOut" title="缩小">-</button>
|
||||||
|
<button id="btnZoomIn" title="放大">+</button>
|
||||||
|
<button id="btnFit" title="适应窗口">适应</button>
|
||||||
|
</div>
|
||||||
|
<div id="stage">
|
||||||
|
<svg id="svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="laneHeadGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#31C3AA"/>
|
||||||
|
<stop offset="1" stop-color="#1DA48E"/>
|
||||||
|
</linearGradient>
|
||||||
|
<marker id="arrow" markerWidth="10" markerHeight="10" refX="7.5" refY="3"
|
||||||
|
orient="auto" markerUnits="userSpaceOnUse">
|
||||||
|
<path d="M0,0 L8,3 L0,6 Z" id="arrowPath" fill="#4FC0A8"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<g id="viewport">
|
||||||
|
<g id="layerLanes"></g>
|
||||||
|
<g id="layerEdges"></g>
|
||||||
|
<g id="layerNodes"></g>
|
||||||
|
<g id="layerRubber"></g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div id="detail">
|
||||||
|
<h4>节点详情</h4>
|
||||||
|
<input id="detailName" placeholder="节点名称">
|
||||||
|
<div class="row" id="detailMeta"></div>
|
||||||
|
<button id="detailRename">保存名称</button>
|
||||||
|
</div>
|
||||||
|
<input id="newNodeInput" placeholder="输入流程名称,回车确认">
|
||||||
|
</div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 离线兜底数据:REST 不可用时使用(预览模式,只读) -->
|
||||||
|
<script id="fallback" type="application/json">
|
||||||
|
{
|
||||||
|
"key": "erp_mto",
|
||||||
|
"name": "ERP 业务流程(离散制造-MTO)",
|
||||||
|
"title": "ERP(离散制造-MTO)",
|
||||||
|
"canvas": { "width": 1560, "height": 980 },
|
||||||
|
"theme": {
|
||||||
|
"pageBg": "#EFF7FD", "canvasBg": "#FFFFFF", "canvasBorder": "#CFE6F5",
|
||||||
|
"bannerFrom": "#1DA48E", "bannerTo": "#31C3AA", "laneBorder": "#8ADACB",
|
||||||
|
"laneHeadBorder": "#6ACDBB", "laneHeadText": "#0F9E87", "bandBg": "#F0FBF8",
|
||||||
|
"bandText": "#0F9E87", "nodeFill": "#E8F2FB", "nodeBorder": "#A9CFE9",
|
||||||
|
"nodeText": "#5B7B94", "nodeSubText": "#93AFC3", "startFill": "#EAF4FD",
|
||||||
|
"primaryFill": "#C6E1F8", "primaryBorder": "#93C6EA", "primaryText": "#2F6D92",
|
||||||
|
"ghostFill": "#F5FAFE", "ghostBorder": "#D5E8F5", "ghostText": "#A2BCCE",
|
||||||
|
"gatewayFill": "#E5F6F1", "gatewayBorder": "#98D3C2", "gatewayText": "#3E8F81",
|
||||||
|
"flow": "#4FC0A8"
|
||||||
|
},
|
||||||
|
"lanes": [
|
||||||
|
{ "name": "销售管理", "kind": "column", "x": 24, "y": 86, "w": 326, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "技术管理", "kind": "column", "x": 350, "y": 86, "w": 223, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "生产管理", "kind": "column", "x": 573, "y": 86, "w": 725, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "采购管理", "kind": "column", "x": 1298, "y": 86, "w": 238, "h": 804, "label_pos": "top-center" },
|
||||||
|
{ "name": "仓库管理", "kind": "band", "x": 573, "y": 470, "w": 725, "h": 140, "label_pos": "bottom-center" },
|
||||||
|
{ "name": "财务管理", "kind": "band", "x": 24, "y": 906, "w": 1512, "h": 62, "label_pos": "bottom-center" }
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "n_customer", "lane": "销售管理", "label": "客户", "x": 235, "y": 130, "w": 96, "h": 34, "variant": "start" },
|
||||||
|
{ "id": "n_quote", "lane": "销售管理", "label": "报价单", "x": 235, "y": 196, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_so", "lane": "销售管理", "label": "销售订单", "x": 235, "y": 262, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_deliver", "lane": "销售管理", "label": "销售出库/退货", "sub": "批次", "x": 235, "y": 372, "w": 116, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ar", "lane": "销售管理", "label": "应收账款明细", "x": 235, "y": 690, "w": 120, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_bill", "lane": "销售管理", "label": "开票", "x": 124, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_recon1", "lane": "销售管理", "label": "对账", "x": 235, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_bad", "lane": "销售管理", "label": "坏账发票", "x": 235, "y": 840, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ver1", "lane": "财务管理", "label": "收款单核销", "x": 235, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
|
||||||
|
{ "id": "n_design", "lane": "技术管理", "label": "方案设计", "x": 388, "y": 229, "w": 104, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_route", "lane": "技术管理", "label": "生产工序", "x": 508, "y": 160, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_product", "lane": "技术管理", "label": "产品信息", "x": 508, "y": 229, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_bom", "lane": "技术管理", "label": "产品BOM", "x": 508, "y": 298, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_shop", "lane": "生产管理", "label": "车间", "x": 660, "y": 130, "w": 96, "h": 34, "variant": "start" },
|
||||||
|
{ "id": "n_plan", "lane": "生产管理", "label": "生产计划", "x": 639, "y": 328, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_pool", "lane": "生产管理", "label": "生产任务池", "x": 774, "y": 238, "w": 100, "h": 30, "variant": "ghost" },
|
||||||
|
{ "id": "n_wo", "lane": "生产管理", "label": "生产工单", "sub": "批次", "x": 774, "y": 328, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_issue", "lane": "生产管理", "label": "生产领/退料", "x": 919, "y": 328, "w": 120, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_qc", "lane": "生产管理", "label": "生产报工/质检", "x": 1080, "y": 328, "w": 132, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_receipt", "lane": "生产管理", "label": "生产入库", "x": 1231, "y": 328, "w": 100, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_wh", "lane": "仓库管理", "label": "仓库", "x": 838, "y": 400, "w": 100, "h": 34, "variant": "primary" },
|
||||||
|
{ "id": "n_out_oth", "lane": "仓库管理", "label": "其他出库单", "x": 660, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_transfer", "lane": "仓库管理", "label": "库存调拨", "x": 838, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_count", "lane": "仓库管理", "label": "库存盘点", "x": 1080, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_in_oth", "lane": "仓库管理", "label": "其他入库单", "x": 1226, "y": 555, "w": 110, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_vendor", "lane": "采购管理", "label": "供应商", "x": 1360, "y": 130, "w": 96, "h": 34, "variant": "start" },
|
||||||
|
{ "id": "n_preq", "lane": "采购管理", "label": "采购申请", "x": 1360, "y": 196, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_po", "lane": "采购管理", "label": "采购订单", "x": 1360, "y": 262, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_grn", "lane": "采购管理", "label": "采购入库/退货", "sub": "批次", "x": 1360, "y": 372, "w": 116, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ap", "lane": "采购管理", "label": "应付账款明细", "x": 1360, "y": 690, "w": 120, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_recon2", "lane": "采购管理", "label": "对账", "x": 1350, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_getinv", "lane": "采购管理", "label": "收票", "x": 1478, "y": 772, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ininv", "lane": "采购管理", "label": "进项发票", "x": 1350, "y": 840, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_ver2", "lane": "财务管理", "label": "付款单核销", "x": 1430, "y": 937, "w": 110, "h": 42, "variant": "gateway", "shape": "diamond" },
|
||||||
|
{ "id": "n_cash", "lane": "财务管理", "label": "收款单", "x": 520, "y": 937, "w": 96, "h": 34, "variant": "doc" },
|
||||||
|
{ "id": "n_fin", "lane": "财务管理", "label": "财务", "x": 790, "y": 937, "w": 120, "h": 34, "variant": "primary" },
|
||||||
|
{ "id": "n_pay", "lane": "财务管理", "label": "付款单", "x": 1050, "y": 937, "w": 96, "h": 34, "variant": "doc" }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "n_customer", "to": "n_quote" }, { "from": "n_quote", "to": "n_so" },
|
||||||
|
{ "from": "n_so", "to": "n_deliver" }, { "from": "n_deliver", "to": "n_ar" },
|
||||||
|
{ "from": "n_ar", "to": "n_bill" }, { "from": "n_ar", "to": "n_recon1" },
|
||||||
|
{ "from": "n_bill", "to": "n_bad" }, { "from": "n_recon1", "to": "n_bad" },
|
||||||
|
{ "from": "n_bad", "to": "n_ver1" }, { "from": "n_deliver", "to": "n_wh" },
|
||||||
|
{ "from": "n_so", "to": "n_plan", "style": "dashed" },
|
||||||
|
{ "from": "n_quote", "to": "n_design" }, { "from": "n_design", "to": "n_route" },
|
||||||
|
{ "from": "n_design", "to": "n_product" }, { "from": "n_design", "to": "n_bom" },
|
||||||
|
{ "from": "n_shop", "to": "n_plan" }, { "from": "n_plan", "to": "n_wo" },
|
||||||
|
{ "from": "n_wo", "to": "n_pool" }, { "from": "n_wo", "to": "n_issue" },
|
||||||
|
{ "from": "n_issue", "to": "n_qc" }, { "from": "n_qc", "to": "n_receipt" },
|
||||||
|
{ "from": "n_wo", "to": "n_wh" }, { "from": "n_issue", "to": "n_wh" },
|
||||||
|
{ "from": "n_receipt", "to": "n_wh" }, { "from": "n_wh", "to": "n_out_oth" },
|
||||||
|
{ "from": "n_wh", "to": "n_transfer" }, { "from": "n_wh", "to": "n_count" },
|
||||||
|
{ "from": "n_wh", "to": "n_in_oth" }, { "from": "n_vendor", "to": "n_preq" },
|
||||||
|
{ "from": "n_preq", "to": "n_po" }, { "from": "n_po", "to": "n_grn" },
|
||||||
|
{ "from": "n_grn", "to": "n_ap" }, { "from": "n_grn", "to": "n_wh" },
|
||||||
|
{ "from": "n_ap", "to": "n_recon2" }, { "from": "n_ap", "to": "n_ininv" },
|
||||||
|
{ "from": "n_recon2", "to": "n_getinv" }, { "from": "n_ininv", "to": "n_getinv" },
|
||||||
|
{ "from": "n_ininv", "to": "n_ver2" }, { "from": "n_getinv", "to": "n_ver2" },
|
||||||
|
{ "from": "n_fin", "to": "n_cash" }, { "from": "n_cash", "to": "n_ver1" },
|
||||||
|
{ "from": "n_fin", "to": "n_pay" }, { "from": "n_pay", "to": "n_ver2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
mode: "loading", // loading | live | preview
|
||||||
|
diagram: null, // {id, name, canvas:{width,height}, theme}
|
||||||
|
theme: {},
|
||||||
|
lanes: [],
|
||||||
|
nodeMap: {}, // id -> node
|
||||||
|
edgeMap: {}, // id -> edge
|
||||||
|
scale: 1, tx: 0, ty: 0,
|
||||||
|
selected: null,
|
||||||
|
drag: null, // 当前拖拽上下文
|
||||||
|
rubber: null, // 拉线上下文
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_THEME = {
|
||||||
|
pageBg: "#EFF7FD", canvasBg: "#FFFFFF", canvasBorder: "#CFE6F5",
|
||||||
|
bannerFrom: "#1DA48E", bannerTo: "#31C3AA", laneBorder: "#8ADACB",
|
||||||
|
laneHeadBorder: "#6ACDBB", laneHeadText: "#0F9E87", bandBg: "#F0FBF8",
|
||||||
|
bandText: "#0F9E87", nodeFill: "#E8F2FB", nodeBorder: "#A9CFE9",
|
||||||
|
nodeText: "#5B7B94", nodeSubText: "#93AFC3", startFill: "#EAF4FD",
|
||||||
|
primaryFill: "#C6E1F8", primaryBorder: "#93C6EA", primaryText: "#2F6D92",
|
||||||
|
ghostFill: "#F5FAFE", ghostBorder: "#D5E8F5", ghostText: "#A2BCCE",
|
||||||
|
gatewayFill: "#E5F6F1", gatewayBorder: "#98D3C2", gatewayText: "#3E8F81",
|
||||||
|
flow: "#4FC0A8",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $("toast");
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add("show");
|
||||||
|
clearTimeout(toast._t);
|
||||||
|
toast._t = setTimeout(() => t.classList.remove("show"), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 数据加载 ----------------
|
||||||
|
function useFallback() {
|
||||||
|
let fb;
|
||||||
|
try { fb = JSON.parse($("fallback").textContent); } catch (e) { fb = null; }
|
||||||
|
if (fb) {
|
||||||
|
applyDiagram(fb);
|
||||||
|
state.mode = "preview";
|
||||||
|
$("modeTag").textContent = "预览模式 · 只读";
|
||||||
|
$("diagramSelect").style.display = "none";
|
||||||
|
} else {
|
||||||
|
$("modeTag").textContent = "无数据";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function idFromUrl() {
|
||||||
|
const v = new URLSearchParams(location.search).get("id");
|
||||||
|
const n = v ? parseInt(v, 10) : NaN;
|
||||||
|
return isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDiagramList() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/yuthon_bpd/api/diagram/list", { credentials: "same-origin" });
|
||||||
|
if (!r.ok) return [];
|
||||||
|
const j = await r.json();
|
||||||
|
return (j && j.diagrams) || [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(list, selectedId) {
|
||||||
|
const sel = $("diagramSelect");
|
||||||
|
if (!list.length) { sel.style.display = "none"; return; }
|
||||||
|
sel.innerHTML = list.map((d) =>
|
||||||
|
`<option value="${d.id}"${String(d.id) === String(selectedId) ? " selected" : ""}>${esc(d.name)}</option>`
|
||||||
|
).join("");
|
||||||
|
sel.style.display = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDiagram(id) {
|
||||||
|
const url = id ? `/yuthon_bpd/api/diagram/data?id=${id}`
|
||||||
|
: "/yuthon_bpd/api/diagram/data";
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json();
|
||||||
|
if (j && j.diagram) {
|
||||||
|
applyDiagram(j.diagram);
|
||||||
|
state.mode = "live";
|
||||||
|
$("modeTag").textContent = "编辑模式";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* 忽略,走兜底 */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
// file:// 或 about: 协议下无法访问 REST,直接走内联兜底(离线预览 / 截图)
|
||||||
|
if (location.protocol === "file:" || location.protocol === "about:") {
|
||||||
|
useFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = await fetchDiagramList();
|
||||||
|
const want = idFromUrl() || (list.length ? list[0].id : null);
|
||||||
|
fillSelect(list, want);
|
||||||
|
const ok = await loadDiagram(want);
|
||||||
|
if (!ok) useFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDiagram(d) {
|
||||||
|
state.diagram = {
|
||||||
|
id: d.id || null,
|
||||||
|
name: d.name || d.title || "业务流程图",
|
||||||
|
canvas: d.canvas || { width: 1560, height: 980 },
|
||||||
|
theme: Object.assign({}, DEFAULT_THEME, d.theme || {}),
|
||||||
|
};
|
||||||
|
state.theme = state.diagram.theme;
|
||||||
|
state.lanes = d.lanes || [];
|
||||||
|
state.nodeMap = {};
|
||||||
|
(d.nodes || []).forEach((n) => { state.nodeMap[n.id] = n; });
|
||||||
|
state.edgeMap = {};
|
||||||
|
(d.edges || []).forEach((e) => { state.edgeMap[e.id] = e; });
|
||||||
|
$("diagramTitle").textContent = state.diagram.name;
|
||||||
|
document.body.style.background = state.theme.pageBg;
|
||||||
|
$("arrowPath").setAttribute("fill", state.theme.flow);
|
||||||
|
renderAll();
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 坐标换算 ----------------
|
||||||
|
function screenToCanvas(clientX, clientY) {
|
||||||
|
const r = $("svg").getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (clientX - r.left - state.tx) / state.scale,
|
||||||
|
y: (clientY - r.top - state.ty) / state.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function applyTransform() {
|
||||||
|
$("viewport").setAttribute(
|
||||||
|
"transform", `translate(${state.tx},${state.ty}) scale(${state.scale})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 渲染 ----------------
|
||||||
|
function nodeColors(n) {
|
||||||
|
const t = state.theme;
|
||||||
|
if (n.variant === "start") return { fill: t.startFill, stroke: t.nodeBorder, text: t.nodeText };
|
||||||
|
if (n.variant === "primary") return { fill: t.primaryFill, stroke: t.primaryBorder, text: t.primaryText };
|
||||||
|
if (n.variant === "ghost") return { fill: t.ghostFill, stroke: t.ghostBorder, text: t.ghostText };
|
||||||
|
if (n.variant === "gateway") return { fill: t.gatewayFill, stroke: t.gatewayBorder, text: t.gatewayText };
|
||||||
|
return { fill: t.nodeFill, stroke: t.nodeBorder, text: t.nodeText };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderLanes();
|
||||||
|
renderEdges();
|
||||||
|
renderNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLanes() {
|
||||||
|
const g = $("layerLanes");
|
||||||
|
let s = "";
|
||||||
|
state.lanes.forEach((l) => {
|
||||||
|
if (l.kind === "column") {
|
||||||
|
s += `<rect class="lane-rect" x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="4"/>`;
|
||||||
|
s += `<rect class="lane-head" x="${l.x}" y="${l.y}" width="${l.w}" height="${l.head_h || 34}" rx="4"/>`;
|
||||||
|
s += `<text class="lane-head-text" x="${l.x + l.w / 2}" y="${l.y + (l.head_h || 34) / 2 + 5}">${esc(l.name)}</text>`;
|
||||||
|
} else { // band
|
||||||
|
s += `<rect class="band-rect" x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="4"/>`;
|
||||||
|
const ty = l.label_pos === "top-center" ? l.y + 18 : l.y + l.h - 12;
|
||||||
|
s += `<text class="band-text" x="${l.x + l.w / 2}" y="${ty}">${esc(l.name)}</text>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
g.innerHTML = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgePathPoints(from, to) {
|
||||||
|
// 节点中心
|
||||||
|
const fc = { x: from.x, y: from.y }, tc = { x: to.x, y: to.y };
|
||||||
|
const dx = tc.x - fc.x, dy = tc.y - fc.y;
|
||||||
|
let out, inn;
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||||
|
out = { x: dx >= 0 ? fc.x + from.w / 2 : fc.x - from.w / 2, y: fc.y };
|
||||||
|
inn = { x: dx >= 0 ? tc.x - to.w / 2 : tc.x + to.w / 2, y: tc.y };
|
||||||
|
} else {
|
||||||
|
out = { x: fc.x, y: dy >= 0 ? fc.y + from.h / 2 : fc.y - from.h / 2 };
|
||||||
|
inn = { x: tc.x, y: dy >= 0 ? tc.y - to.h / 2 : tc.y + to.h / 2 };
|
||||||
|
}
|
||||||
|
return { out, inn };
|
||||||
|
}
|
||||||
|
|
||||||
|
function orthoPath(out, inn) {
|
||||||
|
const r = 8;
|
||||||
|
const horiz = Math.abs(inn.x - out.x) >= Math.abs(inn.y - out.y);
|
||||||
|
if (horiz) {
|
||||||
|
const mx = (out.x + inn.x) / 2;
|
||||||
|
const my = inn.y;
|
||||||
|
let d = `M ${out.x} ${out.y} L ${mx - r} ${out.y} `;
|
||||||
|
d += `Q ${mx} ${out.y} ${mx} ${out.y + (my >= out.y ? r : -r)} `;
|
||||||
|
d += `L ${mx} ${my - (my >= out.y ? r : -r)} `;
|
||||||
|
d += `Q ${mx} ${my} ${mx + (inn.x >= mx ? r : -r)} ${my} `;
|
||||||
|
d += `L ${inn.x} ${my}`;
|
||||||
|
return d;
|
||||||
|
} else {
|
||||||
|
const my = (out.y + inn.y) / 2;
|
||||||
|
const mx = inn.x;
|
||||||
|
let d = `M ${out.x} ${out.y} L ${out.x} ${my - r} `;
|
||||||
|
d += `Q ${out.x} ${my} ${out.x + (mx >= out.x ? r : -r)} ${my} `;
|
||||||
|
d += `L ${mx - (mx >= out.x ? r : -r)} ${my} `;
|
||||||
|
d += `Q ${mx} ${my} ${mx} ${my + (inn.y >= my ? r : -r)} `;
|
||||||
|
d += `L ${mx} ${inn.y}`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdges() {
|
||||||
|
const g = $("layerEdges");
|
||||||
|
let s = "";
|
||||||
|
Object.values(state.edgeMap).forEach((e) => {
|
||||||
|
const from = state.nodeMap[e.from], to = state.nodeMap[e.to];
|
||||||
|
if (!from || !to) return;
|
||||||
|
const { out, inn } = edgePathPoints(from, to);
|
||||||
|
const d = orthoPath(out, inn);
|
||||||
|
s += `<path class="edge-hit" d="${d}" data-edge="${e.id}"/>`;
|
||||||
|
s += `<path class="edge-path ${e.style === "dashed" ? "dashed" : ""}" d="${d}" marker-end="url(#arrow)" data-edge="${e.id}"/>`;
|
||||||
|
if (e.label) {
|
||||||
|
const mx = (out.x + inn.x) / 2, my = (out.y + inn.y) / 2;
|
||||||
|
s += `<text class="edge-label" x="${mx}" y="${my - 4}">${esc(e.label)}</text>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
g.innerHTML = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodes() {
|
||||||
|
const g = $("layerNodes");
|
||||||
|
let s = "";
|
||||||
|
Object.values(state.nodeMap).forEach((n) => {
|
||||||
|
const c = nodeColors(n);
|
||||||
|
const sel = state.selected === n.id ? " selected" : "";
|
||||||
|
const hw = n.w / 2, hh = n.h / 2;
|
||||||
|
let box;
|
||||||
|
if (n.shape === "diamond") {
|
||||||
|
const pts = `0,${-hh} ${hw},0 0,${hh} ${-hw},0`;
|
||||||
|
box = `<polygon class="node-box" points="${pts}" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.4"/>`;
|
||||||
|
} else if (n.shape === "pill") {
|
||||||
|
box = `<rect class="node-box" x="${-hw}" y="${-hh}" width="${n.w}" height="${n.h}" rx="${hh}" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.4"/>`;
|
||||||
|
} else {
|
||||||
|
box = `<rect class="node-box" x="${-hw}" y="${-hh}" width="${n.w}" height="${n.h}" rx="6" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.4"/>`;
|
||||||
|
}
|
||||||
|
const fs = n.shape === "diamond" ? 11 : 12;
|
||||||
|
const ly = n.sub ? -2 : 4;
|
||||||
|
let label = `<text class="node-label" x="0" y="${ly}" font-size="${fs}" fill="${c.text}">${esc(n.label)}</text>`;
|
||||||
|
let sub = "";
|
||||||
|
if (n.sub) sub = `<text class="node-sub" x="0" y="11" fill="${state.theme.nodeSubText}">${esc(n.sub)}</text>`;
|
||||||
|
const port = `<circle class="node-port" cx="${hw + 9}" cy="0" r="5" data-node="${n.id}"/>`;
|
||||||
|
s += `<g class="bpd-node${sel}" data-id="${n.id}" transform="translate(${n.x},${n.y})">${box}${label}${sub}${port}</g>`;
|
||||||
|
});
|
||||||
|
g.innerHTML = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s).replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 交互 ----------------
|
||||||
|
function onDown(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const portEl = e.target.closest(".node-port");
|
||||||
|
if (portEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
startRubber(parseInt(portEl.getAttribute("data-node"), 10), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeEl = e.target.closest(".bpd-node");
|
||||||
|
if (nodeEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
startNodeDrag(parseInt(nodeEl.getAttribute("data-id"), 10), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 空白:平移
|
||||||
|
startPan(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPan(e) {
|
||||||
|
state.drag = { type: "pan", sx: e.clientX, sy: e.clientY, tx0: state.tx, ty0: state.ty };
|
||||||
|
$("stage").classList.add("panning");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNodeDrag(id, e) {
|
||||||
|
const n = state.nodeMap[id];
|
||||||
|
const p = screenToCanvas(e.clientX, e.clientY);
|
||||||
|
state.drag = { type: "node", id, ox: p.x - n.x, oy: p.y - n.y, moved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRubber(id, e) {
|
||||||
|
const n = state.nodeMap[id];
|
||||||
|
state.rubber = { from: id, sx: n.x + n.w / 2, sy: n.y, ex: n.x + n.w / 2, ey: n.y };
|
||||||
|
renderRubber();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e) {
|
||||||
|
if (state.drag && state.drag.type === "pan") {
|
||||||
|
state.tx = state.drag.tx0 + (e.clientX - state.drag.sx);
|
||||||
|
state.ty = state.drag.ty0 + (e.clientY - state.drag.sy);
|
||||||
|
applyTransform();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.drag && state.drag.type === "node") {
|
||||||
|
const p = screenToCanvas(e.clientX, e.clientY);
|
||||||
|
const n = state.nodeMap[state.drag.id];
|
||||||
|
n.x = Math.round(p.x - state.drag.ox);
|
||||||
|
n.y = Math.round(p.y - state.drag.oy);
|
||||||
|
state.drag.moved = true;
|
||||||
|
// 直接改该节点 port 位置 + 重绘相连边
|
||||||
|
moveNodeEl(n);
|
||||||
|
renderEdges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.rubber) {
|
||||||
|
const p = screenToCanvas(e.clientX, e.clientY);
|
||||||
|
state.rubber.ex = p.x; state.rubber.ey = p.y;
|
||||||
|
renderRubber();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveNodeEl(n) {
|
||||||
|
const el = document.querySelector(`.bpd-node[data-id="${n.id}"]`);
|
||||||
|
if (el) el.setAttribute("transform", `translate(${n.x},${n.y})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRubber() {
|
||||||
|
const g = $("layerRubber");
|
||||||
|
if (!state.rubber) { g.innerHTML = ""; return; }
|
||||||
|
const r = state.rubber;
|
||||||
|
g.innerHTML = `<path class="rubber" d="M ${r.sx} ${r.sy} L ${r.ex} ${r.ey}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(e) {
|
||||||
|
if (state.drag && state.drag.type === "pan") {
|
||||||
|
state.drag = null;
|
||||||
|
$("stage").classList.remove("panning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.drag && state.drag.type === "node") {
|
||||||
|
const id = state.drag.id;
|
||||||
|
const moved = state.drag.moved;
|
||||||
|
state.drag = null;
|
||||||
|
if (moved) {
|
||||||
|
saveMove(id);
|
||||||
|
} else {
|
||||||
|
selectNode(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.rubber) {
|
||||||
|
const from = state.rubber.from;
|
||||||
|
const endCanvas = screenToCanvas(e.clientX, e.clientY);
|
||||||
|
state.rubber = null;
|
||||||
|
$("layerRubber").innerHTML = "";
|
||||||
|
// 命中节点?
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const nodeEl = el && el.closest ? el.closest(".bpd-node") : null;
|
||||||
|
if (nodeEl) {
|
||||||
|
const to = parseInt(nodeEl.getAttribute("data-id"), 10);
|
||||||
|
if (to !== from) { createEdge(from, to); }
|
||||||
|
} else {
|
||||||
|
// 空白:弹输入框建新节点
|
||||||
|
showNewNodeInput(e.clientX, e.clientY, from, endCanvas);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNode(id) {
|
||||||
|
state.selected = id;
|
||||||
|
document.querySelectorAll(".bpd-node").forEach((g) => {
|
||||||
|
g.classList.toggle("selected", parseInt(g.getAttribute("data-id"), 10) === id);
|
||||||
|
});
|
||||||
|
const n = state.nodeMap[id];
|
||||||
|
const panel = $("detail");
|
||||||
|
panel.style.display = "block";
|
||||||
|
$("detailName").value = n.label;
|
||||||
|
$("detailMeta").textContent = `坐标 (${n.x}, ${n.y}) 泳道:${n.lane || "—"} 样式:${n.variant}`;
|
||||||
|
panel._nodeId = id;
|
||||||
|
}
|
||||||
|
function hideDetail() { $("detail").style.display = "none"; state.selected = null;
|
||||||
|
document.querySelectorAll(".bpd-node.selected").forEach((g) => g.classList.remove("selected")); }
|
||||||
|
|
||||||
|
function showNewNodeInput(cx, cy, from, endCanvas) {
|
||||||
|
const inp = $("newNodeInput");
|
||||||
|
inp.style.display = "block";
|
||||||
|
inp.style.left = cx + 8 + "px";
|
||||||
|
inp.style.top = cy + 8 + "px";
|
||||||
|
inp.value = "";
|
||||||
|
inp._ctx = { from, endCanvas };
|
||||||
|
setTimeout(() => inp.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 写操作 ----------------
|
||||||
|
async function api(method, url, body) {
|
||||||
|
if (state.mode === "preview") {
|
||||||
|
toast("预览模式:请登录管理员账号后编辑");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method, headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin", body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (r.status === 403) { toast("无权限:需要系统管理员"); return null; }
|
||||||
|
if (!r.ok) { toast("操作失败(" + r.status + ")"); return null; }
|
||||||
|
return await r.json();
|
||||||
|
} catch (e) { toast("网络错误"); return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneAt(cx, cy) {
|
||||||
|
let lid = false;
|
||||||
|
state.lanes.forEach((l) => {
|
||||||
|
if (l.kind === "column" && cx >= l.x && cx <= l.x + l.w && cy >= l.y && cy <= l.y + l.h) lid = l.id || lid;
|
||||||
|
});
|
||||||
|
return lid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNode(label, x, y, from) {
|
||||||
|
const res = await api("POST", `/yuthon_bpd/api/diagram/${state.diagram.id}/node`, {
|
||||||
|
label, x, y, lane_id: laneAt(x, y), variant: "doc", shape: "box", w: 96, h: 34,
|
||||||
|
});
|
||||||
|
if (!res || !res.node) return;
|
||||||
|
const n = res.node;
|
||||||
|
state.nodeMap[n.id] = n;
|
||||||
|
renderNodes();
|
||||||
|
if (from != null) await createEdge(from, n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEdge(from, to) {
|
||||||
|
const res = await api("POST", `/yuthon_bpd/api/diagram/${state.diagram.id}/edge`, { from, to });
|
||||||
|
if (!res || !res.edge) return;
|
||||||
|
state.edgeMap[res.edge.id] = res.edge;
|
||||||
|
renderEdges();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMove(id) {
|
||||||
|
const n = state.nodeMap[id];
|
||||||
|
await api("POST", `/yuthon_bpd/api/node/${id}/move`, { x: n.x, y: n.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRename(id, label) {
|
||||||
|
const res = await api("POST", `/yuthon_bpd/api/node/${id}/rename`, { label });
|
||||||
|
if (res && res.ok) { state.nodeMap[id].label = res.label; renderNodes(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEdge(id) {
|
||||||
|
const res = await api("POST", `/yuthon_bpd/api/edge/${id}/delete`, {});
|
||||||
|
if (res && res.ok) { delete state.edgeMap[id]; renderEdges(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 缩放 / 适应 ----------------
|
||||||
|
function zoomAt(cx, cy, factor) {
|
||||||
|
const r = $("svg").getBoundingClientRect();
|
||||||
|
const sx = cx - r.left, sy = cy - r.top;
|
||||||
|
const ns = Math.min(2.5, Math.max(0.3, state.scale * factor));
|
||||||
|
state.tx = sx - (sx - state.tx) * (ns / state.scale);
|
||||||
|
state.ty = sy - (sy - state.ty) * (ns / state.scale);
|
||||||
|
state.scale = ns;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
const ns = Object.values(state.nodeMap);
|
||||||
|
if (!ns.length) return;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
ns.forEach((n) => {
|
||||||
|
minX = Math.min(minX, n.x - n.w / 2); maxX = Math.max(maxX, n.x + n.w / 2);
|
||||||
|
minY = Math.min(minY, n.y - n.h / 2); maxY = Math.max(maxY, n.y + n.h / 2);
|
||||||
|
});
|
||||||
|
const pad = 40;
|
||||||
|
const cw = maxX - minX + pad * 2, ch = maxY - minY + pad * 2;
|
||||||
|
const r = $("svg").getBoundingClientRect();
|
||||||
|
const s = Math.min(r.width / cw, r.height / ch, 1.4);
|
||||||
|
state.scale = Math.max(0.3, s);
|
||||||
|
state.tx = (r.width - (maxX + minX) * state.scale) / 2;
|
||||||
|
state.ty = (r.height - (maxY + minY) * state.scale) / 2;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchNode(kw) {
|
||||||
|
kw = (kw || "").trim();
|
||||||
|
document.querySelectorAll(".bpd-node.hl").forEach((g) => g.classList.remove("hl"));
|
||||||
|
if (!kw) return;
|
||||||
|
let first = null;
|
||||||
|
Object.values(state.nodeMap).forEach((n) => {
|
||||||
|
const hit = n.label.indexOf(kw) >= 0 || (n.sub && n.sub.indexOf(kw) >= 0);
|
||||||
|
const el = document.querySelector(`.bpd-node[data-id="${n.id}"]`);
|
||||||
|
if (hit && el) { el.classList.add("hl"); if (!first) first = n; }
|
||||||
|
});
|
||||||
|
if (first) {
|
||||||
|
const r = $("svg").getBoundingClientRect();
|
||||||
|
state.tx = r.width / 2 - first.x * state.scale;
|
||||||
|
state.ty = r.height / 2 - first.y * state.scale;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 事件绑定 ----------------
|
||||||
|
function bind() {
|
||||||
|
const svg = $("svg");
|
||||||
|
svg.addEventListener("mousedown", onDown);
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
svg.addEventListener("wheel", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 1 / 1.12);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
$("btnZoomIn").addEventListener("click", () => {
|
||||||
|
const r = $("svg").getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1.15);
|
||||||
|
});
|
||||||
|
$("btnZoomOut").addEventListener("click", () => {
|
||||||
|
const r = $("svg").getBoundingClientRect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / 1.15);
|
||||||
|
});
|
||||||
|
$("btnFit").addEventListener("click", fit);
|
||||||
|
$("searchInput").addEventListener("input", (e) => searchNode(e.target.value));
|
||||||
|
$("diagramSelect").addEventListener("change", (e) => {
|
||||||
|
const id = parseInt(e.target.value, 10);
|
||||||
|
if (!id) return;
|
||||||
|
hideDetail();
|
||||||
|
history.replaceState(null, "", "?id=" + id);
|
||||||
|
loadDiagram(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("detailRename").addEventListener("click", () => {
|
||||||
|
const id = $("detail")._nodeId;
|
||||||
|
if (id != null) saveRename(id, $("detailName").value.trim());
|
||||||
|
});
|
||||||
|
$("detailName").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") $("detailRename").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const inp = $("newNodeInput");
|
||||||
|
inp.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const label = inp.value.trim();
|
||||||
|
const ctx = inp._ctx;
|
||||||
|
inp.style.display = "none";
|
||||||
|
if (label && ctx) createNode(label, Math.round(ctx.endCanvas.x), Math.round(ctx.endCanvas.y), ctx.from);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
inp.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击连线删除(仅提示,实际删除需二次确认)
|
||||||
|
$("layerEdges").addEventListener("click", (e) => {
|
||||||
|
const hit = e.target.closest(".edge-hit");
|
||||||
|
if (hit) {
|
||||||
|
const id = parseInt(hit.getAttribute("data-edge"), 10);
|
||||||
|
if (confirm("删除这条关联流程?")) deleteEdge(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击空白取消选中
|
||||||
|
$("stage").addEventListener("mousedown", (e) => {
|
||||||
|
if (!e.target.closest(".bpd-node") && !e.target.closest("#detail")) hideDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => { /* 保持当前变换,无需重算 */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 启动 ----------------
|
||||||
|
bind();
|
||||||
|
loadData();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============ 流程图表单 / 列表 ============ -->
|
||||||
|
<record id="yuthon_bpd_diagram_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">yuthon.bpd.diagram.form</field>
|
||||||
|
<field name="model">yuthon.bpd.diagram</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<header>
|
||||||
|
<button name="action_open_visual" type="object"
|
||||||
|
string="可视化编辑" class="oe_highlight"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="key"/>
|
||||||
|
<field name="canvas_w"/>
|
||||||
|
<field name="canvas_h"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="泳道">
|
||||||
|
<field name="lane_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="kind"/>
|
||||||
|
<field name="x"/>
|
||||||
|
<field name="y"/>
|
||||||
|
<field name="w"/>
|
||||||
|
<field name="h"/>
|
||||||
|
<field name="head_h"/>
|
||||||
|
<field name="label_pos"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="节点">
|
||||||
|
<field name="node_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="label"/>
|
||||||
|
<field name="sub"/>
|
||||||
|
<field name="lane_id"/>
|
||||||
|
<field name="shape"/>
|
||||||
|
<field name="variant"/>
|
||||||
|
<field name="x"/>
|
||||||
|
<field name="y"/>
|
||||||
|
<field name="w"/>
|
||||||
|
<field name="h"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="连线">
|
||||||
|
<field name="edge_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="from_id"/>
|
||||||
|
<field name="to_id"/>
|
||||||
|
<field name="label"/>
|
||||||
|
<field name="style"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="yuthon_bpd_diagram_view_list" model="ir.ui.view">
|
||||||
|
<field name="name">yuthon.bpd.diagram.list</field>
|
||||||
|
<field name="model">yuthon.bpd.diagram</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="key"/>
|
||||||
|
<field name="canvas_w"/>
|
||||||
|
<field name="canvas_h"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="yuthon_bpd_diagram_action" model="ir.actions.act_window">
|
||||||
|
<field name="name">流程图</field>
|
||||||
|
<field name="res_model">yuthon.bpd.diagram</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="view_id" ref="yuthon_bpd_diagram_view_list"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- 顶层菜单 -->
|
||||||
|
<menuitem id="menu_yuthon_bpd_root"
|
||||||
|
name="业务流程图"
|
||||||
|
web_icon="yuthon_bpd,static/src/img/flow.png"
|
||||||
|
sequence="95"/>
|
||||||
|
|
||||||
|
<!-- 后台维护入口:仅系统管理员 -->
|
||||||
|
<menuitem id="menu_yuthon_bpd_admin" name="流程图管理"
|
||||||
|
parent="menu_yuthon_bpd_root"
|
||||||
|
action="yuthon_bpd_diagram_action"
|
||||||
|
groups="base.group_system"/>
|
||||||
|
|
||||||
|
<!-- 前端可视化入口:新标签打开 bpd_view.html,数据由 REST 接口提供 -->
|
||||||
|
<record id="action_yuthon_bpd_view" model="ir.actions.act_url">
|
||||||
|
<field name="name">流程图视图</field>
|
||||||
|
<field name="url">/yuthon_bpd/static/src/bpd_view.html</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
<menuitem id="menu_yuthon_bpd_view" name="业务流程度"
|
||||||
|
parent="menu_yuthon_bpd_root"
|
||||||
|
action="action_yuthon_bpd_view"
|
||||||
|
groups="base.group_user"/>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user