Parcourir la source

feat(l2): cascade access column, true upstream highlight, day-mode PNG export

Code Pach il y a 2 jours
Parent
commit
17d2a09a14
39 fichiers modifiés avec 340 ajouts et 50 suppressions
  1. 2 1
      README.md
  2. 2 1
      docs/使用说明.md
  3. 229 47
      web-modules/WecomCenter/assets/l2topology/app.js
  4. 3 0
      web-modules/WecomCenter/locale/bg_BG.php
  5. 3 0
      web-modules/WecomCenter/locale/ca_ES.php
  6. 3 0
      web-modules/WecomCenter/locale/cs_CZ.php
  7. 3 0
      web-modules/WecomCenter/locale/da_DK.php
  8. 3 0
      web-modules/WecomCenter/locale/de_DE.php
  9. 3 0
      web-modules/WecomCenter/locale/el_GR.php
  10. 3 0
      web-modules/WecomCenter/locale/en.php
  11. 3 0
      web-modules/WecomCenter/locale/en_GB.php
  12. 3 0
      web-modules/WecomCenter/locale/es_ES.php
  13. 3 0
      web-modules/WecomCenter/locale/fa_IR.php
  14. 3 0
      web-modules/WecomCenter/locale/fi_FI.php
  15. 3 0
      web-modules/WecomCenter/locale/fr_FR.php
  16. 3 0
      web-modules/WecomCenter/locale/he_IL.php
  17. 3 0
      web-modules/WecomCenter/locale/hu_HU.php
  18. 3 0
      web-modules/WecomCenter/locale/id_ID.php
  19. 3 0
      web-modules/WecomCenter/locale/it_IT.php
  20. 3 0
      web-modules/WecomCenter/locale/ja_JP.php
  21. 3 0
      web-modules/WecomCenter/locale/ka_GE.php
  22. 3 0
      web-modules/WecomCenter/locale/ko_KR.php
  23. 3 0
      web-modules/WecomCenter/locale/lt_LT.php
  24. 3 0
      web-modules/WecomCenter/locale/lv_LV.php
  25. 3 0
      web-modules/WecomCenter/locale/nb_NO.php
  26. 3 0
      web-modules/WecomCenter/locale/nl_NL.php
  27. 3 0
      web-modules/WecomCenter/locale/pl_PL.php
  28. 3 0
      web-modules/WecomCenter/locale/pt_BR.php
  29. 3 0
      web-modules/WecomCenter/locale/ro_RO.php
  30. 3 0
      web-modules/WecomCenter/locale/ru_RU.php
  31. 3 0
      web-modules/WecomCenter/locale/sk_SK.php
  32. 3 0
      web-modules/WecomCenter/locale/sv_SE.php
  33. 3 0
      web-modules/WecomCenter/locale/tr_TR.php
  34. 3 0
      web-modules/WecomCenter/locale/uk_UA.php
  35. 3 0
      web-modules/WecomCenter/locale/uz_UZ.php
  36. 3 0
      web-modules/WecomCenter/locale/vi_VN.php
  37. 3 0
      web-modules/WecomCenter/locale/zh_CN.php
  38. 3 0
      web-modules/WecomCenter/locale/zh_TW.php
  39. 2 1
      web-modules/WecomCenter/views/topology.view.php

+ 2 - 1
README.md

@@ -101,7 +101,8 @@ docker compose -p zabbix ps                                     # 查看状态
   核心(可多个)→ 各网络主交换 → 各车间汇聚/分布 → 接入,一级交换机一列,不再拆成卡片或车间详情页。
   每个车间一个框(包住该车间的汇聚+接入),框内再按 GRD→1F→3F→4F→7F 楼层画小框;同一车间若跨网络
   则拆成两个独立框,只连接各自网络的上游。所有链路均为单段直线,从上级标签左缘中点画到下级标签左缘中点,
-  上级标签可遮盖出口,目标标签不被遮挡;含时间轴/终端/离线/搜索/速率&延迟&告警着色。
+  上级标签可遮盖出口,目标标签不被遮挡;接入下还有接入时按层级向右加一列(不改协议连接关系)。
+  含时间轴/终端/离线/搜索/速率&延迟&告警着色,右上角「导出图片」始终按日间模式导出 PNG。
   主题自动跟随 Zabbix 明暗模式,无手动深色开关。
 - 配置页:`Administration → ROMAN 定制中心 → 「L2 拓扑」`(采集模板、刷新/采集间隔、速率配色、
   延迟阈值、分组规则、根框名、**物理网络定义**、**核心交换机定义**等)。物理网络按每行

