(function () { 'use strict'; /* ============================================================ 常量 ============================================================ */ const NS = 'http://www.w3.org/2000/svg'; const FONT = 'ui-monospace, Consolas, "Cascadia Mono", "Microsoft YaHei", monospace'; const LAST_TOPO_KEY = 'route-topo-last'; const NODE_STYLE = { net: { minW:152, h:48, r:12, fs:14, fill:'#eef4ff', stroke:'#3b6fd4' }, host: { minW:118, h:42, r:11, fs:13, fill:'#e9fbf5', stroke:'#0d9488' }, domain: { minW:128, h:42, r:11, fs:13, fill:'#f4efff', stroke:'#7c3aed' }, other: { minW:118, h:42, r:11, fs:13, fill:'#f1f5f9', stroke:'#64748b' } }; const STATUSES = { unknown: { label:'未验证', color:'#94a3b8' }, confirmed: { label:'已确认', color:'#10b981' }, pivot: { label:'跳板', color:'#f59e0b' }, owned: { label:'已控制', color:'#ef4444' }, blocked: { label:'不可达', color:'#64748b' } }; const TYPES = ['net','host','domain','other']; /* ============================================================ 运行时状态 ============================================================ */ let state = { nodes: [], edges: [], view: { tx: 0, ty: 0, s: 1 } }; let selectedId = null; let drag = null; // {type:'node'|'pan'|'link', ...} let history = []; // 撤销栈(快照字符串) let fieldSnapshot = null; /* 登录 / 存储相关 */ let currentUser = null; // {id,username,role,...} 或 null let topoId = null; // 当前打开的拓扑 ID let topoName = ''; // 当前拓扑名称 let topoCanEdit = false; // 当前拓扑是否可写 let topoPermission = 'owner'; // 当前用户对当前拓扑的权限:owner|edit|view let readOnly = false; // 登录但无写权限(他人公开拓扑) let topoScope = 'mine'; // 我的拓扑面板范围:'mine'(当前用户) | 'public'(公开) let dirty = false; let saving = false; let loading = false; // 载入数据时抑制脏标记 let saveTimer = null; let lastSavedAt = null; let loginMode = 'login'; // 'login' | 'register' let siteAllowRegistration = true; // 站点是否开放注册(由后端设置控制) /* 协同:版本号 / 基线数据 / 连接态 */ let baseRev = 0; // 已同步的服务端版本号 let baseData = null; // 最近一次同步的基线数据 {nodes,edges} let collabStarted = false; const collab = { es: null, pollTimer: null, errCount: 0, present: [], topoId: null }; /* ============================================================ DOM ============================================================ */ const svg = document.getElementById('svg'); const viewport = document.getElementById('viewport'); const statsEl = document.getElementById('stats'); const inspEmpty= document.getElementById('inspEmpty'); const inspBody = document.getElementById('inspBody'); const inspMeta = document.getElementById('inspMeta'); const fLabel = document.getElementById('fLabel'); const fType = document.getElementById('fType'); const fStatus = document.getElementById('fStatus'); const fPorts = document.getElementById('fPorts'); const fNote = document.getElementById('fNote'); const fileInput= document.getElementById('fileInput'); const btnUndo = document.getElementById('btnUndo'); const saveStateEl = document.getElementById('saveState'); const bannerEl = document.getElementById('banner'); const btnSaveEl= document.getElementById('btnSave'); const btnUserEl= document.getElementById('btnUser'); const btnMyToposEl = document.getElementById('btnMyTopos'); /* ============================================================ 基础工具 ============================================================ */ function el(tag, attrs, children) { const e = document.createElementNS(NS, tag); if (attrs) { for (const k in attrs) { const v = attrs[k]; if (v === null || v === undefined || v === false) continue; e.setAttribute(k, v); } } if (children !== undefined && children !== null) { (Array.isArray(children) ? children : [children]).forEach(c => { if (c === null || c === undefined || c === false) return; e.append(typeof c === 'object' ? c : document.createTextNode(String(c))); }); } return e; } function uid() { return 'n' + Math.random().toString(36).slice(2, 9); } function getNode(id) { return state.nodes.find(n => n.id === id) || null; } function esc(s) { return String(s == null ? '' : s) .replace(/&/g,'&').replace(//g,'>') .replace(/"/g,'"').replace(/'/g,'''); } function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } function ts() { const d = new Date(), p = n => String(n).padStart(2,'0'); return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } function fmtTime(iso) { if (!iso) return '—'; const d = new Date(iso); if (isNaN(d.getTime())) return '—'; const p = n => String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } /* 稳定序列化(忽略键顺序,用于并发合并的相等比较) */ function stableStr(o) { if (o === null || o === undefined || typeof o !== 'object') { return JSON.stringify(o === undefined ? null : o); } if (Array.isArray(o)) { return '[' + o.map(stableStr).join(',') + ']'; } return '{' + Object.keys(o).sort().map(function (k) { return JSON.stringify(k) + ':' + stableStr(o[k]); }).join(',') + '}'; } /* 当前画布数据快照(深拷贝) */ function snapData() { return { nodes: JSON.parse(JSON.stringify(state.nodes)), edges: JSON.parse(JSON.stringify(state.edges)) }; } function byId(arr) { const m = new Map(); (arr || []).forEach(function (x) { if (x && x.id !== undefined && x.id !== null) m.set(String(x.id), x); }); return m; } /* 按 id 合并单个列表:base 基线 / local 本地 / remote 远端 */ function mergeList(baseArr, localArr, remoteArr) { const b = byId(baseArr), r = byId(remoteArr); const out = []; const used = new Set(); let conflicts = 0; (localArr || []).forEach(function (item) { const id = String(item.id); used.add(id); if (!b.has(id)) { out.push(item); return; } // 本地新增 const bv = b.get(id); const lChanged = stableStr(item) !== stableStr(bv); if (!r.has(id)) { if (lChanged) { out.push(item); conflicts++; } // 本地改 / 远端删 → 保留本地 return; // 否则删除 } const rv = r.get(id); const rChanged = stableStr(rv) !== stableStr(bv); if (rChanged && !lChanged) { out.push(rv); return; } // 仅远端改 → 取远端 out.push(item); // 本地改或未改 → 本地优先 if (rChanged && lChanged && stableStr(item) !== stableStr(rv)) { conflicts++; } }); (remoteArr || []).forEach(function (item) { const id = String(item.id); if (used.has(id) || b.has(id)) return; // 已处理 / 本地已删 (尊重本地删除) out.push(item); // 远端新增 }); return { items: out, conflicts: conflicts }; } function mergeThreeWay(base, local, remote) { const nodes = mergeList(base && base.nodes, local.nodes, remote.nodes); const edges = mergeList(base && base.edges, local.edges, remote.edges); return { nodes: nodes.items, edges: edges.items, conflicts: nodes.conflicts + edges.conflicts }; } const _mc = document.createElement('canvas').getContext('2d'); function textWidth(text, font) { _mc.font = font; return _mc.measureText(String(text || ' ')).width; } /* 编辑权限:游客可在浏览器内临时编辑;登录后按拓扑归属判定 */ function canEdit() { return currentUser ? topoCanEdit : true; } function canSave() { return !!currentUser && !!topoId && topoCanEdit; } /* ============================================================ 尺寸 / 几何 ============================================================ */ function nodeMetrics(n) { const isChild = !!n.parentId; const st = NODE_STYLE[n.type] || NODE_STYLE.other; const fs = isChild ? 11.5 : st.fs; const font = `600 ${fs}px ${FONT}`; const tw = textWidth(n.label || ' ', font); const leftPad = isChild ? 10 : 14; const gap = isChild ? 8 : 11; const rightPad = isChild ? 14 : 18; const w = Math.max(isChild ? 96 : st.minW, leftPad + gap + tw + rightPad); const h = isChild ? 30 : st.h; return { w, h, r: isChild ? 7 : st.r, fs, leftPad, gap, rightPad, tw }; } /** 从矩形中心向 (dx,dy) 方向射出,求与矩形边界的交点 */ function rayToRect(cx, cy, w, h, dx, dy) { const hw = w / 2 + 3, hh = h / 2 + 3; const ax = Math.abs(dx), ay = Math.abs(dy); let k = Infinity; if (ax > 1e-6) k = Math.min(k, hw / ax); if (ay > 1e-6) k = Math.min(k, hh / ay); if (!isFinite(k)) k = 0; return { x: cx + dx * k, y: cy + dy * k }; } /** 计算两节点之间的连线路径与中点。 * p1、p2 均落在「两节点中心的连线」上,因此起点指向 p2 的方向 * 即为指向目标节点中心的方向;末端预留一小段沿该方向的直线段, * 让 marker 箭头(orient=auto)能准确指向目标节点,而非随弧线切线偏移。 */ function edgeCurve(a, b) { const ma = nodeMetrics(a), mb = nodeMetrics(b); const dx = b.x - a.x, dy = b.y - a.y; const p1 = rayToRect(a.x, a.y, ma.w, ma.h, dx, dy); const p2 = rayToRect(b.x, b.y, mb.w, mb.h, -dx, -dy); const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1; const ux = (p2.x - p1.x) / dist, uy = (p2.y - p1.y) / dist; /* 指向目标节点中心 */ const nx = -uy, ny = ux; /* 法向,用于弯曲弧线 */ const bow = Math.min(dist * 0.16, 52); const head = Math.min(14, dist * 0.35); /* 末端直线段长度(箭头方向段) */ const q2 = { x: p2.x - ux * head, y: p2.y - uy * head }; const cx = (p1.x + q2.x) / 2 + nx * bow, cy = (p1.y + q2.y) / 2 + ny * bow; return { d: `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} Q ${cx.toFixed(2)} ${cy.toFixed(2)} ${q2.x.toFixed(2)} ${q2.y.toFixed(2)} L ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`, mid: { x: (p1.x + 2*cx + q2.x) / 4, y: (p1.y + 2*cy + q2.y) / 4 } }; } /* ============================================================ 渲染 ============================================================ */ function render() { const v = state.view; viewport.setAttribute('transform', `translate(${v.tx.toFixed(2)},${v.ty.toFixed(2)}) scale(${v.s.toFixed(4)})`); viewport.textContent = ''; const gEdges = el('g', { class: 'layer-edges' }); const gNodes = el('g', { class: 'layer-nodes' }); /* ---- 1. 父子虚线(归属关系) ---- */ state.nodes.forEach(c => { if (!c.parentId) return; const p = getNode(c.parentId); if (!p) return; const mp = nodeMetrics(p), mc = nodeMetrics(c); const dx = c.x - p.x, dy = c.y - p.y; const a = rayToRect(p.x, p.y, mp.w, mp.h, dx, dy); const b = rayToRect(c.x, c.y, mc.w, mc.h, -dx, -dy); gEdges.append(el('path', { d: `M ${a.x.toFixed(2)} ${a.y.toFixed(2)} L ${b.x.toFixed(2)} ${b.y.toFixed(2)}`, stroke: '#cbd5e1', 'stroke-width': 1.3, 'stroke-dasharray': '4 4', fill: 'none' })); }); /* ---- 2. 有向连线 ---- */ state.edges.forEach(edge => { const a = getNode(edge.from), b = getNode(edge.to); if (!a || !b) return; const { d, mid } = edgeCurve(a, b); const g = el('g', { class: 'edge', 'data-id': edge.id }); g.append(el('path', { class:'edge-hit', d, fill:'none', stroke:'transparent', 'stroke-width':16 })); g.append(el('path', { class: 'edge-line', d, fill: 'none', stroke: '#94a3b8', 'stroke-width': 1.8, 'marker-end': 'url(#arrow)' })); const del = el('g', { class: 'edge-del', 'data-id': edge.id, transform: `translate(${mid.x.toFixed(2)},${mid.y.toFixed(2)})` }); del.append(el('circle', { r: 9, fill: '#ef4444', stroke: '#ffffff', 'stroke-width': 1.5 })); del.append(el('path', { d: 'M -3.1 -3.1 L 3.1 3.1 M 3.1 -3.1 L -3.1 3.1', stroke: '#fff', 'stroke-width': 1.8, 'stroke-linecap': 'round' })); g.append(del); gEdges.append(g); }); /* ---- 3. 连线拖拽中的临时线 ---- */ if (drag && drag.type === 'link') { const a = getNode(drag.from); if (a) { const m = nodeMetrics(a); const dx = drag.cursor.x - a.x, dy = drag.cursor.y - a.y; const p1 = rayToRect(a.x, a.y, m.w, m.h, dx, dy); let p2 = drag.cursor; const tgt = drag.target ? getNode(drag.target) : null; if (tgt && tgt.id !== a.id) { const mt = nodeMetrics(tgt); p2 = rayToRect(tgt.x, tgt.y, mt.w, mt.h, a.x - tgt.x, a.y - tgt.y); } gEdges.append(el('path', { d: `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} L ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`, stroke: tgt ? '#10b981' : '#3b82f6', 'stroke-width': 2, 'stroke-dasharray': '6 4', fill: 'none', 'stroke-linecap': 'round' })); } } /* ---- 4. 节点 ---- */ state.nodes.forEach(n => gNodes.append(buildNode(n))); viewport.append(gEdges, gNodes); updateStats(); } function buildNode(n) { const m = nodeMetrics(n); const st = NODE_STYLE[n.type] || NODE_STYLE.other; const isChild = !!n.parentId; const status = STATUSES[n.status] || STATUSES.unknown; const sel = n.id === selectedId; const isTarget= !!(drag && drag.type === 'link' && drag.target === n.id && drag.from !== n.id); const g = el('g', { class: 'node' + (isChild ? ' child' : '') + (sel ? ' selected' : ''), 'data-id': n.id, transform: `translate(${n.x.toFixed(2)},${n.y.toFixed(2)})` }); const strokeColor = isTarget ? '#10b981' : (sel ? '#2563eb' : st.stroke); /* 投影 */ g.append(el('rect', { x: -m.w/2, y: -m.h/2 + 3, width: m.w, height: m.h, rx: m.r, ry: m.r, fill: 'rgba(15,23,42,.12)' })); /* 主体 */ g.append(el('rect', { x: -m.w/2, y: -m.h/2, width: m.w, height: m.h, rx: m.r, ry: m.r, fill: st.fill, stroke: strokeColor, 'stroke-width': (sel || isTarget) ? 2 : 1.3 })); /* 左侧状态色条 */ g.append(el('rect', { x: -m.w/2 + 1.5, y: -m.h/2 + (m.h - Math.min(m.h - 14, 26)) / 2, width: 3, height: Math.min(m.h - 14, 26), rx: 1.5, fill: status.color, opacity: .95 })); /* 标签文本 */ g.append(el('text', { x: (-m.w/2 + m.leftPad + m.gap).toFixed(2), y: 0.5, 'dominant-baseline': 'central', 'font-family': FONT, 'font-size': m.fs, 'font-weight': 600, fill: isChild ? '#475569' : '#1e293b', 'pointer-events': 'none' }, n.label || '')); /* 端口小提示 */ if (n.ports) { g.append(el('text', { x: (m.w/2 - m.rightPad + 4).toFixed(2), y: 0.5, 'text-anchor': 'end', 'dominant-baseline': 'central', 'font-family': FONT, 'font-size': 10, fill: '#94a3b8', 'pointer-events': 'none' }, '·')); } /* 连线手柄(右端):蓝色端口 + 白色箭头,提示「从这里拉线连接其它节点」 */ const linkH = el('g', { class: 'link-handle', 'data-id': n.id, transform: `translate(${(m.w/2).toFixed(2)},0)` }); linkH.append(el('circle', { r: 18, fill: 'transparent' })); /* 更大的触控热区 */ linkH.append(el('circle', { r: 7.5, fill: '#3b82f6', stroke: '#ffffff', 'stroke-width': 2 })); linkH.append(el('path', { d: 'M -1.9 -2.9 L 2.2 0 L -1.9 2.9 Z', fill: '#ffffff', 'pointer-events': 'none' })); g.append(linkH); /* 子节点手柄(底部):绿色端口 + 白色加号,提示「在这里新增子节点」 */ if (!isChild) { const addH = el('g', { class: 'add-handle', 'data-id': n.id, transform: `translate(0,${(m.h/2).toFixed(2)})` }); addH.append(el('circle', { r: 17, fill: 'transparent' })); /* 更大的触控热区 */ addH.append(el('circle', { r: 7.5, fill: '#10b981', stroke: '#ffffff', 'stroke-width': 2 })); addH.append(el('path', { d: 'M -3 0 L 3 0 M 0 -3 L 0 3', stroke: '#ffffff', 'stroke-width': 2, 'stroke-linecap': 'round', fill: 'none', 'pointer-events': 'none' })); g.append(addH); } return g; } function updateStats() { statsEl.textContent = `节点 ${state.nodes.length} · 连线 ${state.edges.length} · 缩放 ${Math.round(state.view.s * 100)}%`; } /* ============================================================ 历史记录 ============================================================ */ function snapshot() { return JSON.stringify({ nodes: state.nodes, edges: state.edges }); } function pushHistory(snap) { if (!snap) return; if (history.length && history[history.length - 1] === snap) return; history.push(snap); if (history.length > 100) history.shift(); updateUndoBtn(); } function undo() { if (!canEdit()) return; const s = history.pop(); updateUndoBtn(); if (!s) return; try { const d = JSON.parse(s); state.nodes = d.nodes || []; state.edges = d.edges || []; } catch (e) { return; } if (selectedId && !getNode(selectedId)) selectedId = null; render(); syncInspector(); onDataChanged(); } function updateUndoBtn() { btnUndo.disabled = history.length === 0 || !canEdit(); } /* ============================================================ 坐标换算 ============================================================ */ function toWorld(e) { const r = svg.getBoundingClientRect(); return { x: (e.clientX - r.left - state.view.tx) / state.view.s, y: (e.clientY - r.top - state.view.ty) / state.view.s }; } /* ============================================================ 节点 / 连线 操作 ============================================================ */ function createNode(opts) { opts = opts || {}; const n = { id: uid(), label: opts.label || '新节点', type: TYPES.includes(opts.type) ? opts.type : 'host', x: Number.isFinite(opts.x) ? opts.x : 0, y: Number.isFinite(opts.y) ? opts.y : 0, parentId: opts.parentId || null, status: STATUSES[opts.status] ? opts.status : 'unknown', ports: opts.ports || '', note: opts.note || '' }; state.nodes.push(n); return n; } function selectNode(id) { if (selectedId === id) return; selectedId = id; syncInspector(); render(); } function addNodeAtCenter() { if (!canEdit()) return; const r = svg.getBoundingClientRect(); const wx = (r.width / 2 - state.view.tx) / state.view.s; const wy = (r.height / 2 - state.view.ty) / state.view.s; pushHistory(snapshot()); const n = createNode({ x: wx, y: wy, label: '新节点', type: 'host' }); selectedId = n.id; render(); syncInspector(); onDataChanged(); requestAnimationFrame(() => { fLabel.focus(); fLabel.select(); }); } function addChildNode(parentId) { if (!canEdit()) return; const p = getNode(parentId); if (!p) return; pushHistory(snapshot()); const pm = nodeMetrics(p); const siblings = state.nodes.filter(n => n.parentId === parentId).length; const c = createNode({ label: '10.0.0.' + (siblings + 1), type: 'host', x: p.x, y: p.y + pm.h / 2 + 46, parentId: parentId }); selectedId = c.id; render(); syncInspector(); onDataChanged(); requestAnimationFrame(() => { fLabel.focus(); fLabel.select(); }); } function addEdge(from, to) { if (!canEdit()) return; if (!from || !to || from === to) return; if (state.edges.some(e => e.from === from && e.to === to)) return; pushHistory(snapshot()); state.edges.push({ id: uid(), from: from, to: to, label: '' }); onDataChanged(); } function removeEdge(id) { if (!canEdit()) return; const i = state.edges.findIndex(e => e.id === id); if (i < 0) return; pushHistory(snapshot()); state.edges.splice(i, 1); render(); onDataChanged(); } function removeNode(id) { if (!canEdit()) return; if (!getNode(id)) return; pushHistory(snapshot()); state.nodes = state.nodes.filter(n => n.id !== id && n.parentId !== id); state.edges = state.edges.filter(e => e.from !== id && e.to !== id); if (selectedId === id) selectedId = null; render(); syncInspector(); onDataChanged(); } function deleteSelected() { if (!selectedId) return; removeNode(selectedId); } /* ============================================================ 指针交互 ============================================================ */ svg.addEventListener('pointerdown', function (e) { if (e.button !== 0 && e.button !== 1) return; const t = e.target; /* --- 删除连线按钮 --- */ const delBtn = t.closest && t.closest('.edge-del'); if (delBtn) { e.preventDefault(); removeEdge(delBtn.getAttribute('data-id')); return; } /* --- 连线手柄 --- */ const lh = t.closest && t.closest('.link-handle'); if (lh) { if (!canEdit()) return; e.preventDefault(); const id = lh.getAttribute('data-id'); drag = { type: 'link', from: id, cursor: toWorld(e), target: null }; capture(e); render(); return; } /* --- 添加子节点手柄 --- */ const ah = t.closest && t.closest('.add-handle'); if (ah) { e.preventDefault(); addChildNode(ah.getAttribute('data-id')); return; } /* --- 节点本体 --- */ const ng = t.closest && t.closest('.node'); if (ng) { e.preventDefault(); const id = ng.getAttribute('data-id'); const n = getNode(id); if (!n) return; if (selectedId !== id) { selectedId = id; syncInspector(); } if (!canEdit()) { render(); return; } const pt = toWorld(e); const group = [n].concat(state.nodes.filter(c => c.parentId === id)); drag = { type: 'node', id: id, before: snapshot(), moved: false, items: group.map(x => ({ id: x.id, ox: x.x - pt.x, oy: x.y - pt.y })) }; capture(e); render(); return; } /* --- 空白:平移 --- */ e.preventDefault(); drag = { type: 'pan', startX: e.clientX, startY: e.clientY, tx: state.view.tx, ty: state.view.ty }; capture(e); }); function capture(e) { try { svg.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ } } svg.addEventListener('pointermove', function (e) { if (!drag) return; if (drag.type === 'node') { const pt = toWorld(e); let moved = false; drag.items.forEach(d => { const n = getNode(d.id); if (!n) return; const nx = pt.x + d.ox, ny = pt.y + d.oy; if (Math.abs(nx - n.x) > 0.01 || Math.abs(ny - n.y) > 0.01) moved = true; n.x = nx; n.y = ny; }); if (moved) drag.moved = true; render(); return; } if (drag.type === 'pan') { state.view.tx = drag.tx + (e.clientX - drag.startX); state.view.ty = drag.ty + (e.clientY - drag.startY); render(); return; } if (drag.type === 'link') { drag.cursor = toWorld(e); let hovered = null; const under = document.elementFromPoint(e.clientX, e.clientY); if (under && under.closest) { const ng = under.closest('.node'); if (ng) hovered = ng.getAttribute('data-id'); } drag.target = (hovered && hovered !== drag.from) ? hovered : null; render(); } }); function endDrag(e) { if (!drag) return; const d = drag; drag = null; if (d.type === 'link') { if (d.target) addEdge(d.from, d.target); render(); return; } if (d.type === 'node') { if (d.moved) { pushHistory(d.before); onDataChanged(); } render(); return; } if (d.type === 'pan') { const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY); if (dist < 4) { // 视作点击空白 selectedId = null; syncInspector(); } render(); } } svg.addEventListener('pointerup', endDrag); svg.addEventListener('pointercancel', function (e) { if (drag) { drag = null; render(); } }); /* --- 双击空白新建节点 --- */ svg.addEventListener('dblclick', function (e) { if (e.target.closest && e.target.closest('.node')) return; if (!canEdit()) return; const pt = toWorld(e); pushHistory(snapshot()); const n = createNode({ x: pt.x, y: pt.y, label: '新节点', type: 'host' }); selectedId = n.id; render(); syncInspector(); onDataChanged(); requestAnimationFrame(() => { fLabel.focus(); fLabel.select(); }); }); /* --- 滚轮缩放 --- */ svg.addEventListener('wheel', function (e) { e.preventDefault(); const r = svg.getBoundingClientRect(); const px = e.clientX - r.left; const py = e.clientY - r.top; const wx = (px - state.view.tx) / state.view.s; const wy = (py - state.view.ty) / state.view.s; const factor = Math.exp(-e.deltaY * 0.0016); const ns = clamp(state.view.s * factor, 0.15, 4); state.view.s = ns; state.view.tx = px - wx * ns; state.view.ty = py - wy * ns; render(); }, { passive: false }); /* ============================================================ 键盘 ============================================================ */ document.addEventListener('keydown', function (e) { const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); return; } if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedId) { e.preventDefault(); deleteSelected(); } return; } if (e.key === 'Escape') { selectedId = null; syncInspector(); render(); } }); /* ============================================================ 检查器 ============================================================ */ (function initStatusSelect() { fStatus.innerHTML = Object.keys(STATUSES) .map(k => ``).join(''); })(); (function initLegend() { document.getElementById('legend').innerHTML = '
状态图例
' + Object.keys(STATUSES).map(k => `${STATUSES[k].label}` ).join('') + '
'; })(); function syncInspector() { const n = getNode(selectedId); if (!n) { inspEmpty.hidden = false; inspBody.hidden = true; return; } inspEmpty.hidden = true; inspBody.hidden = false; fLabel.value = n.label || ''; fType.value = n.type || 'host'; fStatus.value = STATUSES[n.status] ? n.status : 'unknown'; fPorts.value = n.ports || ''; fNote.value = n.note || ''; const ins = state.edges.filter(e => e.to === n.id) .map(e => getNode(e.from)).filter(Boolean); const outs = state.edges.filter(e => e.from === n.id) .map(e => getNode(e.to)).filter(Boolean); const parent = n.parentId ? getNode(n.parentId) : null; inspMeta.innerHTML = `
上游${ins.length ? ins.map(x => esc(x.label)).join('、') : '—'}
` + `
下游${outs.length ? outs.map(x => esc(x.label)).join('、') : '—'}
` + (parent ? `
所属${esc(parent.label)}
` : ''); updateEditability(); } fLabel.addEventListener('input', function () { const n = getNode(selectedId); if (!n) return; n.label = fLabel.value; render(); onDataChanged(); }); fType.addEventListener('change', function () { const n = getNode(selectedId); if (!n) return; n.type = fType.value; render(); syncInspector(); onDataChanged(); }); fStatus.addEventListener('change', function () { const n = getNode(selectedId); if (!n) return; n.status = fStatus.value; render(); onDataChanged(); }); fPorts.addEventListener('input', function () { const n = getNode(selectedId); if (!n) return; n.ports = fPorts.value; render(); onDataChanged(); }); fNote.addEventListener('input', function () { const n = getNode(selectedId); if (!n) return; n.note = fNote.value; onDataChanged(); }); [fLabel, fType, fStatus, fPorts, fNote].forEach(inp => { inp.addEventListener('focus', () => { fieldSnapshot = snapshot(); }); inp.addEventListener('blur', () => { if (fieldSnapshot && fieldSnapshot !== snapshot()) pushHistory(fieldSnapshot); fieldSnapshot = null; }); }); document.getElementById('btnAddChild').addEventListener('click', function () { if (selectedId) addChildNode(selectedId); }); document.getElementById('btnDelete').addEventListener('click', function () { deleteSelected(); }); /* 根据权限启用/禁用编辑相关控件 */ function updateEditability() { const ok = canEdit(); ['btnUndo','btnAuto','btnClear','btnAddChild','btnDelete'].forEach(id => { const b = document.getElementById(id); if (b) b.disabled = !ok; }); const undoBtn = document.getElementById('btnUndo'); if (undoBtn) undoBtn.disabled = !ok || history.length === 0; [fLabel, fType, fStatus, fPorts, fNote].forEach(i => { if (i) i.disabled = !ok; }); } /* ============================================================ 工具栏 ============================================================ */ btnUndo.addEventListener('click', undo); document.getElementById('btnFit').addEventListener('click', fitView); document.getElementById('btnAuto').addEventListener('click', function () { autoLayout(); }); document.getElementById('btnClear').addEventListener('click', function () { if (!canEdit()) return; if (!state.nodes.length) return; uiConfirm('确定清空所有节点与连线?此操作可撤销。', { title: '清空画布', okText: '清空', danger: true }) .then(function (ok) { if (!ok) return; pushHistory(snapshot()); state.nodes = []; state.edges = []; selectedId = null; render(); syncInspector(); onDataChanged(); }); }); /* ============================================================ 视图适配 / 自动整理 ============================================================ */ function contentBounds() { if (!state.nodes.length) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; state.nodes.forEach(n => { const m = nodeMetrics(n); minX = Math.min(minX, n.x - m.w / 2); maxX = Math.max(maxX, n.x + m.w / 2); minY = Math.min(minY, n.y - m.h / 2); maxY = Math.max(maxY, n.y + m.h / 2); }); return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; } function fitView() { const b = contentBounds(); if (!b) { state.view = { tx: 0, ty: 0, s: 1 }; render(); return; } const r = svg.getBoundingClientRect(); const pad = 80; const w = b.w + pad * 2, h = b.h + pad * 2; const s = clamp(Math.min(r.width / w, r.height / h), 0.15, 1.6); state.view.s = s; state.view.tx = r.width / 2 - (b.minX + b.maxX) / 2 * s; state.view.ty = r.height / 2 - (b.minY + b.maxY) / 2 * s; render(); } function autoLayout() { if (!canEdit()) return; if (!state.nodes.length) return; pushHistory(snapshot()); const tops = state.nodes.filter(n => !n.parentId); if (!tops.length) return; const indeg = new Map(); tops.forEach(n => indeg.set(n.id, 0)); state.edges.forEach(e => { if (indeg.has(e.to)) indeg.set(e.to, indeg.get(e.to) + 1); }); let queue = tops.filter(n => indeg.get(n.id) === 0).map(n => n.id); if (!queue.length) queue = [tops[0].id]; const depth = new Map(); const seen = new Set(); queue.forEach(id => { depth.set(id, 0); seen.add(id); }); let guard = 0; while (queue.length && guard++ < 5000) { const id = queue.shift(); const d = depth.get(id) || 0; state.edges.filter(e => e.from === id).forEach(e => { if (!indeg.has(e.to)) return; const cur = depth.has(e.to) ? depth.get(e.to) : -1; if (d + 1 > cur) depth.set(e.to, d + 1); if (!seen.has(e.to)) { seen.add(e.to); queue.push(e.to); } }); } tops.forEach(n => { if (!depth.has(n.id)) depth.set(n.id, 0); }); const layers = new Map(); tops.forEach(n => { const d = depth.get(n.id) || 0; if (!layers.has(d)) layers.set(d, []); layers.get(d).push(n); }); const COL = 230, ROW = 118; const keys = Array.from(layers.keys()).sort((a, b) => a - b); let maxRowW = 0; keys.forEach(d => { const arr = layers.get(d); arr.sort((a, b) => (a.x - b.x) || (a.label > b.label ? 1 : -1)); maxRowW = Math.max(maxRowW, arr.length * COL); }); keys.forEach(d => { const arr = layers.get(d); const rowW = arr.length * COL; const startX = -rowW / 2 + COL / 2 + maxRowW / 2; arr.forEach((n, i) => { n.x = startX + i * COL; n.y = 120 + d * ROW; }); }); /* 子节点跟随父节点重排 */ tops.forEach(p => { const pm = nodeMetrics(p); const kids = state.nodes.filter(c => c.parentId === p.id); kids.forEach((c, i) => { c.x = p.x + (i - (kids.length - 1) / 2) * 150; c.y = p.y + pm.h / 2 + 50; }); }); render(); onDataChanged(); requestAnimationFrame(fitView); } /* ============================================================ 变更标记 / 保存 ============================================================ */ function onDataChanged() { if (loading) return; dirty = true; updateSaveState(); updateBanner(); scheduleSave(); } function updateSaveState() { updateTopoCurrent(); if (!saveStateEl) return; saveStateEl.className = 'save-state'; if (!currentUser) { saveStateEl.textContent = ''; return; } if (readOnly) { saveStateEl.textContent = '只读'; return; } if (saving) { saveStateEl.textContent = '保存中…'; saveStateEl.classList.add('dirty'); return; } if (!topoId) { saveStateEl.textContent = '未保存'; saveStateEl.classList.add('dirty'); return; } if (dirty) { saveStateEl.textContent = '未保存'; saveStateEl.classList.add('dirty'); return; } saveStateEl.textContent = '已保存'; saveStateEl.classList.add('ok'); } /* 右下角状态区:显示当前正在编辑的拓扑名称与脏标记 */ function updateTopoCurrent() { const box = document.getElementById('topoCurrent'); if (!box) return; const nameEl = box.querySelector('.tc-name'); let text; if (!currentUser) text = '未登录'; else if (topoName) text = topoName; else text = '未保存的新拓扑'; if (nameEl) { nameEl.textContent = text; nameEl.title = text; } box.classList.toggle('dirty', !!dirty); } function scheduleSave() { // 仅在已绑定拓扑时自动保存;未绑定拓扑需手动点「保存」触发新建 if (!canSave() || !topoId) return; clearTimeout(saveTimer); saveTimer = setTimeout(function () { saveNow(true); }, 1200); } function defaultTopoName() { const d = new Date(), p = n => String(n).padStart(2, '0'); return '拓扑 ' + d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } function saveNow(silent) { if (!currentUser || readOnly || saving) return Promise.resolve(); saving = true; updateSaveState(); let p; if (topoId && topoCanEdit) { p = API.saveTopo(topoId, { nodes: state.nodes, edges: state.edges, name: topoName, baseRev: baseRev }); } else { const name = (topoName && topoName.trim()) ? topoName.trim() : defaultTopoName(); p = API.createTopo({ name: name, nodes: state.nodes, edges: state.edges }); } return p.then(function (r) { if (r && r.topology) { topoId = r.topology.id; topoName = r.topology.name; topoCanEdit = true; readOnly = false; topoPermission = 'owner'; lastSavedAt = r.topology.updatedAt; if (typeof r.topology.rev === 'number') baseRev = r.topology.rev; try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {} } baseData = snapData(); dirty = false; if (!silent) toast('已保存:' + topoName, 'ok'); updateShareEntry(); startCollab(); }).catch(function (e) { if (e && e.conflict) { handleConflict(e); return; } saveStateEl.textContent = '保存失败'; saveStateEl.className = 'save-state err'; if (!silent) toastErr('保存失败:' + e.message); }).then(function () { saving = false; updateSaveState(); updateEditability(); updateBanner(); }); } /* ============================================================ 协同:SSE 推送 + 在线成员 + 三方合并 ============================================================ */ const COLLAB_MAX_ES_ERRORS = 2; function collabActive() { return !!currentUser && !!topoId; } function startCollab() { if (!collabActive()) { renderPresence([]); return; } if (collabStarted && collab.topoId === topoId) return; stopCollab(); collab.topoId = topoId; collabStarted = true; collab.errCount = 0; connectStream(); } function stopCollab() { collabStarted = false; collab.topoId = null; if (collab.es) { try { collab.es.close(); } catch (e) {} collab.es = null; } if (collab.pollTimer) { clearInterval(collab.pollTimer); collab.pollTimer = null; } renderPresence([]); } function connectStream() { if (!collabActive()) return; if (typeof window.EventSource !== 'function') { startPolling(); return; } if (collab.es) { try { collab.es.close(); } catch (e) {} collab.es = null; } if (collab.pollTimer) { clearInterval(collab.pollTimer); collab.pollTimer = null; } const url = 'index.php?r=' + encodeURIComponent('/api/topologies/' + encodeURIComponent(topoId) + '/stream') + '&since=' + baseRev; let es; try { es = new EventSource(url); } catch (e) { startPolling(); return; } collab.es = es; es.addEventListener('revision', function (ev) { let d = {}; try { d = JSON.parse(ev.data); } catch (e) {} onRemoteRevision(d.rev); }); es.addEventListener('presence', function (ev) { let d = {}; try { d = JSON.parse(ev.data); } catch (e) {} renderPresence(d.users || []); }); es.addEventListener('gone', function () { if (collab.es === es) stopCollab(); }); es.addEventListener('bye', function () { if (collab.es === es) { try { es.close(); } catch (e) {} collab.es = null; } setTimeout(function () { if (collabStarted) connectStream(); }, 300); }); es.onerror = function () { if (collab.es !== es) return; // 已被 bye / stopCollab 接管,避免重复连接 if (es.readyState === 2) { // CLOSED:无法建立连接,降级轮询 collab.es = null; collab.errCount++; if (collab.errCount >= COLLAB_MAX_ES_ERRORS) { startPolling(); } else { setTimeout(function () { if (collabStarted) connectStream(); }, 1200); } } }; } function startPolling() { if (!collabActive()) return; if (collab.es) { try { collab.es.close(); } catch (e) {} collab.es = null; } if (collab.pollTimer) clearInterval(collab.pollTimer); const tick = function () { if (!collabActive()) return; API.req('GET', '/topologies/' + encodeURIComponent(topoId) + '/poll?since=' + baseRev).then(function (d) { if (d.presence) renderPresence(d.presence); if (d.changed && typeof d.rev === 'number' && d.rev !== baseRev) onRemoteRevision(d.rev); }).catch(function () {}); }; tick(); collab.pollTimer = setInterval(tick, 4000); } function onRemoteRevision(rev) { if (!collabActive() || typeof rev !== 'number') return; if (rev === baseRev) return; if (!topoCanEdit || readOnly) { reloadRemote(); return; } if (dirty) { // 本地有未保存改动:先保存,由版本冲突触发合并,避免覆盖他人 saveNow(true); } else { reloadRemote(); } } function reloadRemote() { if (!topoId) return Promise.resolve(); return API.getTopo(topoId).then(function (r) { if (!r || !r.topology) return; loading = true; topoName = r.topology.name; topoCanEdit = !!r.topology.canEdit; readOnly = !topoCanEdit; topoPermission = r.topology.permission || (topoCanEdit ? 'edit' : 'view'); if (typeof r.topology.rev === 'number') baseRev = r.topology.rev; loadData(r.data, false); loading = false; baseData = snapData(); history = []; dirty = false; updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry(); toast('已同步其他协作者的改动', 'ok'); }).catch(function () {}); } function handleConflict(e) { if (!topoCanEdit) return; const d = e.data || {}; const remoteData = d.data || { nodes: [], edges: [] }; const remoteRev = (typeof d.rev === 'number') ? d.rev : (baseRev + 1); const merged = mergeThreeWay(baseData, { nodes: state.nodes, edges: state.edges }, remoteData); loading = true; state.nodes = merged.nodes; state.edges = merged.edges; loading = false; baseRev = remoteRev; baseData = { nodes: remoteData.nodes || [], edges: remoteData.edges || [] }; render(); syncInspector(); dirty = true; updateSaveState(); if (merged.conflicts > 0) { toast('检测到 ' + merged.conflicts + ' 处并发修改,已自动合并并重新保存', 'warn'); } else { toast('已合并其他协作者的改动', 'ok'); } setTimeout(function () { saveNow(true); }, 80); } function renderPresence(users) { collab.present = users || []; const bar = document.getElementById('collabBar'); if (!bar) return; if (!users || !users.length) { bar.hidden = true; bar.innerHTML = ''; return; } bar.hidden = false; const chips = users.slice(0, 6).map(function (u) { const initial = esc((u.username || '?').slice(0, 1).toUpperCase()); return '' + '' + initial + '' + esc(u.username) + (u.self ? '(我)' : '') + ''; }).join(''); const more = users.length > 6 ? '+' + (users.length - 6) + '' : ''; bar.innerHTML = '' + users.length + ' 人在线' + chips + more; } function sendPresenceLeave() { if (!currentUser || !topoId) return; const url = 'index.php?r=' + encodeURIComponent('/api/topologies/' + encodeURIComponent(topoId) + '/presence'); const body = JSON.stringify({ leave: true }); try { if (navigator.sendBeacon) { navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); return; } } catch (e) {} try { fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }); } catch (e) {} } /* ============================================================ 共享与协作弹窗 ============================================================ */ let shareCandidates = { users: [], groups: [] }; let shareSelected = {}; function updateShareEntry() { const b = document.getElementById('btnShare'); if (!b) return; b.hidden = !(currentUser && topoId && topoPermission === 'owner'); } function shareRow(key, name, sel) { return ''; } function renderShareLists() { const uf = (document.getElementById('shareUserFilter').value || '').trim().toLowerCase(); const gf = (document.getElementById('shareGroupFilter').value || '').trim().toLowerCase(); const myId = currentUser ? currentUser.id : ''; const ul = document.getElementById('shareUserList'); const gl = document.getElementById('shareGroupList'); const users = shareCandidates.users.filter(function (u) { if (u.id === myId) return false; return !uf || (u.username || '').toLowerCase().indexOf(uf) >= 0; }); ul.innerHTML = users.length ? users.map(function (u) { return shareRow('user:' + u.id, u.username, shareSelected['user:' + u.id]); }).join('') : '
无匹配成员
'; const groups = shareCandidates.groups.filter(function (g) { return !gf || (g.name || '').toLowerCase().indexOf(gf) >= 0; }); gl.innerHTML = groups.length ? groups.map(function (g) { return shareRow('group:' + g.id, g.name, shareSelected['group:' + g.id]); }).join('') : '
暂无分组
'; } function openShareDialog() { if (!currentUser) { openLogin(); return; } if (!topoId) { toast('请先保存拓扑后再共享', 'warn'); return; } if (topoPermission !== 'owner') { toast('仅创建者可管理共享', 'warn'); return; } document.getElementById('shareErr').textContent = ''; document.getElementById('shareUserFilter').value = ''; document.getElementById('shareGroupFilter').value = ''; Promise.all([API.directory(), API.topoShares(topoId)]).then(function (res) { const dir = res[0] || {}; const cur = res[1] || {}; shareCandidates = { users: dir.users || [], groups: dir.groups || [] }; shareSelected = {}; (cur.shares || []).forEach(function (s) { shareSelected[s.targetType + ':' + s.targetId] = { type: s.targetType, id: s.targetId, permission: s.permission }; }); document.getElementById('shareMode').checked = !!cur.shareMode; document.getElementById('shareMsg').textContent = '开启共享模式后,选中的成员 / 分组可访问该拓扑,并可选择「可编辑」进行协同修改。'; renderShareLists(); openOverlay('shareOverlay'); }).catch(function (e) { toastErr(e.message); }); } function onShareListChange(e) { const t = e.target; const key = t.getAttribute && t.getAttribute('data-key'); if (!key) return; const parts = key.split(':'); if (t.classList.contains('share-chk')) { if (t.checked) { const sel = t.parentNode.querySelector('.share-perm'); shareSelected[key] = { type: parts[0], id: parts[1], permission: sel ? sel.value : 'edit' }; } else { delete shareSelected[key]; } } else if (t.classList.contains('share-perm')) { if (shareSelected[key]) { shareSelected[key].permission = t.value; } else { const chk = t.parentNode.querySelector('.share-chk'); if (chk) chk.checked = true; shareSelected[key] = { type: parts[0], id: parts[1], permission: t.value }; } } } function saveShare() { if (!topoId) return; const shareMode = document.getElementById('shareMode').checked; const shares = Object.keys(shareSelected).map(function (k) { const s = shareSelected[k]; return { targetType: s.type, targetId: s.id, permission: s.permission }; }); const btn = document.getElementById('btnSaveShare'); btn.disabled = true; API.setShares(topoId, { shareMode: shareMode, shares: shares }).then(function () { btn.disabled = false; closeOverlay('shareOverlay'); toast(shareMode ? ('已开启共享,共 ' + shares.length + ' 项') : '已关闭共享模式', 'ok'); }).catch(function (e) { btn.disabled = false; document.getElementById('shareErr').textContent = e.message; }); } /* ============================================================ 轻提示 ============================================================ */ let toastTimer = null; function toast(msg, type) { const t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.className = 'toast' + (type ? ' ' + type : ''); void t.offsetWidth; /* 强制重排,连续提示也有过渡 */ t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove('show'); }, 2600); } function toastErr(msg) { toast(msg, 'err'); } /* ============================================================ API 客户端 ============================================================ */ function qs(obj) { const parts = []; if (obj) { for (const k in obj) { const v = obj[k]; if (v === '' || v === null || v === undefined) continue; parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v)); } } return parts.length ? ('?' + parts.join('&')) : ''; } const API = { req: function (method, path, body) { const opt = { method: method, credentials: 'same-origin', headers: {} }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } return fetch('index.php?r=' + encodeURIComponent('/api' + path), opt).then(function (r) { return r.text().then(function (txt) { let data = null; try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = null; } if (!r.ok || (data && data.ok === false)) { const msg = (data && data.error) ? data.error : ('请求失败 (' + r.status + ')'); const err = new Error(msg); err.status = r.status; err.data = data; err.conflict = !!(data && data.conflict); throw err; } return data || {}; }); }); }, me: function () { return this.req('GET', '/me'); }, site: function () { return this.req('GET', '/site'); }, login: function (u, p) { return this.req('POST', '/login', { username: u, password: p }); }, register: function (u, p) { return this.req('POST', '/register', { username: u, password: p }); }, logout: function () { return this.req('POST', '/logout', {}); }, changePassword: function (o, n) { return this.req('POST', '/password', { oldPassword: o, newPassword: n }); }, listTopos: function () { return this.req('GET', '/topologies'); }, getTopo: function (id) { return this.req('GET', '/topologies/' + encodeURIComponent(id)); }, createTopo: function (payload) { return this.req('POST', '/topologies', payload); }, saveTopo: function (id, payload) { return this.req('PUT', '/topologies/' + encodeURIComponent(id), payload); }, deleteTopo: function (id) { return this.req('DELETE', '/topologies/' + encodeURIComponent(id)); }, setVisibility: function (id, vis) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/permission', { visibility: vis }); }, renameTopo: function (id, name) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/rename', { name: name }); }, listUsers: function (p) { return this.req('GET', '/users' + qs(p)); }, createUser: function (p) { return this.req('POST', '/users', p); }, updateUser: function (id, p) { return this.req('PUT', '/users/' + encodeURIComponent(id), p); }, deleteUser: function (id) { return this.req('DELETE', '/users/' + encodeURIComponent(id)); }, dashboard: function () { return this.req('GET', '/dashboard'); }, logs: function (p) { return this.req('GET', '/logs' + qs(p)); }, adminTopologies: function (p) { return this.req('GET', '/admin/topologies' + qs(p)); }, directory: function () { return this.req('GET', '/directory'); }, overview: function () { return this.req('GET', '/overview'); }, topoShares: function (id) { return this.req('GET', '/topologies/' + encodeURIComponent(id) + '/shares'); }, setShares: function (id, p) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/shares', p); } }; /* ============================================================ 弹窗工具 ============================================================ */ function openOverlay(id) { const o = document.getElementById(id); if (o) o.hidden = false; } function closeOverlay(id) { const o = document.getElementById(id); if (o) o.hidden = true; } /* 站内确认 / 输入弹窗(替代原生 confirm / prompt) */ let _dlgResolve = null; let _dlgInput = false; function _dlgFinish(result) { closeOverlay('confirmOverlay'); const r = _dlgResolve; _dlgResolve = null; if (r) r(result); } function _dlgConfirmOk() { if (_dlgInput) { const v = document.getElementById('confirmField').value.trim(); if (!v) { document.getElementById('confirmErr').textContent = '请输入内容'; return; } _dlgFinish(v); } else { _dlgFinish(true); } } function _uiDialog(o) { return new Promise(function (resolve) { _dlgResolve = resolve; _dlgInput = !!o.input; document.getElementById('confirmTitle').textContent = o.title || '请确认'; const msg = document.getElementById('confirmMsg'); msg.textContent = o.message || ''; msg.hidden = !o.message; const okBtn = document.getElementById('confirmOk'); okBtn.textContent = o.okText || '确定'; okBtn.className = 'btn ' + (o.danger ? 'danger' : 'primary'); const wrap = document.getElementById('confirmFieldWrap'); const field = document.getElementById('confirmField'); if (o.input) { wrap.hidden = false; document.getElementById('confirmFieldLabel').textContent = o.input.label || ''; field.type = o.input.type || 'text'; field.placeholder = o.input.placeholder || ''; field.value = o.input.value || ''; } else { wrap.hidden = true; } document.getElementById('confirmErr').textContent = ''; openOverlay('confirmOverlay'); setTimeout(function () { if (o.input) { field.focus(); field.select(); } else { okBtn.focus(); } }, 30); }); } function uiConfirm(message, opts) { opts = opts || {}; return _uiDialog({ title: opts.title || '请确认', message: message, okText: opts.okText || '确定', danger: !!opts.danger }); } function uiPrompt(label, opts) { opts = opts || {}; return _uiDialog({ title: opts.title || '请输入', message: opts.message || '', okText: opts.okText || '确定', input: { label: label, placeholder: opts.placeholder || '', type: opts.type || 'text', value: opts.value || '' } }); } /* ============================================================ 登录 / 注册 ============================================================ */ function openLogin() { loginMode = 'login'; applyLoginMode(); document.getElementById('loginErr').textContent = ''; document.getElementById('loginPass').value = ''; document.getElementById('loginPass2').value = ''; const show = document.getElementById('loginShow'); if (show) show.checked = false; ['loginPass', 'loginPass2'].forEach(function (id) { const el = document.getElementById(id); if (el) el.type = 'password'; }); openOverlay('loginOverlay'); setTimeout(function () { document.getElementById('loginUser').focus(); }, 30); } function applyLoginMode() { const isReg = (loginMode !== 'login'); const title = document.getElementById('loginTitle'); const submit = document.getElementById('btnDoLogin'); const toggle = document.getElementById('btnRegister'); const tip = document.getElementById('loginTip'); const pass2Wrap = document.getElementById('loginPass2Wrap'); const pass2 = document.getElementById('loginPass2'); if (isReg) { title.textContent = '注册'; submit.textContent = '注册并登录'; toggle.textContent = '返回登录'; tip.textContent = '注册的账号为普通用户,可由管理员调整权限'; } else { title.textContent = '登录'; submit.textContent = '登录'; toggle.textContent = '注册新账号'; tip.textContent = '默认管理员 admin / admin123'; } if (pass2Wrap) pass2Wrap.hidden = !isReg; if (pass2 && !isReg) pass2.value = ''; } /* 应用站点设置(名称 / Logo / 注册开关) */ function applySite(site) { if (!site) return; if (site.name) { document.title = site.name; const h1 = document.getElementById('brandName'); if (h1) h1.textContent = site.name; } applyBrandLogo('brandLogo', site.logo); siteAllowRegistration = (site.allowRegistration !== false); const regBtn = document.getElementById('btnRegister'); if (regBtn) regBtn.hidden = !siteAllowRegistration; } /* 品牌 Logo:有图片时显示图片,否则回退为字母 R */ function applyBrandLogo(id, logo) { const el = document.getElementById(id); if (!el) return; if (logo) { el.classList.add('has-img'); el.innerHTML = ''; const img = document.createElement('img'); img.src = logo; img.alt = ''; el.appendChild(img); } else { el.classList.remove('has-img'); el.textContent = 'R'; } } function doLoginOrRegister() { const u = document.getElementById('loginUser').value.trim(); const p = document.getElementById('loginPass').value; const errEl = document.getElementById('loginErr'); errEl.textContent = ''; if (!u || !p) { errEl.textContent = '请输入用户名和密码'; return; } if (loginMode !== 'login') { const p2 = document.getElementById('loginPass2').value; if (p !== p2) { errEl.textContent = '两次输入的密码不一致'; return; } } const req = (loginMode === 'login') ? API.login(u, p) : API.register(u, p); req.then(function (r) { currentUser = r.user; closeOverlay('loginOverlay'); onUserChanged(); bootAfterLogin(); maybeForcePwdChange(); }).catch(function (e) { errEl.textContent = e.message; }); } function bootAfterLogin() { toast('已登录:' + currentUser.username, 'ok'); API.listTopos().then(function (r) { const list = r.topologies || []; let last = null; try { last = localStorage.getItem(LAST_TOPO_KEY); } catch (e) {} let target = null; if (last) { target = list.filter(function (t) { return t.id === last; })[0] || null; } if (!target && list.length) { target = list.slice().sort(function (a, b) { return (b.updatedAt || '').localeCompare(a.updatedAt || ''); })[0]; } if (target) { openTopology(target.id); } else { loadDraft(); } }).catch(function (e) { toastErr(e.message); loadDraft(); }); } function maybeForcePwdChange() { if (currentUser && currentUser.mustChangePassword) { openPwdOverlay(true); return true; } return false; } let pwdForced = false; function pwdStrengthError(pw, oldPw, username) { if (!pw || pw.length < 8) return '密码至少 8 位'; if (!/[A-Za-z]/.test(pw) || !/[0-9]/.test(pw)) return '密码必须同时包含字母和数字'; if (username && pw.toLowerCase() === String(username).toLowerCase()) return '密码不能与用户名相同'; if (oldPw && pw === oldPw) return '新密码不能与原密码相同'; return null; } function openPwdOverlay(forced) { pwdForced = !!forced; document.getElementById('pwdOld').value = ''; document.getElementById('pwdNew').value = ''; document.getElementById('pwdNew2').value = ''; const show = document.getElementById('pwdShow'); if (show) show.checked = false; ['pwdOld', 'pwdNew', 'pwdNew2'].forEach(function (id) { const el = document.getElementById(id); if (el) el.type = 'password'; }); document.getElementById('pwdErr').textContent = ''; document.getElementById('pwdTitle').textContent = forced ? '请设置新密码' : '修改密码'; document.getElementById('btnClosePwd').hidden = pwdForced; document.getElementById('pwdHintWrap').hidden = !pwdForced; openOverlay('pwdOverlay'); /* 强制改密时也先聚焦原密码,引导用户从填写原密码开始 */ setTimeout(function () { document.getElementById('pwdOld').focus(); }, 30); } /* ============================================================ 登录态 UI ============================================================ */ function onUserChanged() { if (currentUser) { btnUserEl.textContent = currentUser.username + (currentUser.role === 'admin' ? ' · 管理员' : ''); document.getElementById('ddName').textContent = currentUser.username + ' · ' + (currentUser.role === 'admin' ? '管理员' : '普通用户'); } else { btnUserEl.textContent = '登录'; stopCollab(); } document.getElementById('userMenu').hidden = true; updateAdminEntry(); updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry(); } function updateAdminEntry() { const menu = document.getElementById('userMenu'); let item = document.getElementById('ddAdmin'); const isAdmin = currentUser && currentUser.role === 'admin'; if (isAdmin) { if (!item) { item = document.createElement('div'); item.className = 'dd-item'; item.id = 'ddAdmin'; item.textContent = '管理员控制台'; item.addEventListener('click', function () { document.getElementById('userMenu').hidden = true; window.location.href = 'web/admin.html'; }); menu.insertBefore(item, document.getElementById('ddPwd')); } item.hidden = false; } else if (item) { item.hidden = true; } } function updateBanner() { if (!currentUser) { bannerEl.hidden = false; bannerEl.className = 'banner warn'; bannerEl.innerHTML = '未登录模式:当前为默认演示页,编辑内容不会保存,也无法访问已存数据。' + '立即登录 / 注册'; const l = document.getElementById('bannerLogin'); if (l) l.addEventListener('click', openLogin); return; } if (readOnly) { bannerEl.hidden = false; bannerEl.className = 'banner info'; bannerEl.innerHTML = '只读:这是「' + esc(topoName) + '」的公开拓扑(非本人创建),仅可查看,无法编辑。'; return; } bannerEl.hidden = true; bannerEl.innerHTML = ''; } /* ============================================================ 打开 / 新建拓扑 ============================================================ */ function openTopology(id) { return API.getTopo(id).then(function (r) { loading = true; topoId = r.topology.id; topoName = r.topology.name; topoCanEdit = !!r.topology.canEdit; readOnly = !topoCanEdit; topoPermission = r.topology.permission || (topoCanEdit ? 'edit' : 'view'); baseRev = (typeof r.topology.rev === 'number') ? r.topology.rev : 0; history = []; dirty = false; loadData(r.data, false); baseData = snapData(); loading = false; try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {} updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry(); closeOverlay('topoOverlay'); requestAnimationFrame(fitView); startCollab(); toast('已打开:' + topoName + (readOnly ? '(只读)' : ''), 'ok'); }).catch(function (e) { toastErr(e.message); }); } function loadDraft() { stopCollab(); loading = true; topoId = null; topoName = ''; topoCanEdit = true; readOnly = false; topoPermission = 'owner'; baseRev = 0; baseData = null; state.nodes = []; state.edges = []; state.view = { tx: 0, ty: 0, s: 1 }; selectedId = null; history = []; dirty = false; loadData(demoData(), false); loading = false; updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry(); requestAnimationFrame(fitView); } function startNewTopology() { stopCollab(); loading = true; topoId = null; topoName = ''; topoCanEdit = true; readOnly = false; topoPermission = 'owner'; baseRev = 0; baseData = null; state.nodes = []; state.edges = []; state.view = { tx: 0, ty: 0, s: 1 }; selectedId = null; history = []; dirty = false; loading = false; render(); syncInspector(); updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry(); } /* ============================================================ 我的拓扑面板 ============================================================ */ function openTopoPanel() { if (!currentUser) { openLogin(); return; } openOverlay('topoOverlay'); refreshTopoList(); } function itemRow(name, sub, badges, actions) { const item = document.createElement('div'); item.className = 'topo-item'; const meta = document.createElement('div'); meta.className = 'topo-meta'; const nm = document.createElement('div'); nm.className = 'topo-name'; nm.textContent = name; const sb = document.createElement('div'); sb.className = 'topo-sub'; sb.textContent = sub; meta.appendChild(nm); meta.appendChild(sb); item.appendChild(meta); (badges || []).forEach(function (b) { const sp = document.createElement('span'); sp.className = 'badge ' + b.cls; sp.textContent = b.text; item.appendChild(sp); }); const row = document.createElement('div'); row.className = 'row-mini'; (actions || []).forEach(function (a) { const btn = document.createElement('button'); btn.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : ''); btn.textContent = a.text; btn.addEventListener('click', a.fn); row.appendChild(btn); }); item.appendChild(row); return item; } function refreshTopoList() { const box = document.getElementById('topoList'); box.innerHTML = '
加载中…
'; API.listTopos().then(function (r) { const all = r.topologies || []; const uid = currentUser ? currentUser.id : ''; const list = (topoScope === 'shared') ? all.filter(function (t) { return t.sharedWithMe; }) : (topoScope === 'public') ? all.filter(function (t) { return t.visibility === 'public'; }) : all.filter(function (t) { return t.ownerId === uid; }); box.innerHTML = ''; if (!list.length) { box.innerHTML = '
' + (topoScope === 'shared' ? '暂无共享给你的拓扑。' : topoScope === 'public' ? '暂无公开拓扑。' : '还没有拓扑,输入名称点击「新建」开始。') + '
'; return; } list.forEach(function (t) { const badges = [{ cls: t.visibility === 'public' ? 'public' : 'private', text: t.visibility === 'public' ? '公开' : '私有' }]; if (t.sharedWithMe) { badges.push({ cls: 'owner', text: t.permission === 'edit' ? '共享·可编辑' : '共享·只读' }); } else if (!t.canEdit) { badges.push({ cls: 'ro', text: '只读' }); } const actions = [{ text: '打开', primary: true, fn: function () { openTopology(t.id); } }]; if (t.ownerId === uid) { actions.push({ text: '重命名', fn: function () { renameTopo(t.id, t.name); } }); actions.push({ text: t.visibility === 'public' ? '设为私有' : '设为公开', fn: function () { toggleVisibility(t.id, t.visibility); } }); actions.push({ text: '删除', danger: true, fn: function () { deleteTopo(t.id, t.name); } }); } box.appendChild(itemRow( t.name, '所有者 ' + t.ownerName + ' · 节点 ' + t.nodeCount + ' · 连线 ' + t.edgeCount + ' · 更新 ' + fmtTime(t.updatedAt), badges, actions )); }); }).catch(function (e) { box.innerHTML = '
加载失败:' + esc(e.message) + '
'; }); } function toggleVisibility(id, cur, cb) { const vis = (cur === 'public') ? 'private' : 'public'; API.setVisibility(id, vis).then(function () { toast('已设为' + (vis === 'public' ? '公开' : '私有'), 'ok'); if (cb) cb(); else refreshTopoList(); }).catch(function (e) { toastErr(e.message); }); } function deleteTopo(id, name, cb) { uiConfirm('确定删除拓扑「' + name + '」?此操作不可恢复。', { title: '删除拓扑', okText: '删除', danger: true }) .then(function (ok) { if (!ok) return; return API.deleteTopo(id).then(function () { toast('已删除', 'ok'); if (topoId === id) startNewTopology(); if (cb) cb(); else refreshTopoList(); }).catch(function (e) { toastErr(e.message); }); }); } function renameTopo(id, curName, cb) { uiPrompt('新名称', { title: '重命名拓扑', message: '为拓扑「' + curName + '」设置新名称(最多 60 个字符)。', placeholder: '例如:某内网横向拓扑', value: curName, okText: '保存' }).then(function (v) { if (v === null) return; v = String(v).trim(); if (!v) { toastErr('名称不能为空'); return; } if (v === curName) return; API.renameTopo(id, v).then(function () { toast('已重命名', 'ok'); if (topoId === id) { topoName = v; updateBanner(); updateTopoCurrent(); } if (cb) cb(); else refreshTopoList(); }).catch(function (e) { toastErr(e.message); }); }); } function createNewTopo() { const inp = document.getElementById('newTopoName'); const name = inp.value.trim(); if (!name) { toastErr('请输入拓扑名称'); return; } const tplSel = document.getElementById('newTopoTemplate'); const tpl = (tplSel && TEMPLATES[tplSel.value]) ? TEMPLATES[tplSel.value] : TEMPLATES.blank; const data = tpl.data() || { nodes: [], edges: [] }; API.createTopo({ name: name, nodes: data.nodes || [], edges: data.edges || [] }).then(function (r) { inp.value = ''; toast('已创建:' + name, 'ok'); openTopology(r.topology.id); }).catch(function (e) { toastErr(e.message); }); } /* ============================================================ 管理员控制台 已迁移至独立页面:web/admin.html(逻辑见 web/admin.js) ============================================================ */ /* ============================================================ 数据导入 / 导出 ============================================================ */ function loadData(data, pushHist) { if (!data || !Array.isArray(data.nodes)) throw new Error('数据格式不正确:缺少 nodes 数组'); if (pushHist !== false) pushHistory(snapshot()); state.nodes = data.nodes.map(function (n) { return { id: String(n.id || uid()), label: String(n.label == null ? '' : n.label), type: TYPES.indexOf(n.type) >= 0 ? n.type : 'host', x: Number(n.x) || 0, y: Number(n.y) || 0, parentId: n.parentId ? String(n.parentId) : null, status: STATUSES[n.status] ? n.status : 'unknown', ports: String(n.ports == null ? '' : n.ports), note: String(n.note == null ? '' : n.note) }; }); const ids = new Set(state.nodes.map(function (n) { return n.id; })); state.nodes.forEach(function (n) { if (n.parentId && !ids.has(n.parentId)) n.parentId = null; }); state.edges = (Array.isArray(data.edges) ? data.edges : []) .filter(function (e) { return e && ids.has(String(e.from)) && ids.has(String(e.to)); }) .map(function (e) { return { id: String(e.id || uid()), from: String(e.from), to: String(e.to), label: String(e.label == null ? '' : e.label) }; }); selectedId = null; render(); syncInspector(); } function serializeData() { return { version: 1, name: topoName || '', exportedAt: new Date().toISOString(), nodes: state.nodes, edges: state.edges }; } function download(blobOrUrl, filename) { const a = document.createElement('a'); if (typeof blobOrUrl === 'string') { a.href = blobOrUrl; } else { a.href = URL.createObjectURL(blobOrUrl); } a.download = filename; document.body.appendChild(a); a.click(); a.remove(); if (typeof blobOrUrl !== 'string') { setTimeout(function () { URL.revokeObjectURL(a.href); }, 8000); } } function buildExportSvg() { const b = contentBounds(); if (!b) return null; const pad = 60; const minX = b.minX - pad, minY = b.minY - pad; const w = b.w + pad * 2, h = b.h + pad * 2; const clone = svg.cloneNode(true); clone.setAttribute('xmlns', NS); clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); clone.setAttribute('width', w.toFixed(0)); clone.setAttribute('height', h.toFixed(0)); clone.setAttribute('viewBox', minX.toFixed(2) + ' ' + minY.toFixed(2) + ' ' + w.toFixed(2) + ' ' + h.toFixed(2)); const vp = clone.querySelector('#viewport'); vp.setAttribute('transform', 'translate(0,0) scale(1)'); clone.querySelectorAll('.link-handle, .add-handle, .edge-del, .edge-hit') .forEach(function (node) { node.remove(); }); const bg = document.createElementNS(NS, 'rect'); bg.setAttribute('x', minX); bg.setAttribute('y', minY); bg.setAttribute('width', w); bg.setAttribute('height', h); bg.setAttribute('fill', '#ffffff'); vp.parentNode.insertBefore(bg, vp); return { node: clone, w: w, h: h }; } function exportJson() { if (!state.nodes.length) { toastErr('当前没有内容可导出'); return; } const str = JSON.stringify(serializeData(), null, 2); download(new Blob([str], { type: 'application/json;charset=utf-8' }), 'route-topology-' + ts() + '.json'); } function exportSvg() { const res = buildExportSvg(); if (!res) { toastErr('当前没有内容可导出'); return; } const str = '\n' + new XMLSerializer().serializeToString(res.node); download(new Blob([str], { type: 'image/svg+xml;charset=utf-8' }), 'route-topology-' + ts() + '.svg'); } function exportPng() { const res = buildExportSvg(); if (!res) { toastErr('当前没有内容可导出'); return; } const maxDim = 8000; let scale = 2; if (res.w * scale > maxDim || res.h * scale > maxDim) { scale = Math.max(1, Math.min(maxDim / res.w, maxDim / res.h)); } const str = new XMLSerializer().serializeToString(res.node); const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(str); const img = new Image(); img.onload = function () { const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(res.w * scale)); canvas.height = Math.max(1, Math.round(res.h * scale)); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); if (canvas.toBlob) { canvas.toBlob(function (blob) { if (blob) download(blob, 'route-topology-' + ts() + '.png'); else download(canvas.toDataURL('image/png'), 'route-topology-' + ts() + '.png'); }, 'image/png'); } else { download(canvas.toDataURL('image/png'), 'route-topology-' + ts() + '.png'); } }; img.onerror = function () { toastErr('导出图片失败,请尝试导出 SVG。'); }; img.src = url; } function importJsonFile(file) { const reader = new FileReader(); reader.onload = function () { try { const data = JSON.parse(reader.result); loadData(data, true); if (data && typeof data.name === 'string' && data.name) { topoName = data.name; } onDataChanged(); requestAnimationFrame(fitView); toast('导入成功', 'ok'); } catch (err) { toastErr('导入失败:' + err.message); } }; reader.onerror = function () { toastErr('读取文件失败'); }; reader.readAsText(file); } /* ============================================================ 导出 / 导入 格式选择 ============================================================ */ const EXPORT_FORMATS = [ { badge: 'PNG', name: 'PNG 图片', desc: '位图输出,适合插入文档或直接分享', run: function () { exportPng(); } }, { badge: 'SVG', name: 'SVG 矢量图', desc: '矢量格式,可无损缩放与二次编辑', run: function () { exportSvg(); } }, { badge: 'JSON', name: 'JSON 数据', desc: '保存全部节点与连线,可再次导入', run: function () { exportJson(); } } ]; const IMPORT_FORMATS = [ { badge: 'JSON', name: 'JSON 数据', desc: '从导出的 JSON 文件恢复拓扑(将覆盖当前画布)', run: function () { fileInput.click(); } } ]; function openFormatDialog(mode) { const isImport = (mode === 'import'); const formats = isImport ? IMPORT_FORMATS : EXPORT_FORMATS; document.getElementById('formatTitle').textContent = isImport ? '导入拓扑' : '导出拓扑'; document.getElementById('formatMsg').textContent = isImport ? '选择导入的文件格式。导入的节点与连线将替换当前画布内容(可撤销)。' : '选择导出格式。'; const box = document.getElementById('formatList'); box.textContent = ''; formats.forEach(function (f) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'format-item'; btn.innerHTML = '' + esc(f.badge) + '' + '' + '' + esc(f.name) + '' + '' + esc(f.desc) + '' + ''; btn.addEventListener('click', function () { closeOverlay('formatOverlay'); f.run(); }); box.appendChild(btn); }); openOverlay('formatOverlay'); } /* ============================================================ 示例数据 ============================================================ */ function demoData() { const n1 = 'n1', n2 = 'n2', n3 = 'n3', n4 = 'n4', n5 = 'n5'; return { nodes: [ { id: n1, label: '192.168.1.0/24', type: 'net', x: 120, y: 130, status: 'confirmed', ports: '', note: '办公网段' }, { id: n2, label: '192.168.1.10', type: 'host', x: 120, y: 232, status: 'owned', ports: '80, 443', note: 'Web 服务器,已 getshell', parentId: n1 }, { id: n3, label: '10.10.10.0/24', type: 'net', x: 430, y: 100, status: 'confirmed', ports: '', note: '内网核心区' }, { id: n4, label: 'dc.corp.local', type: 'domain', x: 430, y: 226, status: 'unknown', ports: '389, 445', note: '域控' }, { id: n5, label: '172.16.0.0/16', type: 'net', x: 740, y: 168, status: 'unknown', ports: '', note: '未知区域' } ], edges: [ { id: 'e1', from: n1, to: n3, label: '' }, { id: 'e2', from: n1, to: n4, label: '' }, { id: 'e3', from: n3, to: n5, label: '' }, { id: 'e4', from: n2, to: n3, label: '' } ] }; } /* ============================================================ 拓扑模板(新建拓扑时可选用) ============================================================ */ function tplDemoData() { return demoData(); } function tplAdData() { return { nodes: [ { id:'ta1', label:'10.0.0.0/24', type:'net', x:90, y:120, status:'confirmed', ports:'', note:'DMZ 边界网段' }, { id:'ta2', label:'10.0.0.21', type:'host', x:90, y:236, status:'owned', ports:'80, 3389', note:'边界 Web 机,已 getshell', parentId:'ta1' }, { id:'ta3', label:'172.16.1.0/24', type:'net', x:400, y:120, status:'confirmed', ports:'', note:'域内网段' }, { id:'ta4', label:'dc.corp.local', type:'domain', x:400, y:248, status:'pivot', ports:'88, 389, 445', note:'域控,可 DCSync' }, { id:'ta5', label:'172.16.1.50', type:'host', x:720, y:120, status:'unknown', ports:'445', note:'文件服务器' }, { id:'ta6', label:'172.16.1.60', type:'host', x:720, y:248, status:'unknown', ports:'1433', note:'数据库服务器' } ], edges: [ { id:'te1', from:'ta1', to:'ta3', label:'' }, { id:'te2', from:'ta2', to:'ta3', label:'' }, { id:'te3', from:'ta3', to:'ta4', label:'' }, { id:'te4', from:'ta4', to:'ta5', label:'' }, { id:'te5', from:'ta4', to:'ta6', label:'' } ] }; } function tplWebData() { return { nodes: [ { id:'tw1', label:'www.example.com', type:'domain', x:120, y:110, status:'confirmed', ports:'', note:'对外主站' }, { id:'tw2', label:'10.10.10.5', type:'host', x:120, y:230, status:'owned', ports:'80, 443', note:'Web 服务器,存在上传点', parentId:'tw1' }, { id:'tw3', label:'10.10.10.0/24', type:'net', x:430, y:110, status:'confirmed', ports:'', note:'应用内网段' }, { id:'tw4', label:'10.10.10.8', type:'host', x:430, y:230, status:'pivot', ports:'3306', note:'数据库,可作跳板' }, { id:'tw5', label:'10.10.20.0/24', type:'net', x:740, y:170, status:'unknown', ports:'', note:'办公网段' } ], edges: [ { id:'twe1', from:'tw1', to:'tw2', label:'' }, { id:'twe2', from:'tw2', to:'tw3', label:'' }, { id:'twe3', from:'tw3', to:'tw4', label:'' }, { id:'twe4', from:'tw4', to:'tw5', label:'' } ] }; } const TEMPLATES = { blank: { label: '空白画布', data: function () { return { nodes: [], edges: [] }; } }, demo: { label: '示例:小型内网', data: tplDemoData }, ad: { label: '模板:AD 域渗透', data: tplAdData }, web: { label: '模板:Web 边界打点', data: tplWebData } }; (function initTemplates() { const sel = document.getElementById('newTopoTemplate'); if (!sel) return; sel.innerHTML = Object.keys(TEMPLATES) .map(k => ``).join(''); })(); /* ============================================================ 事件绑定 ============================================================ */ document.getElementById('btnSave').addEventListener('click', function () { if (!currentUser) { openLogin(); return; } saveNow(false); }); btnMyToposEl.addEventListener('click', openTopoPanel); document.getElementById('topoScope').addEventListener('click', function (e) { const b = e.target.closest && e.target.closest('.seg-btn'); if (!b) return; const scope = b.getAttribute('data-scope'); if (scope === topoScope) return; topoScope = scope; Array.prototype.forEach.call(this.querySelectorAll('.seg-btn'), function (x) { x.classList.toggle('active', x === b); }); refreshTopoList(); }); document.getElementById('btnDoLogin').addEventListener('click', doLoginOrRegister); document.getElementById('btnRegister').addEventListener('click', function () { loginMode = (loginMode === 'login') ? 'register' : 'login'; applyLoginMode(); document.getElementById('loginErr').textContent = ''; }); document.getElementById('btnCloseLogin').addEventListener('click', function () { closeOverlay('loginOverlay'); }); document.getElementById('loginPass').addEventListener('keydown', function (e) { if (e.key === 'Enter') doLoginOrRegister(); }); document.getElementById('loginPass2').addEventListener('keydown', function (e) { if (e.key === 'Enter') doLoginOrRegister(); }); document.getElementById('loginShow').addEventListener('change', function () { const t = this.checked ? 'text' : 'password'; ['loginPass', 'loginPass2'].forEach(function (id) { const el = document.getElementById(id); if (el) el.type = t; }); }); document.getElementById('btnCloseTopo').addEventListener('click', function () { closeOverlay('topoOverlay'); }); document.getElementById('btnCreateTopo').addEventListener('click', createNewTopo); document.getElementById('newTopoName').addEventListener('keydown', function (e) { if (e.key === 'Enter') createNewTopo(); }); document.getElementById('btnClosePwd').addEventListener('click', function () { if (pwdForced) return; closeOverlay('pwdOverlay'); }); document.getElementById('btnDoPwd').addEventListener('click', function () { const o = document.getElementById('pwdOld').value; const n = document.getElementById('pwdNew').value; const n2 = document.getElementById('pwdNew2').value; const err = document.getElementById('pwdErr'); err.textContent = ''; if (!o) { err.textContent = '请输入原密码'; return; } const bad = pwdStrengthError(n, o, currentUser ? currentUser.username : ''); if (bad) { err.textContent = bad; return; } if (n !== n2) { err.textContent = '两次输入的新密码不一致'; return; } API.changePassword(o, n).then(function (r) { if (r && r.user) currentUser = r.user; pwdForced = false; toast('密码已修改', 'ok'); closeOverlay('pwdOverlay'); document.getElementById('pwdOld').value = ''; document.getElementById('pwdNew').value = ''; document.getElementById('pwdNew2').value = ''; onUserChanged(); }).catch(function (e) { err.textContent = e.message; }); }); /* 显示 / 隐藏密码:勾选后将三个密码框切换为明文 */ document.getElementById('pwdShow').addEventListener('change', function () { const t = this.checked ? 'text' : 'password'; ['pwdOld', 'pwdNew', 'pwdNew2'].forEach(function (id) { const el = document.getElementById(id); if (el) el.type = t; }); }); ['pwdNew', 'pwdNew2'].forEach(function (id) { document.getElementById(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('btnDoPwd').click(); } }); }); /* 用户下拉菜单为 fixed 定位,按按钮实际位置动态摆放,避免被顶部栏 overflow 裁剪 */ function positionUserMenu() { const m = document.getElementById('userMenu'); if (!m || m.hidden) { return; } const r = btnUserEl.getBoundingClientRect(); m.style.top = Math.round(r.bottom + 6) + 'px'; m.style.right = Math.round(Math.max(8, window.innerWidth - r.right)) + 'px'; } btnUserEl.addEventListener('click', function (e) { e.stopPropagation(); if (!currentUser) { openLogin(); return; } const m = document.getElementById('userMenu'); if (m.hidden) { m.hidden = false; positionUserMenu(); } else { m.hidden = true; } }); document.getElementById('userMenu').addEventListener('click', function (e) { e.stopPropagation(); }); document.addEventListener('click', function () { document.getElementById('userMenu').hidden = true; }); window.addEventListener('resize', positionUserMenu); const topbarEl = document.querySelector('.topbar'); if (topbarEl) { topbarEl.addEventListener('scroll', positionUserMenu); } document.getElementById('ddPwd').addEventListener('click', function () { document.getElementById('userMenu').hidden = true; openPwdOverlay(false); }); document.getElementById('ddLogout').addEventListener('click', function () { document.getElementById('userMenu').hidden = true; API.logout().then(function () { currentUser = null; topoId = null; topoName = ''; topoCanEdit = false; readOnly = false; try { localStorage.removeItem(LAST_TOPO_KEY); } catch (e) {} onUserChanged(); loadDraft(); toast('已退出登录'); }).catch(function (e) { toastErr(e.message); }); }); document.getElementById('btnExport').addEventListener('click', function () { openFormatDialog('export'); }); document.getElementById('btnImport').addEventListener('click', function () { openFormatDialog('import'); }); document.getElementById('btnCloseFormat').addEventListener('click', function () { closeOverlay('formatOverlay'); }); fileInput.addEventListener('change', function (e) { const f = e.target.files && e.target.files[0]; if (f) importJsonFile(f); fileInput.value = ''; }); document.querySelectorAll('.overlay').forEach(function (o) { o.addEventListener('click', function (e) { if (e.target !== o) return; if (o.id === 'loginOverlay') return; /* 登录/注册框禁止点击遮罩关闭,避免误触 */ if (o.id === 'pwdOverlay' && pwdForced) return; if (o.id === 'confirmOverlay') { _dlgFinish(null); return; } o.hidden = true; }); }); /* ---------- 共享与协作弹窗 ---------- */ document.getElementById('btnShare').addEventListener('click', openShareDialog); document.getElementById('btnCloseShare').addEventListener('click', function () { closeOverlay('shareOverlay'); }); document.getElementById('btnCancelShare').addEventListener('click', function () { closeOverlay('shareOverlay'); }); document.getElementById('btnSaveShare').addEventListener('click', saveShare); document.getElementById('shareUserList').addEventListener('change', onShareListChange); document.getElementById('shareGroupList').addEventListener('change', onShareListChange); document.getElementById('shareUserFilter').addEventListener('input', function () { renderShareLists(); }); document.getElementById('shareGroupFilter').addEventListener('input', function () { renderShareLists(); }); /* ---------- 检查器分区折叠 ---------- */ Array.prototype.forEach.call(document.querySelectorAll('#inspBody .insp-sec-head'), function (h) { h.addEventListener('click', function () { h.parentNode.classList.toggle('collapsed'); }); }); /* ---------- 用户菜单:我的工作台 ---------- */ document.getElementById('ddWorkspace').addEventListener('click', function () { document.getElementById('userMenu').hidden = true; window.location.href = 'web/user.html'; }); /* 离开页面时告知服务端下线 */ window.addEventListener('beforeunload', function () { sendPresenceLeave(); }); /* ---------- 站内确认 / 输入弹窗 ---------- */ document.getElementById('confirmOk').addEventListener('click', _dlgConfirmOk); document.getElementById('confirmCancel').addEventListener('click', function () { _dlgFinish(null); }); document.getElementById('confirmClose').addEventListener('click', function () { _dlgFinish(null); }); document.getElementById('confirmField').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); _dlgConfirmOk(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !document.getElementById('confirmOverlay').hidden) { _dlgFinish(null); } }); /* ============================================================ 启动 ============================================================ */ function pendingOpenId() { try { const p = new URLSearchParams(window.location.search); const id = p.get('topo'); if (id) { return id; } } catch (e) {} return null; } function pendingShare() { try { const p = new URLSearchParams(window.location.search); return p.get('share') === '1'; } catch (e) {} return false; } function init() { updateEditability(); API.site().then(function (r) { applySite(r.site); }).catch(function () { /* 忽略:回退页面默认值 */ }); API.me().then(function (r) { if (r && r.user) { currentUser = r.user; onUserChanged(); const openId = pendingOpenId(); if (openId) { openTopology(openId).then(function () { const forced = maybeForcePwdChange(); if (!forced && pendingShare()) { openShareDialog(); } }); } else { bootAfterLogin(); maybeForcePwdChange(); } } else { onUserChanged(); loadDraft(); } }).catch(function () { onUserChanged(); loadDraft(); }); } init(); })();