RoutePage/web/app.js
2026-09-15 02:00:21 +08:00

2027 lines
71 KiB
JavaScript

(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 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; // 站点是否开放注册(由后端设置控制)
/* ============================================================
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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())}`;
}
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 => `<option value="${k}">${STATUSES[k].label}</option>`).join('');
})();
(function initLegend() {
document.getElementById('legend').innerHTML =
'<div class="legend-title">状态图例</div><div class="legend-items">' +
Object.keys(STATUSES).map(k =>
`<span><i style="background:${STATUSES[k].color}"></i>${STATUSES[k].label}</span>`
).join('') +
'</div>';
})();
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 =
`<div class="meta-row"><span>上游</span><b>${ins.length ? ins.map(x => esc(x.label)).join('、') : '—'}</b></div>` +
`<div class="meta-row"><span>下游</span><b>${outs.length ? outs.map(x => esc(x.label)).join('、') : '—'}</b></div>` +
(parent ? `<div class="meta-row"><span>所属</span><b>${esc(parent.label)}</b></div>` : '');
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();
['btnAdd','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; });
}
/* ============================================================
工具栏
============================================================ */
document.getElementById('btnAdd').addEventListener('click', addNodeAtCenter);
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() {
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 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 });
} 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;
lastSavedAt = r.topology.updatedAt;
try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {}
}
dirty = false;
if (!silent) toast('已保存:' + topoName, 'ok');
}).catch(function (e) {
saveStateEl.textContent = '保存失败';
saveStateEl.className = 'save-state err';
if (!silent) toastErr('保存失败:' + e.message);
}).then(function () {
saving = false;
updateSaveState();
updateEditability();
updateBanner();
});
}
/* ============================================================
轻提示
============================================================ */
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;
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)); }
};
/* ============================================================
弹窗工具
============================================================ */
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 = '登录';
}
document.getElementById('userMenu').hidden = true;
updateAdminEntry();
updateBanner();
updateEditability();
updateSaveState();
updateUndoBtn();
}
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 = '未登录模式:当前为默认演示页,编辑内容不会保存,也无法访问已存数据。' +
'<span class="lnk" id="bannerLogin">立即登录 / 注册</span>';
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;
history = [];
dirty = false;
loadData(r.data, false);
loading = false;
try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {}
updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn();
closeOverlay('topoOverlay');
requestAnimationFrame(fitView);
toast('已打开:' + topoName + (readOnly ? '(只读)' : ''), 'ok');
}).catch(function (e) { toastErr(e.message); });
}
function loadDraft() {
loading = true;
topoId = null;
topoName = '';
topoCanEdit = true;
readOnly = false;
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();
requestAnimationFrame(fitView);
}
function startNewTopology() {
loading = true;
topoId = null;
topoName = '';
topoCanEdit = true;
readOnly = false;
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();
}
/* ============================================================
我的拓扑面板
============================================================ */
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 = '<div class="empty">加载中…</div>';
API.listTopos().then(function (r) {
const all = r.topologies || [];
const uid = currentUser ? currentUser.id : '';
const list = (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 = '<div class="empty">' +
(topoScope === 'public' ? '暂无公开拓扑。' : '还没有拓扑,输入名称点击「新建」开始。') +
'</div>';
return;
}
list.forEach(function (t) {
const badges = [{
cls: t.visibility === 'public' ? 'public' : 'private',
text: t.visibility === 'public' ? '公开' : '私有'
}];
if (!t.canEdit) { badges.push({ cls: 'ro', text: '只读' }); }
const actions = [{ text: '打开', primary: true, fn: function () { openTopology(t.id); } }];
if (t.canEdit) {
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 = '<div class="empty">加载失败:' + esc(e.message) + '</div>'; });
}
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(); }
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 = '<?xml version="1.0" encoding="UTF-8"?>\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 =
'<span class="fi-badge">' + esc(f.badge) + '</span>' +
'<span class="fi-text">' +
'<span class="fi-name">' + esc(f.name) + '</span>' +
'<span class="fi-desc">' + esc(f.desc) + '</span>' +
'</span>';
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 => `<option value="${k}">${esc(TEMPLATES[k].label)}</option>`).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('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 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();
bootAfterLogin();
maybeForcePwdChange();
} else {
onUserChanged();
loadDraft();
}
}).catch(function () {
onUserChanged();
loadDraft();
});
}
init();
})();