+ 2 - 1
docs/使用说明.md

@@ -233,7 +233,7 @@ zabbix-deploy/
   - 从左到右固定为 **核心(可多个)→ 各网络主交换 → 各车间汇聚/分布 → 接入**,一级交换机一列;列只按层级区分,
     不按车间拆成多列。
   - 每个车间一个框(包住该车间的汇聚+接入),框内按 **GRD → 1F → 3F → 4F → 7F** 从下到上画楼层小框,
-    每层接入交换机在该楼层框内单列排列。同一车间跨网络时拆成两个独立框,只连接各自网络的上游。
+    每层接入交换机在该楼层框内排列;接入下还有接入时按层级向右加一列(不改协议连接关系)。同一车间跨网络时拆成两个独立框,只连接各自网络的上游。
   - 所有链路均为单段直线,从**上级标签左缘中点**画到**下级标签左缘中点**;上级标签可以遮住线路出口,
     但任何目标标签和其他线路不被遮挡;同一车间内部线路互不交叉,跨区直连核心的链路除外。点任意交换机高亮完整上联链路,点空白恢复。
 - **节点标签**:`车间 · 楼层机柜U · 角色 · IP`;双击节点跳转该主机的问题页。
@@ -243,6 +243,7 @@ zabbix-deploy/
 - **时间轴**:右上「快照时间」下拉可选 `实时` 或历史某分钟快照(保存 **7 天**,5 分钟一个点)。
 - **明暗主题**:自动跟随 Zabbix 当前主题(Zabbix 深色即深色、亮色即亮色),无手动开关。
 - **搜索**:输入 IP / MAC / 名称,下拉结果点击即居中定位。
+- **导出图片**:右上角「导出图片」把当前完整拓扑导出为 PNG;无论当前 Zabbix 是明色还是深色,导出图固定使用日间模式(白底)。
 - 实时模式按配置的刷新秒数自动刷新,可暂停;支持缩放/适应窗口。
 
 ### 10.3 数据与存储

+ 229 - 47
web-modules/WecomCenter/assets/l2topology/app.js

@@ -39,6 +39,7 @@
   var detailNet = null;       // null/auto, 'SURVE' or 'OFFICE' inside a workshop
   var focusGroupLabel = null; // ?g= target, zoomed after the full chart is built
   var pathParent = {};        // child -> parent chain for hover highlighting
+  var lastRoleMap = {};       // switch id -> computed display role
   var lockedPath = null;      // switch id whose path is locked by click
 
   function el(id) { return document.getElementById(id); }
@@ -453,11 +454,12 @@
   var CORE_X = 130, MAIN_X = 430, DIST_X = 790, ACCESS_X = 1300;
   var CORE_W = 200, MAIN_W = 300, NODE_W3 = 300;
   var WORK_LEFT = 610, WORK_RIGHT = 1530;
-  var FLOOR_LEFT = 1130, FLOOR_RIGHT = 1525;
+  var FLOOR_LEFT = 1130;
   var CHART_W = 1560;
+  var ACCESS_STEP = 380, DIST_STEP = 86;
   var SEC_HEAD = 46, CORE_ROW = 92, WORK_TOP = 148;
