用途:统一流程图制作标准,确保任何功能的流程图都能:
- 100% 还原真实业务逻辑(包括分支、回流、异常路径)
- 清晰展示三端联动(operate / control / front 的交互与影响)
- 提供完整节点详情(每个节点的触发条件、执行逻辑、涉及数据、代码位置)
- 自动布局不重叠(用 dagre 自动分层,不手写坐标,杜绝线条/元素重叠交叉)
- 可拖拽 + 记忆位置(节点可拖动,位置自动存 localStorage,刷新后恢复)
- 支持无限扩展(画布宽高不限,业务有多复杂流程图就撑多大)
⚠ 本规范 v2(2026-06-24 重写):吸取第一版"手写坐标导致重叠乱"的教训, 改为 dagre 自动布局;分支从"藏在面板文字里"改为"真实画进图";新增拖拽+localStorage。 标杆参考实现见第九章「参考示例」。
每个操作都要说清楚:
为了好维护、避免单文件过大被截断,拆成 4 个文件放同一目录:
| 文件 | 作用 | 大小参考 |
|---|---|---|
xxx流程图.html |
骨架:顶栏+图例+画布+详情面板 DOM + 全部 CSS | ~10KB |
flow-data.js |
数据:NODES(节点数组)+ EDGES(连线数组) |
随业务 |
flow-render.js |
引擎:dagre 布局 + 渲染 + 拖拽 + localStorage + 折线 + 面板 | ~14KB |
dagre.min.js |
自动布局库(离线内置,约 278KB,自包含 graphlib) | 278KB |
HTML 末尾按顺序引用:
<script src="dagre.min.js"></script>
<script src="flow-data.js"></script>
<script src="flow-render.js"></script>
dagre.min.js 来源:
https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js, 下载后放同目录即可离线用(已验证可用)。flow-render.js里调dagre.graphlib.Graph/dagre.layout。
┌────────────────────────────────────────────────┐
│ 顶部固定栏:标题 + 图例 + [重置布局][适应屏幕][已保存] │
├────────────────────────────────────────────────┤
│ 画布视口 viewport(可平移/滚轮缩放): │
│ world(transform 平移缩放): │
│ ├─ svg(直角折线连线,在节点下层) │
│ └─ 节点(绝对定位,dagre 算坐标,可拖拽) │
│ 右下角:缩放控制 [+][百分比][-] │
└────────────────────────────────────────────────┘
右侧滑出:详情面板(点节点弹出)
| 类型 | type | 边框色 | 背景色 | 图标 | 说明 |
|---|---|---|---|---|---|
| 起止 | start |
#16A085 |
#E8F8F5 |
🥚/🔚 | 流程起点终点 |
| operate | operate |
#4A90E2 |
#EAF3FC |
🖥️ | 操作端动作 |
| control | control |
#F39C12 |
#FEF5E7 |
⚙️ | 后台动作 |
| front | front |
#9B59B6 |
#F5EEF8 |
💻 | 管理端动作 |
| 判断分支 | branch |
#E67E22 |
#FDF2E9 |
❓ | 决策点(虚线边框) |
| 异常 | error |
#E74C3C |
#FDEDEC |
⚠️ | 报警/失败 |
节点结构(由 JS 生成,宽度固定 200px,高度由内容自动量):
<div class="flow-node operate" id="node-op-add">
<div class="node-head"><span class="node-icon">🖥️</span><span class="node-title">新建患者入箱</span></div>
<span class="node-tag">operate 操作端</span>
<div class="node-brief">录入患者信息 + 选 16 孔位 + 受精方式</div> <!-- 一句话摘要,画布上直接可见 -->
</div>
node-brief 很重要:画布上每个节点直接显示一句话摘要,不用点开就懂大概;点开才看完整详情。
| type | 含义 | 颜色 | 线型 |
|---|---|---|---|
internal |
本端内部流程 | #7F8C8D 灰 |
实线 |
cross |
跨端调用(API/MQTT/Kafka/DB同步) | #16A085 绿 |
虚线 6,5 |
error |
异常/报警路径 | #E74C3C 红 |
点线 2,4 |
loop |
回流(回到前面某步/下一轮循环) | #E67E22 橙 |
点线 2,4 |
M sx,sy L sx,midY L ex,midY L ex,ey<marker>,每种 type 一个对应颜色的箭头v2 变更:从 10 个板块精简为 8 个。删掉了"后续分支"(分支改画进图)。 面板宽度 640px,从右滑出,点遮罩/ESC/×关闭。面板只讲这一个节点自己的事。
每个节点的 detail 对象字段(flow-data.js 里)与面板板块一一对应:
| 字段 | 面板板块 | 内容 |
|---|---|---|
desc |
📋 这步是什么 | 一段大白话,说清这个节点干什么 |
pre |
🔑 前置条件 | 数组,能进这步要满足的条件 |
trigger |
🎯 触发方式 | 哪个界面哪个按钮 / 什么 MQTT / 什么循环触发 |
steps |
🔄 操作步骤 | 数组,用户做什么 或 系统自动执行的步骤 |
backend |
⚙️ 后端逻辑 | 代码层面做了什么(调接口/发MQTT/落库) |
data |
💾 数据/状态变化 | 数组,DB哪些表、内存哪些变量、UI怎么变 |
cross |
🌐 三端联动 | 对象 {from, via, to[]},没有跨端则 null(面板不显示该块) |
code |
📍 代码位置 | 数组,文件:行号 方法名 / 表名 / topic |
{
id: 'op-start-dish', type: 'operate', icon: '▶️', title: '开始培养(保存)',
brief: '落库 dish+embryo → 发 MQTT 通知 control', // 画布上直接显示的一句话
detail: {
desc: '入箱的核心动作。前端校验后调 StartDishApi,后端把培养皿+每个胚胎落库,再发 MQTT StartDish 通知 control。',
pre: ['表单校验通过(孔位≥1、必填非空)', '舱室=空闲', 'control 在线'],
trigger: 'operate 入箱窗点【保存=开始培养】→ StartDish_Click',
steps: ['前端校验必填项', '调 StartDishApi', '后端落库 dish 表+embryo 表', '后端发 MQTT StartDish'],
backend: 'StartDish_Click → StartDishApi → dish/embryo 落库 → MqttSendRpc 发 MQTT StartDish',
data: ['DB:dish 表插1条(培养中),embryo 表插N条(state=0)', '舱状态:空闲→培养中'],
cross: {
from: 'operate 点【开始培养】',
via: 'StartDishApi → dish/embryo 落库 → MQTT StartDish',
to: ['control 收 MQTT → HouseBin.StartDish → 启动舱主循环',
'front 设备管理:舱格变"培养中"',
'operate 主界面:舱格变色+显示患者']
},
code: ['operate AddDishWindowView.xaml.cs:451 StartDish_Click',
'control AppData.cs:1102 StartDish', 'DB dish/embryo 表']
}
}
本端:operate 点【开始培养】
↓ StartDishApi → dish/embryo 落库 → MQTT StartDish
影响:
▸ control 收 MQTT → HouseBin.StartDish → 启动舱主循环
▸ front 设备管理:舱格变"培养中"
▸ operate 主界面:舱格变色+显示患者
核心:分支不靠手画,而是在
EDGES里写好"从哪个节点连到哪些节点", dagre 自动算出分叉布局。你只管把分支的节点和连线定义对,图形自动成型。
加一个 branch 类型判断节点,从它连出两条边到两个不同节点:
// 节点
{ id: 'br-balance-or-start', type: 'branch', title: '判断:先平衡?还是直接开始?', ... }
// 连线:判断节点 → 两条路
{ from: 'br-balance-or-start', to: 'op-balance', type:'internal', label:'①点【平衡】' },
{ from: 'br-balance-or-start', to: 'op-start-dish', type:'internal', label:'②点【开始培养】' },
判断节点连出 N 条边到 N 个并排节点,dagre 自动把它们排成一排:
{ from: 'br-destination', to: 'op-transplant', type:'internal', label:'移植' },
{ from: 'br-destination', to: 'op-freeze', type:'internal', label:'冷冻' },
{ from: 'br-destination', to: 'op-delete', type:'internal', label:'删除' },
{ from: 'br-destination', to: 'op-invalid', type:'internal', label:'作废' },
// 四个去向再汇合到"结束培养"
{ from: 'op-transplant', to: 'op-end', type:'internal' },
{ from: 'op-freeze', to: 'op-end', type:'internal' },
{ from: 'op-delete', to: 'op-end', type:'internal' },
{ from: 'op-invalid', to: 'op-end', type:'internal' },
用 type:'loop'(橙色点线),从后面节点连回前面节点:
{ from: 'ctl-airswap', to: 'ctl-loop', type:'loop', label:'换气完→下轮' },
{ from: 'op-back-main', to: 'op-main', type:'loop', label:'舱位释放·可放下一个' },
用 type:'error'(红色点线),从主流程节点旁拉出到异常节点:
{ from: 'ctl-photo', to: 'err-alarm', type:'error', label:'拍照失败' },
{ from: 'err-alarm', to: 'op-main', type:'error', label:'报警显示' },
用 type:'cross'(绿色虚线),表示经 API/MQTT/Kafka 到另一个端:
{ from: 'op-start-dish', to: 'ctl-recv', type:'cross', label:'MQTT StartDish' },
{ from: 'ctl-photo', to: 'op-detail', type:'cross', label:'Kafka→DB→轮询' },
要点:分支汇合点(如四去向都→结束培养)也用边表达,dagre 会自动收拢。 你不需要算任何坐标,只要边写对,图就对。
tl-flow-positions-v2)world 元素的 transform: translate() scale()M sx,sy L sx,midY L ex,midY L ex,eyrenderEdges())NODES 和 EDGES 两个数组(见 3.1 / 第四章示例)computeLayout()——dagre 算每个节点 {x,y}(先临时渲染量节点高度)loadSaved()——读 localStorage 覆盖坐标renderNodes()——绝对定位渲染节点 + 绑拖拽 + 绑点击renderEdges()——SVG 直角折线 + 箭头 + 标签makeDraggable()——拖拽 + 拖完 savePositions()showDetail / closeDetail / detailHTML)init()——串起来;重置布局/适应屏幕 按钮const NODES = [
{
id: 'op-start-dish', // 唯一ID,格式:端-功能-动作
type: 'operate', // start/operate/control/front/branch/error
icon: '▶️',
title: '开始培养(保存)',
brief: '落库 dish+embryo → 发 MQTT 通知 control', // 画布上直接显示
detail: { desc, pre[], trigger, steps[], backend, data[], cross|null, code[] } // 见第三章
},
// ... 没有 x/y!坐标由 dagre 算
];
const EDGES = [
{ from: 'op-start-dish', to: 'ctl-recv', type: 'cross', label: 'MQTT StartDish' },
// type: internal(本端) / cross(跨端) / error(异常) / loop(回流)
// label 可选
];
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 90, marginx: 40, marginy: 40 });
g.setDefaultEdgeLabel(() => ({}));
NODES.forEach(n => g.setNode(n.id, { width: 200, height: 量出来的高度 }));
EDGES.forEach(e => g.setEdge(e.from, e.to));
dagre.layout(g);
g.nodes().forEach(id => { const nd = g.node(id); nodePos[id] = { x: nd.x - nd.width/2, y: nd.y - nd.height/2 }; });
branch 判断节点、error 异常节点)端-功能,如 op-start-dish、ctl-loop、br-destination)brief(画布一句话)+ 完整 detail(8 字段,见第三章)branch 节点EDGES 到不同子节点(见第四章)loop、异常旁路用 error、跨端用 crosstype(internal/cross/error/loop)+ 可选 labeldetail.cross 写清 {from, via, to[]}cross 类型边在图上连出来node --check flow-data.js / node --check flow-render.js验数据脚本(复用):
node -e '
global.dagre=require("./dagre.min.js");
const src=require("fs").readFileSync("./flow-data.js","utf8").replace(/const NODES/,"NODES").replace(/const EDGES/,"EDGES");
global.NODES=[];global.EDGES=[];eval(src);
const ids=new Set(NODES.map(n=>n.id));let bad=0;
EDGES.forEach(e=>{if(!ids.has(e.from)){console.log("X起点:",e.from);bad++}if(!ids.has(e.to)){console.log("X终点:",e.to);bad++}});
const g=new dagre.graphlib.Graph();g.setGraph({rankdir:"TB",nodesep:60,ranksep:90});g.setDefaultEdgeLabel(()=>({}));
NODES.forEach(n=>g.setNode(n.id,{width:200,height:90}));EDGES.forEach(e=>{if(g.hasNode(e.from)&&g.hasNode(e.to))g.setEdge(e.from,e.to)});
dagre.layout(g);
const b=g.nodes().map(id=>{const n=g.node(id);return{id,x:n.x-100,y:n.y-45,w:200,h:90}});let ov=0;
for(let i=0;i<b.length;i++)for(let j=i+1;j<b.length;j++){const a=b[i],c=b[j];if(a.x<c.x+c.w&&a.x+a.w>c.x&&a.y<c.y+c.h&&a.y+a.h>c.y)ov++}
console.log("节点",NODES.length,"边",EDGES.length,"坏引用",bad,"重叠",ov,(ov===0&&bad===0)?"[OK]":"[!]");
'
错:一个"标记胚胎去向"节点,分支写在面板"后续分支"里:移植/冷冻/删除/作废。
纠正:建 br-destination 判断节点,画布上真分出 4 个并排节点 + 4 条边(见 4.2)。
错:每个节点写死 x:200, y:100,节点一多线条到处交叉。
纠正:删掉所有坐标,用 dagre 自动分层布局(见 6.3),脚本验证 0 重叠。
缺前置条件/缺三端联动/缺代码位置。纠正:严格按第三章 8 字段填。
所有线一个样 → 看不出本端还是跨端。纠正:按 2.3 用 4 种 type 区分颜色+线型。
错:节点详情多了,单个 HTML 几千行,一次写容易断。 纠正:拆 4 文件(见 2.0),数据(flow-data.js)和引擎(flow-render.js)分开,分批追加。
时差培养箱-培养全流程详图.html(骨架+CSS)flow-data.js(23 节点 + 39 连线)flow-render.js(dagre+拖拽+localStorage+折线+面板)dagre.min.js(布局库)下次做新功能流程图:复制这 4 个文件,改
flow-data.js的 NODES/EDGES, HTML 改标题,flow-render.js 基本不用动。改完跑 7.6 的验证脚本。
✅ 记住这几条,流程图就不会出错:
📌 每次制作前先读这份规范,制作后跑 7.6 验证脚本。直接复制第九章标杆 4 文件改最省事。