jsplumb 审批流前端库案例

bash 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>审批流设计器</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');

:root {
  --bg:        #f5f3ef;
  --bg2:       #ede9e3;
  --surface:   #ffffff;
  --border:    #e0dbd2;
  --border2:   #ccc7be;
  --text:      #2c2a27;
  --text2:     #6b6860;
  --text3:     #9e9b95;
  --accent:    #2563eb;
  --accent-lt: #eff4ff;
  --green:     #059669;
  --green-lt:  #ecfdf5;
  --orange:    #d97706;
  --orange-lt: #fffbeb;
  --purple:    #7c3aed;
  --purple-lt: #f5f3ff;
  --red:       #dc2626;
  --shadow:    0 1px 3px rgba(0,0,0,.06), 0 2px 8px rgba(0,0,0,.05);
  --shadow-md: 0 4px 18px rgba(0,0,0,.1);
  --r:         10px;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Noto Sans SC',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:13px;}

/* Header */
header{background:var(--surface);border-bottom:1px solid var(--border);height:50px;padding:0 18px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;box-shadow:0 1px 3px rgba(0,0,0,.05);z-index:200;}
.logo{display:flex;align-items:center;gap:9px;font-weight:600;font-size:14px;}
.logo-mark{width:20px;height:20px;border-radius:5px;background:var(--accent);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:700;}
.hdr-btns{display:flex;gap:6px;}
.btn{padding:5px 12px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);font-size:12px;cursor:pointer;font-family:inherit;transition:all .15s;}
.btn:hover{border-color:var(--border2);background:var(--bg);color:var(--text);}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff;}
.btn.primary:hover{background:#1d4ed8;}

/* Layout */
.main{display:flex;flex:1;overflow:hidden;}

/* Sidebar */
.sidebar{width:178px;background:var(--surface);border-right:1px solid var(--border);padding:14px 10px;flex-shrink:0;display:flex;flex-direction:column;gap:5px;overflow-y:auto;}
.s-label{font-size:10px;font-weight:600;color:var(--text3);letter-spacing:.9px;text-transform:uppercase;padding:0 5px;margin:10px 0 4px;}
.s-label:first-child{margin-top:0;}
.node-tpl{padding:8px 10px;border-radius:8px;border:1.5px dashed var(--border);background:var(--bg);cursor:grab;display:flex;align-items:center;gap:9px;font-size:12px;color:var(--text2);transition:all .18s;user-select:none;}
.node-tpl:hover{border-style:solid;color:var(--text);transform:translateX(2px);}
.node-tpl[data-type="start"]:hover   {border-color:var(--green); background:var(--green-lt);}
.node-tpl[data-type="approve"]:hover {border-color:var(--accent);background:var(--accent-lt);}
.node-tpl[data-type="condition"]:hover{border-color:var(--orange);background:var(--orange-lt);}
.node-tpl[data-type="cc"]:hover      {border-color:var(--purple);background:var(--purple-lt);}
.node-tpl[data-type="end"]:hover     {border-color:var(--red);   background:#fff5f5;}
.tpl-ico{width:26px;height:26px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
.ti-s{background:var(--green-lt); color:var(--green);}
.ti-a{background:var(--accent-lt);color:var(--accent);}
.ti-c{background:var(--orange-lt);color:var(--orange);}
.ti-cc{background:var(--purple-lt);color:var(--purple);}
.ti-e{background:#fff5f5;color:var(--red);}
.s-tip{margin-top:auto;padding:10px;background:var(--bg2);border-radius:8px;font-size:11px;color:var(--text3);line-height:1.9;}

/* Canvas */
.canvas-wrap{flex:1;position:relative;overflow:auto;background-color:#f7f5f1;background-image:radial-gradient(circle,#d4cfc7 1px,transparent 1px);background-size:22px 22px;}
#canvas{width:4000px;height:3000px;position:relative;transform-origin:0 0;}

/* Nodes --- 默认静默,仅选中激活 */
.flow-node{position:absolute;width:156px;border-radius:var(--r);border:1.5px solid var(--border);background:var(--surface);cursor:default;box-shadow:var(--shadow);z-index:10;transition:border-color .18s,box-shadow .18s;}
/* 选中态 */
.flow-node.selected{border-color:var(--accent)!important;box-shadow:0 0 0 3px rgba(37,99,235,.14),var(--shadow-md);}
/* 磁吸目标高亮(连线拖拽靠近时) */
.flow-node.snap-target{border-color:#10b981!important;box-shadow:0 0 0 3px rgba(16,185,129,.18),var(--shadow-md);}

.node-bar{height:3px;border-radius:var(--r) var(--r) 0 0;}
.nd-start     .node-bar{background:var(--green);}
.nd-approve   .node-bar{background:var(--accent);}
.nd-condition .node-bar{background:var(--orange);}
.nd-cc        .node-bar{background:var(--purple);}
.nd-end       .node-bar{background:var(--red);}

.node-inner{padding:8px 10px;display:flex;align-items:center;gap:8px;}
.node-type-ico{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;}
.nd-start     .node-type-ico{background:var(--green-lt); color:var(--green);}
.nd-approve   .node-type-ico{background:var(--accent-lt);color:var(--accent);}
.nd-condition .node-type-ico{background:var(--orange-lt);color:var(--orange);}
.nd-cc        .node-type-ico{background:var(--purple-lt);color:var(--purple);}
.nd-end       .node-type-ico{background:#fff5f5;         color:var(--red);}

.node-info{flex:1;min-width:0;}
.node-title{font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.node-sub{font-size:10.5px;color:var(--text3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}

/* 删除按钮:仅选中时显示 */
.node-del{position:absolute;top:7px;right:7px;width:15px;height:15px;border-radius:4px;background:transparent;border:none;color:var(--text3);cursor:pointer;font-size:10px;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s,background .15s;}
.flow-node.selected .node-del{opacity:1;}
.node-del:hover{background:#fee2e2;color:var(--red);}

/* Condition branches */
.node-branches{border-top:1px solid var(--border);padding:5px 8px 7px;display:flex;flex-wrap:wrap;gap:3px;}
.br-tag{display:inline-flex;align-items:center;background:var(--orange-lt);border:1px solid #fcd34d;border-radius:4px;padding:1px 6px;font-size:10px;color:var(--orange);}

/* jsPlumb endpoints --- 默认隐藏,选中节点时才显示 */
.jtk-endpoint{z-index:20!important;opacity:0;transition:opacity .18s;}
.flow-node.selected ~ .jtk-endpoint,
.flow-node.selected .jtk-endpoint{opacity:1;}
.jtk-connector{z-index:5!important;}
/* 磁吸目标节点上的端点也高亮 */
.flow-node.snap-target .jtk-endpoint{opacity:1;}

/* Inspector */
.inspector{width:234px;background:var(--surface);border-left:1px solid var(--border);flex-shrink:0;display:flex;flex-direction:column;overflow:hidden;}
.insp-hdr{padding:13px 15px 11px;border-bottom:1px solid var(--border);font-size:12px;font-weight:600;color:var(--text2);}
.insp-body{flex:1;overflow-y:auto;padding:14px 13px;display:flex;flex-direction:column;gap:13px;}
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:12px;gap:8px;text-align:center;line-height:1.9;}
.empty-ico{font-size:26px;opacity:.35;}

.fg{display:flex;flex-direction:column;gap:4px;}
.fg label{font-size:11px;font-weight:500;color:var(--text2);}
.fg input,.fg select,.fg textarea{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;padding:6px 8px;font-family:inherit;outline:none;transition:border-color .15s;width:100%;}
.fg input:focus,.fg select:focus,.fg textarea:focus{border-color:var(--accent);background:#fff;}
.fg textarea{resize:vertical;min-height:52px;}
.fg select option{background:#fff;}

/* Conditions */
.conds-area{display:flex;flex-direction:column;gap:5px;}
.cond-row{background:var(--bg);border:1px solid var(--border);border-radius:7px;padding:8px 9px;display:flex;flex-direction:column;gap:5px;}
.cond-hdr{display:flex;align-items:center;justify-content:space-between;font-size:10.5px;font-weight:600;color:var(--orange);}
.cond-del{width:15px;height:15px;border-radius:3px;background:transparent;border:none;color:var(--text3);cursor:pointer;font-size:10px;display:flex;align-items:center;justify-content:center;transition:background .15s;}
.cond-del:hover{background:#fee2e2;color:var(--red);}
.cond-input{background:#fff;border:1px solid var(--border);border-radius:5px;color:var(--text);font-size:11.5px;padding:4px 7px;font-family:inherit;outline:none;transition:border-color .15s;width:100%;}
.cond-input:focus{border-color:var(--orange);}
.add-cond{display:flex;align-items:center;justify-content:center;gap:5px;padding:6px;border:1.5px dashed var(--border2);border-radius:7px;background:transparent;color:var(--text3);font-size:11.5px;cursor:pointer;font-family:inherit;transition:all .15s;width:100%;}
.add-cond:hover{border-color:var(--orange);color:var(--orange);background:var(--orange-lt);}

.apply-btn{padding:7px;border-radius:7px;border:none;background:var(--accent);color:#fff;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:background .15s;width:100%;margin-top:2px;}
.apply-btn:hover{background:#1d4ed8;}

/* Footer */
footer{background:var(--surface);border-top:1px solid var(--border);padding:4px 16px;display:flex;justify-content:space-between;font-size:11px;color:var(--text3);flex-shrink:0;}

/* Zoom */
.zoom-wrap{position:absolute;right:14px;bottom:14px;display:flex;gap:4px;z-index:100;}
.zoom-btn{width:28px;height:28px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text2);cursor:pointer;font-size:15px;display:flex;align-items:center;justify-content:center;transition:all .15s;box-shadow:var(--shadow);}
.zoom-btn:hover{border-color:var(--accent);color:var(--accent);}
.zoom-btn.sm{font-size:10px;width:36px;}

/* Label popup */
.lbl-popup{position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:11px 12px;z-index:9999;display:none;flex-direction:column;gap:7px;min-width:190px;box-shadow:var(--shadow-md);}
.lbl-popup.show{display:flex;}
.lbl-ptitle{font-size:11px;font-weight:600;color:var(--text2);}
.lbl-popup input{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;padding:5px 8px;font-family:inherit;outline:none;}
.lbl-popup input:focus{border-color:var(--accent);}
.lbl-btns{display:flex;gap:5px;}
.lbl-btns button{flex:1;padding:5px;border-radius:5px;border:none;font-size:11.5px;cursor:pointer;font-family:inherit;font-weight:500;}
.lbl-ok{background:var(--accent);color:#fff;}
.lbl-ok:hover{background:#1d4ed8;}
.lbl-cancel{background:var(--bg2);color:var(--text2);}
.lbl-cancel:hover{background:var(--border);}
</style>
</head>
<body>

<header>
  <div class="logo">
    <div class="logo-mark">AF</div>
    审批流程设计器
  </div>
  <div class="hdr-btns">
    <button class="btn" onclick="doExport()">↓ 导出</button>
    <button class="btn" onclick="doClear()">清空</button>
    <button class="btn primary" onclick="doSave()">保存</button>
  </div>
</header>

<div class="main">
  <!-- 侧栏 -->
  <div class="sidebar">
    <div class="s-label">节点类型</div>
    <div class="node-tpl" draggable="true" data-type="start">
      <div class="tpl-ico ti-s">●</div>开始节点
    </div>
    <div class="node-tpl" draggable="true" data-type="approve">
      <div class="tpl-ico ti-a">✓</div>审批节点
    </div>
    <div class="node-tpl" draggable="true" data-type="condition">
      <div class="tpl-ico ti-c">◈</div>条件判断
    </div>
    <div class="node-tpl" draggable="true" data-type="cc">
      <div class="tpl-ico ti-cc">@</div>抄送节点
    </div>
    <div class="node-tpl" draggable="true" data-type="end">
      <div class="tpl-ico ti-e">■</div>结束节点
    </div>
    <div class="s-tip">
      拖拽节点至画布<br>
      选中节点后拖锚点连线<br>
      单击节点编辑属性<br>
      单击连线编辑标签
    </div>
  </div>

  <!-- 画布 -->
  <div class="canvas-wrap" id="canvasWrap">
    <div id="canvas"></div>
    <div class="zoom-wrap">
      <button class="zoom-btn" onclick="doZoom(.12)">+</button>
      <button class="zoom-btn" onclick="doZoom(-.12)">−</button>
      <button class="zoom-btn sm" onclick="doZoomReset()">重置</button>
    </div>
  </div>

  <!-- 属性面板 -->
  <div class="inspector">
    <div class="insp-hdr">节点属性</div>
    <div class="insp-body" id="inspBody">
      <div class="empty-state">
        <div class="empty-ico">◫</div>
        点击节点<br>编辑属性
      </div>
    </div>
  </div>
</div>

<footer>
  <span id="statusTxt">就绪 --- 从左侧拖拽节点开始</span>
  <span id="cntTxt">节点: 0 · 连线: 0</span>
</footer>

<!-- 连线标签弹窗 -->
<div class="lbl-popup" id="lblPopup">
  <div class="lbl-ptitle">连线标签</div>
  <input type="text" id="lblInput" placeholder="如:通过 / 拒绝 / 金额≤1万">
  <div class="lbl-btns">
    <button class="lbl-ok"     onclick="applyLbl()">确定</button>
    <button class="lbl-cancel" onclick="closeLbl()">取消</button>
  </div>
</div>

<script>
// ═══════════════════════════════
// 状态
// ═══════════════════════════════
let jsp, scale = 1, seq = 0;
let selNodeId = null, selConn = null;
let NODES = {}, CONNS = [];

const DEF = {
  start:     { label:'开始',     ico:'●', cls:'nd-start',     sub: ()  => '' },
  approve:   { label:'审批节点', ico:'✓', cls:'nd-approve',   sub: m   => m.assignee  || '待设置审批人' },
  condition: { label:'条件判断', ico:'◈', cls:'nd-condition', sub: m   => m.conditions?.length ? `${m.conditions.length} 个分支` : '待配置条件' },
  cc:        { label:'抄送节点', ico:'@', cls:'nd-cc',        sub: m   => m.assignee  || '待设置抄送人' },
  end:       { label:'结束',     ico:'■', cls:'nd-end',       sub: ()  => '' },
};

// ═══════════════════════════════
// jsPlumb 初始化
// ═══════════════════════════════
jsPlumb.ready(() => {
  jsp = jsPlumb.getInstance({
    Container: 'canvas',
    ConnectionOverlays: [['Arrow',{location:1,width:9,length:9}]],
  });
  jsp.importDefaults({
    Connector: ['Flowchart', {cornerRadius:5, stub:22}],
    PaintStyle:         {stroke:'#2563eb', strokeWidth:1.8},
    HoverPaintStyle:    {stroke:'#1d4ed8', strokeWidth:2.2},
    EndpointStyle:      {fill:'#2563eb', radius:4.5},
    EndpointHoverStyle: {fill:'#1d4ed8'},
  });
  jsp.bind('connection', info => {
    CONNS.push({id:info.connection.id, src:info.sourceId, tgt:info.targetId, label:''});
    info.connection.bind('click', (conn, e) => {
      selConn = conn;
      const c = CONNS.find(x => x.id === conn.id);
      showLbl(e.clientX, e.clientY, c ? c.label : '');
      e.stopPropagation();
    });
    syncCount();
  });
  jsp.bind('connectionDetached', info => {
    CONNS = CONNS.filter(c => c.id !== info.connection.id);
    syncCount();
  });

  // 磁吸附:连线拖拽过程中高亮最近节点
  jsp.bind('connectionDrag', conn => { snapHighlight(conn); });
  jsp.bind('connectionDragStop', () => { clearSnapHighlight(); });

  setupPan();
});

// ═══════════════════════════════
// 拖拽创建节点
// ═══════════════════════════════
let dragType = null;
document.querySelectorAll('.node-tpl').forEach(el => {
  el.addEventListener('dragstart', e => {
    dragType = el.dataset.type;
    e.dataTransfer.effectAllowed = 'copy';
  });
});
const wrapEl = document.getElementById('canvasWrap');
wrapEl.addEventListener('dragover', e => e.preventDefault());
wrapEl.addEventListener('drop', e => {
  e.preventDefault();
  if (!dragType) return;
  const r = wrapEl.getBoundingClientRect();
  const x = (e.clientX - r.left + wrapEl.scrollLeft) / scale;
  const y = (e.clientY - r.top  + wrapEl.scrollTop)  / scale;
  createNode(dragType, x - 78, y - 24);
  dragType = null;
});

// ═══════════════════════════════
// 创建节点
// ═══════════════════════════════
function defMeta(type) {
  if (type === 'approve')   return {name:DEF[type].label, assignee:'', approveType:'or', remark:''};
  if (type === 'condition') return {name:DEF[type].label, conditions:[{label:'分支1', expr:''}], remark:''};
  if (type === 'cc')        return {name:DEF[type].label, assignee:'', remark:''};
  return {name: DEF[type].label};
}

function nodeHTML(id, type, meta) {
  const d = DEF[type], sub = d.sub(meta);
  let branchHTML = '';
  if (type === 'condition' && meta.conditions && meta.conditions.length) {
    branchHTML = `<div class="node-branches">${
      meta.conditions.map(c => `<span class="br-tag">${h(c.label)||'分支'}</span>`).join('')
    }</div>`;
  }
  return `<div class="node-bar"></div>
<div class="node-inner">
  <div class="node-type-ico">${d.ico}</div>
  <div class="node-info">
    <div class="node-title">${h(meta.name||d.label)}</div>
    ${sub ? `<div class="node-sub">${h(sub)}</div>` : ''}
  </div>
</div>
${branchHTML}
<button class="node-del" data-nid="${id}" title="删除">✕</button>`;
}

function createNode(type, x, y, id, meta) {
  const d = DEF[type];
  id   = id   || `n${++seq}`;
  meta = meta || defMeta(type);

  const el = document.createElement('div');
  el.id = id;
  el.className = `flow-node ${d.cls}`;
  el.style.cssText = `left:${x}px;top:${y}px`;
  el.innerHTML = nodeHTML(id, type, meta);
  document.getElementById('canvas').appendChild(el);

  jsp.draggable(el, {
    grid:[12,12],
    stop:() => { NODES[id].x = parseInt(el.style.left); NODES[id].y = parseInt(el.style.top); }
  });

  ['Top','Right','Bottom','Left'].forEach(anchor => {
    jsp.addEndpoint(el, {
      anchor, isSource:true, isTarget:true, maxConnections:-1,
      endpoint:['Dot',{radius:4.5}],
      paintStyle:{fill:'#2563eb', stroke:'#fff', strokeWidth:1.5},
      hoverPaintStyle:{fill:'#1d4ed8'},
    });
  });
  // 初始隐藏端点
  setTimeout(() => setEndpointsVisible(id, false), 0);

  // 点击选中(用事件委托避免多次绑定)
  el.addEventListener('mousedown', e => {
    const btn = e.target.closest('.node-del');
    if (btn) { deleteNode(btn.dataset.nid, e); return; }
    selectNode(id);
    e.stopPropagation();
  });

  NODES[id] = {id, type, x, y, meta: structuredClone(meta)};
  syncCount();
  return id;
}

function refreshNodeDOM(id) {
  const node = NODES[id], el = document.getElementById(id);
  if (!el || !node) return;
  // 仅替换非 jsPlumb 子元素
  Array.from(el.children).forEach(c => {
    if (!c.classList.contains('jtk-endpoint') && !c.classList.contains('_jsPlumb_endpoint'))
      c.remove();
  });
  el.insertAdjacentHTML('afterbegin', nodeHTML(id, node.type, node.meta));
}

// ═══════════════════════════════
// 选中 & 属性面板
// ═══════════════════════════════
function selectNode(id) {
  // 取消上一个
  if (selNodeId && selNodeId !== id) {
    document.getElementById(selNodeId)?.classList.remove('selected');
    setEndpointsVisible(selNodeId, false);
  }
  selNodeId = id;
  const el = document.getElementById(id);
  if (el) el.classList.add('selected');
  setEndpointsVisible(id, true);
  renderInsp(id);
}

function deselectAll() {
  if (selNodeId) {
    document.getElementById(selNodeId)?.classList.remove('selected');
    setEndpointsVisible(selNodeId, false);
    selNodeId = null;
  }
  document.getElementById('inspBody').innerHTML =
    '<div class="empty-state"><div class="empty-ico">◫</div>点击节点<br>编辑属性</div>';
}

// 显示/隐藏某节点的所有 jsPlumb 端点
function setEndpointsVisible(id, visible) {
  if (!jsp || !id) return;
  try {
    const eps = jsp.getEndpoints(id) || [];
    eps.forEach(ep => {
      if (ep.canvas) ep.canvas.style.opacity = visible ? '1' : '0';
    });
  } catch(_) {}
}

function renderInsp(id) {
  const node = NODES[id];
  if (!node) return;
  const d = DEF[node.type], m = node.meta;
  let extra = '';

  if (node.type === 'approve') {
    extra = `
    <div class="fg"><label>审批人</label>
      <input id="p_as" value="${h(m.assignee||'')}" placeholder="如:张三、部门经理"></div>
    <div class="fg"><label>审批方式</label>
      <select id="p_at">
        <option value="or"  ${m.approveType==='or' ?'selected':''}>任意一人通过</option>
        <option value="and" ${m.approveType==='and'?'selected':''}>全部通过</option>
        <option value="seq" ${m.approveType==='seq'?'selected':''}>顺序审批</option>
      </select></div>`;
  }

  if (node.type === 'condition') {
    const rows = (m.conditions||[]).map((c, i) => `
      <div class="cond-row">
        <div class="cond-hdr">
          <span>分支 ${i+1}</span>
          <button class="cond-del" onclick="delCond(${i})">✕</button>
        </div>
        <input class="cond-input" id="cl_${i}" value="${h(c.label||'')}" placeholder="分支名,如:通过">
        <input class="cond-input" id="ce_${i}" value="${h(c.expr||'')}"  placeholder="条件表达式,如:金额 > 1000">
      </div>`).join('');
    extra = `
    <div class="fg"><label>条件分支</label>
      <div class="conds-area" id="condsArea">${rows}</div>
      <button class="add-cond" onclick="addCond()">+ 添加分支</button>
    </div>`;
  }

  if (node.type === 'cc') {
    extra = `
    <div class="fg"><label>抄送人</label>
      <input id="p_as" value="${h(m.assignee||'')}" placeholder="如:李四、HR部门"></div>`;
  }

  document.getElementById('inspBody').innerHTML = `
    <div class="fg"><label>节点类型</label>
      <div style="font-size:12px;color:var(--accent);font-weight:600;padding:2px 0">${d.label}</div></div>
    <div class="fg"><label>节点名称</label>
      <input id="p_nm" value="${h(m.name||d.label)}" placeholder="节点名称"></div>
    ${extra}
    <div class="fg"><label>备注</label>
      <textarea id="p_rm" placeholder="可选说明">${h(m.remark||'')}</textarea></div>
    <button class="apply-btn" onclick="applyProps('${id}')">应用修改</button>
  `;
}

function addCond() {
  if (!selNodeId) return;
  const node = NODES[selNodeId];
  if (!node || node.type !== 'condition') return;
  saveConds(selNodeId);
  node.meta.conditions.push({label:`分支${node.meta.conditions.length+1}`, expr:''});
  renderInsp(selNodeId);
}

function delCond(i) {
  if (!selNodeId) return;
  const node = NODES[selNodeId];
  if (!node || node.type !== 'condition') return;
  saveConds(selNodeId);
  node.meta.conditions.splice(i, 1);
  renderInsp(selNodeId);
}

function saveConds(id) {
  const node = NODES[id];
  if (!node || node.type !== 'condition') return;
  node.meta.conditions = (node.meta.conditions||[]).map((c, i) => ({
    label: document.getElementById(`cl_${i}`)?.value ?? c.label,
    expr:  document.getElementById(`ce_${i}`)?.value ?? c.expr,
  }));
}

function applyProps(id) {
  const node = NODES[id];
  const nm = document.getElementById('p_nm')?.value.trim();
  if (nm) node.meta.name = nm;
  const as = document.getElementById('p_as');
  if (as) node.meta.assignee = as.value.trim();
  const at = document.getElementById('p_at');
  if (at) node.meta.approveType = at.value;
  const rm = document.getElementById('p_rm');
  if (rm) node.meta.remark = rm.value;
  if (node.type === 'condition') saveConds(id);

  refreshNodeDOM(id);
  setStatus('属性已更新');
  renderInsp(id);
}

// ═══════════════════════════════
// 删除节点(修复版)
// ═══════════════════════════════
function deleteNode(id, e) {
  if (e) e.stopPropagation();
  setEndpointsVisible(id, false);
  jsp.deleteConnectionsForElement(id);
  jsp.removeAllEndpoints(id);
  const el = document.getElementById(id);
  if (el) el.remove();
  delete NODES[id];
  CONNS = CONNS.filter(c => c.src !== id && c.tgt !== id);
  if (selNodeId === id) {
    selNodeId = null;
    document.getElementById('inspBody').innerHTML =
      '<div class="empty-state"><div class="empty-ico">◫</div>点击节点<br>编辑属性</div>';
  }
  syncCount();
}

// ═══════════════════════════════
// 清空(修复版)
// ═══════════════════════════════
function doClear() {
  if (!Object.keys(NODES).length) return;
  if (!confirm('确定清空所有节点和连线?')) return;
  jsp.deleteEveryConnection();
  Object.keys(NODES).forEach(id => {
    try { jsp.removeAllEndpoints(id); } catch(_) {}
    document.getElementById(id)?.remove();
  });
  NODES = {};
  CONNS = [];
  selNodeId = null;
  lastSnapId = null;
  document.getElementById('inspBody').innerHTML =
    '<div class="empty-state"><div class="empty-ico">◫</div>点击节点<br>编辑属性</div>';
  syncCount();
  setStatus('已清空');
}

// ═══════════════════════════════
// 连线标签
// ═══════════════════════════════
function showLbl(x, y, cur) {
  const p = document.getElementById('lblPopup');
  p.style.cssText = `left:${x}px;top:${y}px`;
  document.getElementById('lblInput').value = cur || '';
  p.classList.add('show');
  setTimeout(() => document.getElementById('lblInput').focus(), 50);
}
function closeLbl() { document.getElementById('lblPopup').classList.remove('show'); selConn = null; }
function applyLbl() {
  if (!selConn) return;
  const lbl = document.getElementById('lblInput').value.trim();
  selConn.setLabel(lbl);
  const c = CONNS.find(x => x.id === selConn.id);
  if (c) c.label = lbl;
  closeLbl();
}
document.addEventListener('click', e => { if (!e.target.closest('#lblPopup')) closeLbl(); });
document.getElementById('lblInput').addEventListener('keydown', e => { if(e.key==='Enter') applyLbl(); });

// ═══════════════════════════════
// 缩放
// ═══════════════════════════════
function doZoom(d) {
  scale = Math.min(2, Math.max(.25, scale + d));
  document.getElementById('canvas').style.transform = `scale(${scale})`;
  jsp.setZoom(scale);
  setStatus(`缩放 ${Math.round(scale*100)}%`);
}
function doZoomReset() {
  scale = 1;
  document.getElementById('canvas').style.transform = '';
  jsp.setZoom(1);
  setStatus('缩放重置');
}

// ═══════════════════════════════
// 磁吸附高亮
// ═══════════════════════════════
const SNAP_DIST = 80;
let snapTimer = null, lastSnapId = null;
let _lastMouse = null;
document.addEventListener('mousemove', e => { _lastMouse = {x: e.clientX, y: e.clientY}; });

function snapHighlight(conn) {
  if (snapTimer) return;
  snapTimer = setInterval(() => {
    if (!_lastMouse) return;
    const wrapEl2 = document.getElementById('canvasWrap');
    const rect = wrapEl2.getBoundingClientRect();
    const mx = (_lastMouse.x - rect.left + wrapEl2.scrollLeft) / scale;
    const my = (_lastMouse.y - rect.top  + wrapEl2.scrollTop)  / scale;
    let nearest = null, nearDist = SNAP_DIST;
    Object.keys(NODES).forEach(id => {
      if (conn && id === conn.sourceId) return;
      const n = NODES[id];
      if (!n) return;
      const nx = n.x + 78, ny = n.y + 24;
      const dist = Math.hypot(mx - nx, my - ny);
      if (dist < nearDist) { nearDist = dist; nearest = id; }
    });
    if (nearest !== lastSnapId) {
      if (lastSnapId) document.getElementById(lastSnapId)?.classList.remove('snap-target');
      lastSnapId = nearest;
      if (nearest) document.getElementById(nearest)?.classList.add('snap-target');
    }
  }, 40);
}

function clearSnapHighlight() {
  if (snapTimer) { clearInterval(snapTimer); snapTimer = null; }
  if (lastSnapId) { document.getElementById(lastSnapId)?.classList.remove('snap-target'); lastSnapId = null; }
  document.querySelectorAll('.snap-target').forEach(el => el.classList.remove('snap-target'));
}

// ═══════════════════════════════
// 平移
// ═══════════════════════════════
function setupPan() {
  const w = document.getElementById('canvasWrap');
  let pan=false, sx,sy,sl,st, moved=false;
  w.addEventListener('mousedown', e => {
    if (e.target !== w && e.target.id !== 'canvas') return;
    pan=true; moved=false; sx=e.pageX; sy=e.pageY; sl=w.scrollLeft; st=w.scrollTop;
    w.style.cursor='grabbing';
  });
  window.addEventListener('mousemove', e => {
    if (!pan) return;
    moved = true;
    w.scrollLeft = sl-(e.pageX-sx);
    w.scrollTop  = st-(e.pageY-sy);
  });
  window.addEventListener('mouseup', e => {
    if (pan && !moved) {
      // 点击空白处取消选中
      if (e.target === w || e.target.id === 'canvas') deselectAll();
    }
    pan=false; wrapEl.style.cursor='';
  });
}

// ═══════════════════════════════
// 导出 / 保存
// ═══════════════════════════════
function doExport() {
  const data = {
    nodes: Object.values(NODES).map(n => ({id:n.id,type:n.type,x:n.x,y:n.y,...n.meta})),
    connections: CONNS.map(c => ({source:c.src,target:c.tgt,label:c.label}))
  };
  const a = Object.assign(document.createElement('a'), {
    href: URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'})),
    download: 'flow.json'
  });
  a.click();
  setStatus('已导出 flow.json');
}
function doSave() {
  const data = {
    nodes: Object.values(NODES).map(n => ({id:n.id,type:n.type,x:n.x,y:n.y,...n.meta})),
    connections: CONNS.map(c => ({source:c.src,target:c.tgt,label:c.label}))
  };
  try { localStorage.setItem('af_v2', JSON.stringify(data)); setStatus('✓ 已保存'); }
  catch(e) { setStatus('保存失败'); }
}

// ═══════════════════════════════
// 工具函数
// ═══════════════════════════════
function setStatus(m) { document.getElementById('statusTxt').textContent = m; }
function syncCount() {
  const nc = Object.keys(NODES).length;
  const cc = jsp ? jsp.getAllConnections().length : 0;
  document.getElementById('cntTxt').textContent = `节点: ${nc} · 连线: ${cc}`;
}
function h(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function structuredClone(obj) {
  try { return window.structuredClone ? window.structuredClone(obj) : JSON.parse(JSON.stringify(obj)); }
  catch(_) { return JSON.parse(JSON.stringify(obj)); }
}

</script>
</body>
</html>
相关推荐
踩着两条虫2 小时前
从“降门槛”到“提效率”:VTJ.PRO与百度秒哒的差异化路径分析
前端·vue.js·ai编程
一名优秀的码农2 小时前
vulhub系列-59-Web-Machine-N72(超详细)
前端·安全·web安全·网络安全·网络攻击模型·安全威胁分析
色空大师2 小时前
网站搭建实操(十)前端搭建
前端·webpack·vue·网站·论坛
ApjRvH3vg2 小时前
什么是Skills
前端
꧁꫞꯭零꯭点꯭꫞꧂2 小时前
JavaScript模块化规范
开发语言·前端·javascript
三万棵雪松2 小时前
【Linux 物联网网关主控系统-Web部分(四)】
linux·前端·物联网·嵌入式linux
摸鱼的春哥2 小时前
Agent教程22:AI大模型兼容,踩坑到崩溃
前端·javascript·后端
regret~2 小时前
【记录】前端创建
前端
深念Y2 小时前
前端实时通信技术:HTTP轮询、SSE、WebSocket、WebRTC
前端·websocket·网络协议·http·实时互动·轮询·实时通信