-  var ROW_H = 24, ROW_GAP = 8, WS_GAP = 20, SEC_GAP = 40;
-  var FLOOR_GAP = 18;
+  var ROW_H = 34, ROW_GAP = 14, WS_GAP = 36, SEC_GAP = 52;
+  var FLOOR_GAP = 26;
 
   function makeNetworks() {
     var raw = CFG.networks;
@@ -584,16 +586,23 @@
     // config).  An explicitly empty list means the administrator wants no
     // configured core column.
     if (!(CFG.core_rules instanceof Array) && (/^core(\s|-)/i.test(String(n.name || '')) || groupOf(n).label === 'Core')) return 'core';
-    // STP topology is the truth for Dist vs Access: a switch with downstream
-    // children is a distribution point even when its hostname says Access.
-    // This fixes the 3301/4504 cases where the old snapshot tier was a root
-    // tier but the device is really an aggregation switch.
+    // Hostname role is the display truth for Access switches.  An access
+    // switch may itself have access children (cascade/stack); it still stays
+    // an access node and the children simply take a further-right column.
+    // A Dist switch stays a distribution point even when it has only one child.
+    if (r === 'access') return 'access';
+    if (r === 'dist') {
+      // Old snapshots sometimes call a leaf "Dist"; if it has no children and
+      // the collector already marked it as access, keep it an access leaf.
+      if (tier === 'access' && childCount <= 0) return 'access';
+      return 'dist';
+    }
+    if (r === 'core') return childCount > 0 ? 'dist' : 'main';
     if (tier === 'core') {
-      if (childCount > 0 || r === 'dist') return 'dist';
+      if (childCount > 0) return 'dist';
       return 'main';
     }
     if (childCount > 0) return 'dist';
-    if (r === 'dist' && tier !== 'access') return 'dist';
     if (tier === 'distribution') return 'dist';
     return 'access';
   }
@@ -629,15 +638,37 @@
     (snap.edges || []).forEach(function (e) {
       if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
     });
+    function depthOf(n) {
+      var d = parseInt(n && n.depth, 10);
+      return (isFinite(d) && d >= 0 && d < 99) ? d : 999;
+    }
+    // Walk upstream to the highest non-core node.  This is the real workshop
+    // anchor.  It keeps cascaded access (2.59 -> 2.75) in the 4401 frame while
+    // still allowing an access switch that is itself an anchor (4504 -> .118/
+    // 6109 access) to own one frame.  No protocol edge is changed here.
+    function rootOf(n) {
+      var cur = n, guard = 0;
+      while (cur && guard++ < 80) {
+        var cd = depthOf(cur), best = null;
+        (adj[cur.id] || []).forEach(function (id) {
+          var up = byId[id];
+          if (!up || up.id === cur.id) return;
+          var ud = depthOf(up);
+          if (ud < cd) {
+            if (!best || ud < depthOf(best)) best = up;
+          }
+        });
+        if (!best) return cur;
+        if (roleOf(best) === 'core') return cur;
+        cur = best;
+      }
+      return cur || n;
+    }
     (snap.nodes || []).forEach(function (n) {
       var r = roleOf(n);
       if (r === 'core' || r === 'main') return;
-      if (r === 'dist') { keys[n.id] = groupOf(n).label; return; }
-      var ds = (adj[n.id] || []).map(function (id) { return byId[id]; })
-        .filter(function (d) { return d && roleOf(d) === 'dist'; });
-      var same = ds.filter(function (d) { return groupOf(d).label === groupOf(n).label; });
-      var pick = same[0] || ds[0];
-      keys[n.id] = pick ? groupOf(pick).label : groupOf(n).label;
+      var root = rootOf(n);
+      keys[n.id] = groupOf(root || n).label;
     });
     return keys;
   }
@@ -664,48 +695,119 @@
       });
   }
 
-  function layoutWorkshop(w, top) {
+  function layoutWorkshop(w, top, snap) {
     var rowH = ROW_H + ROW_GAP + (showTerm ? 24 : 0);
-    var floorMap = {}, floors = [];
+    var floorMap = {}, floors = [], accessFloor = {};
     w.access.forEach(function (n) {
       var fi = floorInfo(n);
       var k = fi.key;
       if (!floorMap[k]) { floorMap[k] = {key: k, label: fi.label, rank: (fi.key === 'NA' ? 999 : fi.rank), nodes: [], rows: []}; floors.push(floorMap[k]); }
       floorMap[k].nodes.push(n);
+      accessFloor[n.id] = k;
     });
     floors.sort(function (a, b) { return a.rank - b.rank || String(a.label).localeCompare(String(b.label)); });
-    var accessH = 0;
+
+    // Build the access sub-columns from the real graph: direct children of a
+    // dist are column 0, their access children column 1, and so on.  No
+    // logical parent/child is replaced by this; it only decides x positions.
+    var byId = {}, adj = {}, member = {};
+    w.all.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; member[n.id] = true; });
+    (snap.edges || []).forEach(function (e) {
+      if (member[e.source] && member[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
+    });
+    function numDepth(n) {
+      var d = parseInt(n && n.depth, 10);
+      return (isFinite(d) && d >= 0 && d < 99) ? d : 99;
+    }
+    var starts = w.dists.map(function (n) { return n.id; });
+    if (!starts.length) {
+      // A frame with no Dist (e.g. 4504 when its hostname says Access) still
+      // has a top access node.  Find nodes with no upstream member.
+      w.access.forEach(function (n) {
+        var up = false;
+        (adj[n.id] || []).forEach(function (id) {
+          var other = byId[id];
+          if (other && numDepth(other) < numDepth(n)) up = true;
+        });
+        if (!up) starts.push(n.id);
+      });
+      if (!starts.length && w.access.length) starts.push(w.access[0].id);
+    }
+    var level = {}, queue = [];
+    starts.forEach(function (id) { if (byId[id]) { level[id] = 0; queue.push(id); } });
+    while (queue.length) {
+      var cur = queue.shift();
+      (adj[cur] || []).forEach(function (id) {
+        if (!byId[id] || level[id] != null) return;
+        level[id] = level[cur] + 1;
+        queue.push(id);
+      });
+    }
+    var base = w.dists.length ? 1 : 0;
+    w.access.forEach(function (n) {
+      var lv = level[n.id];
+      n._col = (lv == null) ? 0 : Math.max(0, lv - base);
+      if (n._col > 12) n._col = 12;
+    });
+
+    var maxCol = 0;
     floors.forEach(function (f) {
-      f.nodes.sort(function (a, b) { return String(a.name).localeCompare(String(b.name)); });
-      f.h = 24 + f.nodes.length * rowH + 8;
-      accessH += f.h;
+      // A child fed from another floor (e.g. 1FA-27 2.59 -> GRD 2.75) is put
+      // at the top of its own floor frame.  Its straight line then stays out
+      // of the labels of the floor's normal first column.
+      function parentFloorKey(n) {
+        var best = null, bestD = 999;
+        (adj[n.id] || []).forEach(function (id) {
+          var up = byId[id];
+          if (!up || !accessFloor[id]) return;
+          var ud = numDepth(up);
+          if (ud < numDepth(n) && ud < bestD) { best = up; bestD = ud; }
+        });
+        return best ? accessFloor[best.id] : f.key;
+      }
+      f.nodes.sort(function (a, b) {
+        var ca = (parentFloorKey(a) === f.key ? 1 : 0) - (parentFloorKey(b) === f.key ? 1 : 0);
+        if (ca) return ca;
+        var cc = (a._col || 0) - (b._col || 0);
+        if (cc) return cc;
+        return String(a.name).localeCompare(String(b.name));
+      });
+      f.nodes.forEach(function (n) { maxCol = Math.max(maxCol, n._col || 0); f.maxCol = Math.max(f.maxCol || 0, n._col || 0); });
+      f.crossFloor = f.nodes.some(function (n) { return parentFloorKey(n) !== f.key; });
+      f.h = 28 + f.nodes.length * rowH + 10;
     });
+    var accessH = 0;
+    floors.forEach(function (f) { accessH += f.h; });
     if (floors.length > 1) accessH += (floors.length - 1) * FLOOR_GAP;
-    var distH = w.dists.length ? (w.dists.length * 64 + (w.dists.length - 1) * 8) : 0;
+    var distH = w.dists.length ? (w.dists.length * DIST_STEP + (w.dists.length - 1) * 10) : 0;
     var inner = Math.max(accessH, distH, 54);
-    var h = 38 + inner + 10;
+    var h = 40 + inner + 12;
     w.top = top; w.h = h; w.rowH = rowH;
+    w.right = Math.max(WORK_RIGHT, ACCESS_X + maxCol * ACCESS_STEP + NODE_W3 / 2 + 26);
+
     // Floors are stacked bottom-up: GRD lowest, then 1F → 3F → 4F → 7F.
-    var cursor = top + h - 8;
+    var cursor = top + h - 10;
     floors.forEach(function (f) {
       cursor -= f.h;
-      f.frame = {x: FLOOR_LEFT, y: cursor, w: FLOOR_RIGHT - FLOOR_LEFT, h: f.h};
+      var floorW = Math.max(340, ACCESS_X + (f.maxCol || 0) * ACCESS_STEP + NODE_W3 / 2 + 26 - FLOOR_LEFT);
+      f.frame = {x: FLOOR_LEFT, y: cursor, w: floorW, h: f.h};
+      if (f.crossFloor) f.labelX = FLOOR_LEFT + 72;
       f.nodes.forEach(function (n, i) {
-        f.rows.push({id: n.id, x: ACCESS_X, y: cursor + 22 + rowH / 2 + i * rowH});
+        f.rows.push({id: n.id, x: ACCESS_X + (n._col || 0) * ACCESS_STEP, y: cursor + 28 + rowH / 2 + i * rowH});
       });
       cursor -= FLOOR_GAP;
     });
-    var dstart = top + 38 + (inner - distH) / 2;
+    var dstart = top + 40 + (inner - distH) / 2;
     w.distPos = w.dists.map(function (n, i) {
-      return {id: n.id, x: DIST_X, y: dstart + 32 + i * 72};
+      return {id: n.id, x: DIST_X, y: dstart + DIST_STEP / 2 + i * (DIST_STEP + 10)};
     });
     w.floors = floors;
     w.title = w.label + '  ·  ' + netLabel(w.net) + '  ·  ' + w.all.length + ' ' + t('l2.unit_sw', '台');
     // A workshop without a distribution switch only needs a box around its
-    // access column; keeping the full dist-column width would leave a large
+    // access columns; keeping the full dist-column width would leave a large
     // empty rectangle and make the chart look sparse.
     var frameLeft = w.dists.length ? WORK_LEFT : FLOOR_LEFT - 40;
-    w.frame = {x: frameLeft, y: top, w: WORK_RIGHT - frameLeft, h: h};
+    w.frame = {x: frameLeft, y: top, w: w.right - frameLeft, h: h};
     return h;
   }
 
@@ -714,7 +816,7 @@
     var netMap = assignNetworks(snap);
     var sections = [], placements = [], frames = [], roleMap = {};
     var wsKeys = workshopKeys(snap);
-    var y = 100;
+    var y = 100, maxRight = CHART_W;
     NETWORKS.forEach(function (net) {
       var secNodes = nodes.filter(function (n) { return netMap[n.id] === net.key; });
       if (!secNodes.length) return;
@@ -732,13 +834,14 @@
       };
       var cur = y + WORK_TOP;
       ws.forEach(function (w) {
-        layoutWorkshop(w, cur);
+        layoutWorkshop(w, cur, snap);
+        maxRight = Math.max(maxRight, w.right || WORK_RIGHT);
         cur += w.h + WS_GAP;
         frames.push({id: 'ws_' + net.key + '_' + w.key, type: 'wsframe', x: w.frame.x, y: w.frame.y, w: w.frame.w, h: w.frame.h,
           label: w.title, group: w.key, net: net.key, color: net.color});
         w.floors.forEach(function (f, fi) {
           frames.push({id: 'ff_' + net.key + '_' + w.key + '_' + fi, type: 'floorframe', x: f.frame.x, y: f.frame.y,
-            w: f.frame.w, h: f.frame.h, label: f.label + '  ·  ' + f.nodes.length + ' ' + t('l2.unit_sw', '台'), net: net.key, color: net.color});
+            w: f.frame.w, h: f.frame.h, label: f.label + '  ·  ' + f.nodes.length + ' ' + t('l2.unit_sw', '台'), labelX: f.labelX, net: net.key, color: net.color});
         });
         w.distPos.forEach(function (p) {
           var n = nodes.filter(function (x) { return x.id === p.id; })[0];
@@ -768,7 +871,9 @@
       sections.push(sec);
       y += sec.h + SEC_GAP;
     });
-    var root = {x: 0, y: 0, w: CHART_W, h: y + 6};
+    var chartW = Math.max(CHART_W, maxRight + 40);
+    sections.forEach(function (sec) { sec.w = chartW - 20; });
+    var root = {x: 0, y: 0, w: chartW, h: y + 6};
     return {sections: sections, placements: placements, frames: frames, netMap: netMap, roleMap: roleMap, root: root};
   }
 
@@ -978,13 +1083,15 @@
         'width': 0.7, 'line-color': d ? '#3b4a5e' : '#c5cfdb', 'opacity': 0.55,
         'curve-style': 'bezier', 'z-index-compare': 'manual', 'z-index': 11
       }},
-      {selector: 'node.alarm', style: {'border-width': 6}},
+      // Latency is a secondary label signal; alarm and offline must win over
+      // latency so an alarmed/offline switch is never shown as merely orange.
+      {selector: 'node.lat-warn', style: {'border-color': '#F39C12', 'border-width': 4, 'border-style': 'solid'}},
+      {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
+      {selector: 'node.alarm', style: {'border-color': '#E45959', 'border-width': 6}},
       {selector: 'node.offline', style: {'border-color': '#C0392B', 'border-width': 6, 'border-style': 'dashed', 'opacity': 0.9}},
       {selector: 'node.dim', style: {'opacity': 0.12}},
       {selector: 'edge.dim', style: {'opacity': 0.08}},
       {selector: 'edge.hl', style: {'width': 5, 'line-color': '#FFD54A', 'opacity': 1, 'z-index-compare': 'manual', 'z-index': 45}},
-      {selector: 'node.lat-warn', style: {'border-color': '#F39C12'}},
-      {selector: 'node.lat-bad', style: {'border-color': '#E74C3C', 'border-width': 6}},
       {selector: 'node.hl', style: {'border-color': '#ff0', 'border-width': 7, 'overlay-color': '#ff0', 'overlay-opacity': 0.25, 'overlay-padding': 6}},
       {selector: 'node.pathhl', style: {'border-width': 5, 'border-color': '#FFD54A', 'overlay-color': '#FFD54A', 'overlay-opacity': 0.2, 'overlay-padding': 4}},
       {selector: 'edge.pathhl', style: {'width': 5.5, 'line-color': '#FFD54A', 'opacity': 1}},
@@ -1068,6 +1175,7 @@
   function renderFull(snap) {
     var L = computeFull(snap), els = [];
     if (!L.placements.length) { applyElements([], false); return; }
+    lastRoleMap = L.roleMap || {};
     var byId = {};
     L.placements.forEach(function (p) { byId[p.node.id] = p; });
 
@@ -1089,7 +1197,7 @@
       var tt = f.type === 'floorframe' ? 'floorlabel' : 'wslabel';
       var ty = f.type === 'floorframe' ? 11 : 18;
       els.push({group: 'nodes', data: {id: 'label_' + f.id, type: tt, label: f.label, w: f.w, h: 16},
-        position: {x: f.x + f.w / 2, y: f.y + ty}, selectable: false, grabbable: false});
+        position: {x: (f.labelX != null ? f.labelX : f.x + f.w / 2), y: f.y + ty}, selectable: false, grabbable: false});
     });
 
     L.placements.forEach(function (p) {
@@ -1522,6 +1630,38 @@
     setTimeout(function () { n.removeClass('hl'); }, 2600);
   }
 
+  function exportPng() {
+    if (!cy) return;
+    var btn = el('l2-export'), oldText = btn ? btn.textContent : '';
+    if (btn) { btn.disabled = true; btn.textContent = t('l2.exporting', '正在导出…'); }
+    var wasDark = dark;
+    try {
+      // The exported file must always be the day palette, even when the
+      // operator is currently using Zabbix dark mode.
+      cy.elements().removeClass('hl dim pathhl');
+      cy.style(styleFor(false));
+      if (typeof cy.forceRender === 'function') cy.forceRender();
+      var data = cy.png({full: true, scale: 1, maxWidth: 2600, maxHeight: 18000, bg: '#ffffff'});
+      var now = new Date();
+      function p2(v) { return (v < 10 ? '0' : '') + v; }
+      var suffix = META.usedTs ? ('_' + META.usedTs) : '_live';
+      var name = 'L2_Topology_' + now.getFullYear() + p2(now.getMonth() + 1) + p2(now.getDate()) +
+        '_' + p2(now.getHours()) + p2(now.getMinutes()) + suffix + '.png';
+      var a = document.createElement('a');
+      a.href = data;
+      a.download = name;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+    } catch (e) {
+      window.alert(t('l2.export_fail', '导出失败,请重试。'));
+    } finally {
+      cy.style(styleFor(wasDark));
+      if (typeof cy.forceRender === 'function') cy.forceRender();
+      if (btn) { btn.disabled = false; btn.textContent = oldText; }
+    }
+  }
+
   function groupOfNodeId(id) {
     var hit = (lastSnap && lastSnap.nodes || []).filter(function (n) { return n.id === id; })[0];
     return hit ? groupOf(hit).label : null;
@@ -1559,22 +1699,63 @@
     lockedPath = null;
   }
 
+  function upstreamGraph(nodeId) {
+    var snap = lastSnap || {};
+    var nodes = snap.nodes || [], edges = snap.edges || [];
+    var byId = {}, adj = {};
+    nodes.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; });
+    edges.forEach(function (e) {
+      if (adj[e.source] && adj[e.target]) { adj[e.source].push(e.target); adj[e.target].push(e.source); }
+    });
+    var coreIds = nodes.filter(function (n) {
+      return (lastRoleMap[n.id] || roleOf(n)) === 'core';
+    }).map(function (n) { return n.id; });
+    // If the configured core list is empty, fall back to the collector's root
+    // tier so the upstream chain still starts at the physical core.
+    if (!coreIds.length) {
+      nodes.forEach(function (n) {
+        if (String(n.tier || '').toLowerCase() === 'core' || /^core(\s|-)/i.test(String(n.name || ''))) coreIds.push(n.id);
+      });
+    }
+    var level = {}, queue = [];
+    coreIds.forEach(function (id) { level[id] = 0; queue.push(id); });
+    while (queue.length) {
+      var cur = queue.shift();
+      (adj[cur] || []).forEach(function (nb) {
+        if (level[nb] == null) { level[nb] = level[cur] + 1; queue.push(nb); }
+      });
+    }
+    var nodeSet = {}, edgeSet = {}, stack = [nodeId];
+    nodeSet[nodeId] = true;
+    while (stack.length) {
+      var cur = stack.pop();
+      var curLv = level[cur];
+      if (curLv == null || curLv <= 0) continue;
+      (adj[cur] || []).forEach(function (nb) {
+        if (level[nb] !== curLv - 1) return;
+        // Cytoscape edges are anchored to hidden edgepoint nodes; the real
+        // switch pair is stored in p/c, so identify both orientations.
+        edgeSet[cur + '|' + nb] = true;
+        edgeSet[nb + '|' + cur] = true;
+        if (!nodeSet[nb]) { nodeSet[nb] = true; stack.push(nb); }
+      });
+    }
+    return {nodes: nodeSet, edges: edgeSet};
+  }
+
   function highlightPath(nodeId) {
     if (!cy) return;
-    var chain = [], id = nodeId, guard = 0;
-    while (id && guard++ < 40) {
-      chain.unshift(id);
-      id = pathParent[id] || null;
-    }
+    var up = upstreamGraph(nodeId);
     cy.elements('node[type="switch"], node[type="ghost"], edge').addClass('dim').removeClass('pathhl');
     var nodes = cy.collection();
-    chain.forEach(function (cid) { var n = cy.getElementById(cid); if (n && n.length) nodes = nodes.union(n); });
+    Object.keys(up.nodes).forEach(function (id) {
+      var n = cy.getElementById(id);
+      if (n && n.length) nodes = nodes.union(n);
+    });
     nodes.removeClass('dim').addClass('pathhl');
     var edges = cy.edges().filter(function (e) {
       var p = e.data('p'), c = e.data('c');
-      if (p && c) return chain.indexOf(p) >= 0 && chain.indexOf(c) >= 0;
-      var s = e.data('source'), t = e.data('target');
-      return chain.indexOf(s) >= 0 && chain.indexOf(t) >= 0;
+      return p && c && (up.edges[p + '|' + c] || up.edges[c + '|' + p]);
     });
     edges.removeClass('dim').addClass('pathhl');
   }
@@ -1736,6 +1917,7 @@
     el('l2-zin').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() * 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
     el('l2-zout').addEventListener('click', function () { if (cy) cy.zoom({level: cy.zoom() / 1.25, renderedPosition: {x: cy.width() / 2, y: cy.height() / 2}}); });
     el('l2-fit').addEventListener('click', function () { if (cy) cy.fit(cy.elements(), 50); });
+    if (el('l2-export')) el('l2-export').addEventListener('click', exportPng);
     el('l2-refresh-btn').addEventListener('click', function () { reload(true); });
     window.addEventListener('resize', fitViewport);
 

+ 3 - 0
web-modules/WecomCenter/locale/bg_BG.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/ca_ES.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/cs_CZ.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/da_DK.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/de_DE.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Bild exportieren',
+    'l2.exporting' => 'Wird exportiert…',
+    'l2.export_fail' => 'Export fehlgeschlagen, bitte erneut versuchen.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/el_GR.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/en.php

@@ -214,6 +214,9 @@ return [
 	'l2.auto_refresh'    => 'Auto refresh',
 	'l2.min'             => 'min',
 	'l2.refresh_now'     => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit'             => 'Fit to window',
 	'l2.alarms'          => 'Alarms',
 	'l2.offline'         => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/en_GB.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/es_ES.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Exportar imagen',
+    'l2.exporting' => 'Exportando…',
+    'l2.export_fail' => 'Error al exportar, inténtelo de nuevo.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/fa_IR.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/fi_FI.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/fr_FR.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Exporter l\'image',
+    'l2.exporting' => 'Export en cours…',
+    'l2.export_fail' => 'Échec de l\'export, veuillez réessayer.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/he_IL.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/hu_HU.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/id_ID.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/it_IT.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/ja_JP.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => '画像をエクスポート',
+    'l2.exporting' => 'エクスポート中…',
+    'l2.export_fail' => 'エクスポートに失敗しました。再試行してください。',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/ka_GE.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/ko_KR.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/lt_LT.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/lv_LV.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/nb_NO.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/nl_NL.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/pl_PL.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/pt_BR.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/ro_RO.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/ru_RU.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Экспорт изображения',
+    'l2.exporting' => 'Экспорт…',
+    'l2.export_fail' => 'Не удалось экспортировать, повторите попытку.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/sk_SK.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/sv_SE.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/tr_TR.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/uk_UA.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/uz_UZ.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/vi_VN.php

@@ -173,6 +173,9 @@ return [	'menu.title' => 'ROMAN Custom Center',
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => 'Export image',
+    'l2.exporting' => 'Exporting…',
+    'l2.export_fail' => 'Export failed, please retry.',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => 'Offline switches',

+ 3 - 0
web-modules/WecomCenter/locale/zh_CN.php

@@ -201,6 +201,9 @@ return [
 	'l2.auto_refresh'    => '自动刷新',
 	'l2.min'             => '分钟',
 	'l2.refresh_now'     => '立即刷新',
+    'l2.export_img' => '导出图片',
+    'l2.exporting' => '正在导出…',
+    'l2.export_fail' => '导出失败,请重试。',
 	'l2.fit'             => '适应窗口',
 	'l2.alarms'          => '告警',
 	'l2.offline'         => '离线交换机',

+ 3 - 0
web-modules/WecomCenter/locale/zh_TW.php

@@ -184,6 +184,9 @@ return [
 	'l2.auto_refresh' => 'Auto refresh',
 	'l2.min' => 'min',
 	'l2.refresh_now' => 'Refresh now',
+    'l2.export_img' => '匯出圖片',
+    'l2.exporting' => '正在匯出…',
+    'l2.export_fail' => '匯出失敗,請重試。',
 	'l2.fit' => 'Fit to window',
 	'l2.alarms' => 'Alarms',
 	'l2.offline' => '離線交換機',

+ 2 - 1
web-modules/WecomCenter/views/topology.view.php

@@ -63,6 +63,7 @@ if (!function_exists('l2e')) { function l2e($v) { return htmlspecialchars((strin
     <button class="l2-btn" id="l2-zin">+</button>
     <button class="l2-btn" id="l2-zout">-</button>
     <button class="l2-btn" id="l2-fit"><?php echo l2e(I18n::t('l2.fit')); ?></button>
+    <button class="l2-btn" id="l2-export"><?php echo l2e(I18n::t('l2.export_img')); ?></button>
   </div>
 
   <div class="l2-main">
@@ -106,7 +107,7 @@ window.L2_META = {
 };
 </script>
 <script src="<?php echo $base; ?>cytoscape.min.js"></script>
-<script src="<?php echo $base; ?>app.js?v=20261108"></script>
+<script src="<?php echo $base; ?>app.js?v=20261109"></script>
 <script>
 (function(){
   var tabs = document.querySelectorAll('.side-tab');