@linyanzhi/dsh-mesh 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,3196 @@
1
+ // dsh-mesh-ui — 浏览器半(client plugin bundle)v3
2
+ //
3
+ // 手写成 DSH 期望的形状:window.__ModuleLoader__.load({ id, factory(require) }),react 由平台提供。
4
+ // 设置窗口 settings.section(契约:id 必填 + label)→「AI 网络」:
5
+ //
6
+ // ┌ 头部:标题 / verdict / 版本 / 时间 / 刷新 / 复制诊断 / 原始 JSON
7
+ // ├ 画布(viewport):滚轮缩放(以鼠标为中心)、拖拽平移、双击复位、缩放按钮、自适应 fit
8
+ // │ 画布内 = 世界坐标(1080×680):本机居中 + peer 卡片 + 曲线连线 + 延迟标签
9
+ // └ 折叠面板:本机实例 / 委托台账 / 告警历史 / 诊断建议(默认收起,省垂直空间)
10
+ //
11
+ // 2026-11-06 v3 重做(用户反馈:v2 卡片/连线糟糕、设置面板窄导致内容看不见、缺缩放拖拽)。
12
+ //
13
+ // 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/)
14
+
15
+ window.__ModuleLoader__.load({
16
+ id: 'dsh-mesh-ui',
17
+ factory: (require) => {
18
+ var module = { exports: {} }
19
+ var exports = module.exports
20
+ var React = require('react')
21
+ var h = React.createElement
22
+ var useState = React.useState
23
+ var useEffect = React.useEffect
24
+ var useRef = React.useRef
25
+ var useCallback = React.useCallback
26
+
27
+ var API = '/api/dsh-mesh/status'
28
+ var REFRESH_MS = 5000
29
+ var SUPPORTS_ZOOM = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('zoom', '1.5')
30
+
31
+ // 2026-11-07 用户要求:卡片标题只用固定中文叫法,不显示电脑设备名
32
+ var NODE_LABELS = {
33
+ home: '个人电脑1号',
34
+ home1: '个人电脑1号',
35
+ home2: '个人电脑2号',
36
+ company: '公司电脑1号',
37
+ company2: '公司电脑2号',
38
+ 'company2-lan': '公司电脑2号',
39
+ local: '本机(local)',
40
+ phone1: '个人手机1号'
41
+ }
42
+ // 2026-11-09 用户要求:名称后显示设备类型。类型依据设备快照(CPU 形态)判断,可直接改这张表:
43
+ // peer-x Ryzen 7 5700U(移动 U)→ 笔记本;peer-x 7800X3D(桌面 U)→ 主机
44
+ // peer-x i5-8500(桌面 U)→ 主机;peer-x i5-8250U(移动 U)→ 笔记本;手机 → 手机
45
+ var DEVICE_TYPES = { home: '笔记本', home1: '笔记本', home2: '主机', company: '主机', company2: '笔记本', phone1: '手机' }
46
+ function deviceType(name) {
47
+ var raw = String(name || '').toLowerCase()
48
+ var key = peerDeviceKey(raw)
49
+ return DEVICE_TYPES[key] || DEVICE_TYPES[raw] || ''
50
+ }
51
+ function nodeDisplay(name) {
52
+ var label = nodeLabel(name)
53
+ var type = deviceType(name)
54
+ return type ? label + ' · ' + type : label
55
+ }
56
+ // 同一台设备可能有多个 peer 记录(不同 token/入口:peer-node / peer-node-lan / peer-node-exec)。
57
+ // AI 网络拓扑按「设备」展示:去掉后缀归并,取可达且延迟最优的那条。
58
+ var PEER_SUFFIX = /-(lan|wan|exec|exe|import|local|ctl)$/i
59
+ function peerDeviceKey(name) {
60
+ var n = String(name || '').trim().toLowerCase()
61
+ return n.replace(PEER_SUFFIX, '')
62
+ }
63
+ function dedupePeers(list) {
64
+ var byKey = {}
65
+ var order = []
66
+ ;(list || []).forEach(function (peer) {
67
+ var key = peerDeviceKey(peer.name)
68
+ if (!byKey[key]) { byKey[key] = peer; order.push(key); return }
69
+ var cur = byKey[key]
70
+ var better = false
71
+ if (!cur.up && peer.up) better = true
72
+ else if (Boolean(cur.up) === Boolean(peer.up)) {
73
+ var a = cur.latencyMs == null ? 1e9 : cur.latencyMs
74
+ var b = peer.latencyMs == null ? 1e9 : peer.latencyMs
75
+ if (b < a) better = true
76
+ else if (b === a && (peer.instancesCount || 0) > (cur.instancesCount || 0)) better = true
77
+ }
78
+ if (better) byKey[key] = peer
79
+ })
80
+ return order.map(function (k) { return byKey[k] })
81
+ }
82
+ function filterSelfPeers(list, nodeName) {
83
+ var selfKey = peerDeviceKey(nodeName)
84
+ return (list || []).filter(function (peer) { return !selfKey || peerDeviceKey(peer.name) !== selfKey })
85
+ }
86
+ function nodeLabel(name) { return NODE_LABELS[String(name || '').toLowerCase()] || String(name || '未知节点') }
87
+ // [layout-helpers-begin] 2026-11-09 拓扑自定义布局:节点 key 顺序 + localStorage 持久化 + clamp
88
+ var LAYOUT_STORE_KEY = 'dsh-mesh-ui.topology.layout.v1'
89
+ function topologyNodeKeys(data) {
90
+ var d = data || {}
91
+ var peers = filterSelfPeers(dedupePeers(d.peers || []), d.nodeName)
92
+ return ['local'].concat(peers.map(function (peer) { return 'peer:' + peer.name }))
93
+ }
94
+ // 2026-11-10:设备显示顺序(左→右 / 逆时针)——拓扑渲染与拖拽位置表必须共用同一顺序
95
+ var FAN_ORDER = ['home', 'home2', 'company', 'company2', 'phone1']
96
+ function fanRankOfNodeKey(key, data) {
97
+ var dev = key === 'local' ? peerDeviceKey(data && data.nodeName) : peerDeviceKey(String(key || '').replace(/^peer:/, ''))
98
+ var idx = FAN_ORDER.indexOf(dev)
99
+ return idx < 0 ? 99 : idx
100
+ }
101
+ function orderedNodeKeys(data) {
102
+ var keys = topologyNodeKeys(data).slice()
103
+ keys.sort(function (a, b) {
104
+ var d = fanRankOfNodeKey(a, data) - fanRankOfNodeKey(b, data)
105
+ return d !== 0 ? d : String(a).localeCompare(String(b))
106
+ })
107
+ return keys
108
+ }
109
+ // 2026-11-10:用户手工调整后认可的默认布局(世界坐标,10000×6000 画布内)
110
+ // 设备按设备名(home/peer-node/company/peer-node/peer-node)映射,各机通用;服务器两张卡单独一张表
111
+ var DEFAULT_DEVICE_POS = {
112
+ home: [4701.61, 3002.94],
113
+ home2: [4783.27, 3130.21],
114
+ company: [5016.63, 3153.48],
115
+ company2: [5255.63, 3132.50],
116
+ phone1: [5356.20, 3007.42]
117
+ }
118
+ var DEFAULT_INFRA_POS = {
119
+ aliyun: [4887.59, 2856.78],
120
+ sakura: [5177.39, 2858.40]
121
+ }
122
+ function devKeyOfNodeKey(key, data) {
123
+ return key === 'local' ? peerDeviceKey(data && data.nodeName) : peerDeviceKey(String(key || '').replace(/^peer:/, ''))
124
+ }
125
+ function loadMovedLayout() {
126
+ try {
127
+ var raw = window.localStorage.getItem(LAYOUT_STORE_KEY)
128
+ if (!raw) return {}
129
+ var obj = JSON.parse(raw)
130
+ return obj && typeof obj === 'object' ? obj : {}
131
+ } catch (_) { return {} }
132
+ }
133
+ function saveMovedLayout(obj) {
134
+ try { window.localStorage.setItem(LAYOUT_STORE_KEY, JSON.stringify(obj || {})) } catch (_) { /* ignore */ }
135
+ }
136
+ function clampNum(v, min, max) { return Math.max(min, Math.min(max, v)) }
137
+ // [layout-helpers-end]
138
+ // [relay-helpers-begin] 2026-11-09 架构层:识别公网中转站(frps)与通信方式(纯函数,可单测)
139
+ // 2026-11-10 隐私:默认不展示公网 IP/域名;局域网/回环地址保留。
140
+ // 用户在页头打开「显示公网地址」后,maskHost/maskText 直接返回原值。
141
+ var showPublicAddresses = false
142
+ function isPrivateNetworkHost(raw) {
143
+ var h = String(raw || '').trim().toLowerCase().replace(/^\[|\]$/g, '')
144
+ h = h.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, '')
145
+ if (!h) return true
146
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '127.0.0.1') return true
147
+ if (h.indexOf('.') < 0) return true // 主机名(DESKTOP-xxx / home2)
148
+ if (/(\.local|\.lan|\.home|\.internal)$/.test(h)) return true
149
+ var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
150
+ if (m) {
151
+ var a = Number(m[1]); var b = Number(m[2])
152
+ return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31)
153
+ }
154
+ return false
155
+ }
156
+ function maskHost(raw) {
157
+ var s = String(raw || '').trim()
158
+ if (showPublicAddresses) return s || '—'
159
+ if (!s) return '—'
160
+ var port = ''
161
+ var m = /^(.*):(\d+)$/.exec(s)
162
+ if (m && m[1].indexOf(']') < 0) { s = m[1]; port = ':' + m[2] }
163
+ return (isPrivateNetworkHost(s) ? s : '公网地址已隐藏') + port
164
+ }
165
+ function maskText(value) {
166
+ if (showPublicAddresses) return value
167
+ if (typeof value !== 'string') return value
168
+ return value
169
+ .replace(/https?:\/\/[^\s"'<>]+/gi, function (u) {
170
+ try { var x = new URL(u); return isPrivateNetworkHost(x.hostname) ? u : (x.protocol + '//公网地址已隐藏' + (x.port ? ':' + x.port : '')) } catch (e) { return u }
171
+ })
172
+ .replace(/\b(?!10\.|192\.168\.|127\.|172\.(?:1[6-9]|2\d|3[01])\.)(?:\d{1,3}\.){3}\d{1,3}\b/g, '公网地址已隐藏')
173
+ .replace(/\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|cn|top|xyz|me|cc|app|cloud)(?::\d+)?\b/gi, '公网地址已隐藏')
174
+ }
175
+ // 判定:同一公网 host 被 >=2 个 peer 使用 → 视为共享中转站;LAN/loopback 不算。
176
+ var RELAY_PUBLIC_KINDS = { wan: 1, frp: 1 }
177
+ function relayGroups(peers) {
178
+ var groups = {}
179
+ var order = []
180
+ ;(peers || []).forEach(function (peer) {
181
+ var t = peer.tunnel || {}
182
+ var host = String(t.host || '').trim().toLowerCase()
183
+ if (!host || !RELAY_PUBLIC_KINDS[t.kind]) return
184
+ var g = groups[host]
185
+ if (!g) { g = groups[host] = { host: t.host || host, hostKey: host, kind: t.kind, peers: [], ports: {}, up: 0 }; order.push(host) }
186
+ g.peers.push(peer)
187
+ if (t.port) g.ports[String(t.port)] = (g.ports[String(t.port)] || 0) + 1
188
+ if (peer.up) g.up++
189
+ })
190
+ return order.map(function (k) { return groups[k] })
191
+ .filter(function (g) { return g.peers.length >= 2 })
192
+ .sort(function (a, b) { return b.peers.length - a.peers.length })
193
+ }
194
+ function relayHostSet(peers) {
195
+ var set = {}
196
+ relayGroups(peers).forEach(function (g) { set[g.hostKey] = g })
197
+ return set
198
+ }
199
+ function commLabel(peer, relayHosts) {
200
+ var t = peer.tunnel || {}
201
+ var host = String(t.host || '').toLowerCase()
202
+ if (!host) return { text: '—', short: '—' }
203
+ var relay = relayHosts && relayHosts[host]
204
+ var port = t.port ? ':' + t.port : ''
205
+ if (relay) return { text: (relay.kind === 'frp' ? '域名穿透' : '自建 frps 中转') + ' · ' + maskHost(relay.host) + port, short: '中转' + (t.port ? ' ' + t.port : '') }
206
+ if (t.kind === 'loopback') return { text: '本机回环', short: '本机' }
207
+ if (t.kind === 'lan') return { text: '局域网直连 · ' + maskHost(t.host) + port, short: 'LAN' }
208
+ if (t.kind === 'frp') return { text: '公网穿透 · ' + maskHost(t.host) + port, short: '穿透' }
209
+ if (t.kind === 'wan') return { text: '公网直连 · ' + maskHost(t.host) + port, short: '公网' }
210
+ return { text: (t.label || t.kind || '未知') + ' · ' + maskHost(t.host) + port, short: t.label || '未知' }
211
+ }
212
+ // [relay-helpers-end]
213
+ var W = 560
214
+ var H = 400
215
+ var MIN_SCALE = 0.18
216
+ var MAX_SCALE = 2.6
217
+
218
+ var CSS = `
219
+ .dm-root { display:flex; flex-direction:column; gap:12px; font-size:13px; line-height:1.5; color:inherit; }
220
+ .dm-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
221
+ .dm-title { font-size:15.5px; font-weight:650; letter-spacing:.2px; }
222
+ .dm-sub { font-size:11.5px; opacity:.55; }
223
+ .dm-pill { padding:1px 9px; border-radius:999px; font-size:11.5px; font-weight:600; border:1px solid transparent; }
224
+ .dm-pill.ok { background:rgba(63,185,80,.14); color:#2e9e4f; border-color:rgba(63,185,80,.35); }
225
+ .dm-pill.bad { background:rgba(248,81,73,.14); color:#c9362c; border-color:rgba(248,81,73,.35); }
226
+ .dm-pill.warn { background:rgba(210,153,34,.16); color:#9a6c12; border-color:rgba(210,153,34,.4); }
227
+ .dm-pill.muted { background:rgba(128,128,128,.12); color:inherit; border-color:rgba(128,128,128,.28); opacity:.8; }
228
+ .dm-btn { font-size:11.5px; padding:3px 10px; border-radius:7px; border:1px solid rgba(128,128,128,.38); background:transparent; color:inherit; cursor:pointer; white-space:nowrap; }
229
+ .dm-btn:hover { background:rgba(128,128,128,.12); }
230
+ .dm-btn.primary { border-color:rgba(88,166,255,.55); color:#3b82d6; }
231
+
232
+ /* 画布视口 */
233
+ .dm-viewport { position:relative; width:100%; height:clamp(240px, 34vh, 380px); min-height:220px; overflow:hidden; border-radius:12px;
234
+ border:1px solid rgba(128,128,128,.22); background:
235
+ radial-gradient(ellipse at 50% 45%, rgba(88,166,255,.07), transparent 60%),
236
+ linear-gradient(rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px,
237
+ linear-gradient(90deg, rgba(128,128,128,.05) 1px, transparent 1px) 0 0/26px 26px;
238
+ cursor:grab; touch-action:none; user-select:none; }
239
+ .dm-viewport.dragging { cursor:grabbing; }
240
+ .dm-viewport.resizing { cursor:ns-resize; }
241
+ /* 2026-11-09:画布高度可拖拽调整 */
242
+ .dm-resize { position:absolute; left:50%; bottom:0; transform:translateX(-50%); width:64px; height:12px; z-index:6; cursor:ns-resize; display:flex; align-items:center; justify-content:center; border-radius:6px 6px 0 0; background:rgba(128,128,128,.18); color:rgba(128,128,128,.9); font-size:12px; line-height:1; user-select:none; }
243
+ .dm-resize:hover { background:rgba(88,166,255,.28); color:#7fb4ff; }
244
+ /* 2026-11-09:悬浮节点高亮 + 连线上显示完整地址 */
245
+ .dm-edge-label { paint-order: stroke; stroke: rgba(15,17,21,.9); stroke-width: 3px; font-family: ui-monospace,Consolas,monospace; pointer-events: none; }
246
+ body.light .dm-edge-label { stroke: rgba(255,255,255,.92); }
247
+ .dm-card-node.hovered { border-color: rgba(88,166,255,.95) !important; box-shadow: 0 0 0 2px rgba(88,166,255,.35), 0 10px 26px rgba(0,0,0,.25); z-index: 5; }
248
+ .dm-world-outer { position:absolute; left:0; top:0; }
249
+ .dm-world { position:absolute; left:0; top:0; transform-origin:0 0; }
250
+ .dm-lines { position:absolute; left:0; top:0; overflow:visible; pointer-events:none; }
251
+ .dm-world-label { position:absolute; left:10px; top:8px; font-size:11px; opacity:.5; font-variant-numeric:tabular-nums; }
252
+
253
+ /* 节点卡片(世界坐标里) */
254
+ .dm-card-node { position:absolute; box-sizing:border-box; overflow:visible; border-radius:12px; padding:10px 12px;
255
+ border:1px solid rgba(128,128,128,.3); background:rgba(128,128,128,.06); color:inherit; box-shadow:0 6px 22px rgba(0,0,0,.10); }
256
+ .dm-card-node.local { border-width:1.6px; border-color:rgba(88,166,255,.55); background:rgba(88,166,255,.10); }
257
+ .dm-card-node.down { border-style:dashed; border-color:rgba(248,81,73,.6); background:rgba(248,81,73,.07); }
258
+ .dm-card-node.down .dm-n-name { opacity:1; }
259
+ .dm-card-node.down .dm-card-domain, .dm-card-node.down .dm-card-dsh { opacity:.62; }
260
+ .dm-card-node.down .dm-v, .dm-card-node.down .dm-big, .dm-card-node.down .dm-extra { opacity:.6; }
261
+ .dm-card-node.down .dm-chip { opacity:.72; }
262
+ .dm-offline-row { margin-top:5px; font-size:10.5px; color:#c9362c; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
263
+ .dm-n-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
264
+ .dm-n-name { font-weight:650; font-size:13px; display:flex; align-items:center; gap:7px; min-width:0; }
265
+ .dm-n-name span.dm-ell { overflow:visible; text-overflow:clip; white-space:nowrap; }
266
+ .dm-dot { width:9px; height:9px; border-radius:50%; display:inline-block; background:#8b949e; flex:none; }
267
+ .dm-dot.ok { background:#2e9e4f; box-shadow:0 0 0 3px rgba(63,185,80,.18); }
268
+ .dm-dot.bad { background:#d14b41; box-shadow:0 0 0 3px rgba(248,81,73,.18); }
269
+ .dm-big { font-size:17px; font-weight:650; font-variant-numeric:tabular-nums; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
270
+ .dm-unit { font-size:11px; opacity:.55; margin-left:2px; }
271
+ .dm-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:5px; font-size:11.5px; }
272
+ .dm-k { opacity:.58; font-size:11px; }
273
+ .dm-v { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11.5px; font-variant-numeric:tabular-nums; }
274
+ .dm-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
275
+ .dm-mini { display:flex; gap:4px; flex-wrap:wrap; margin-top:6px; }
276
+ .dm-extra { font-size:11px; opacity:.6; font-variant-numeric:tabular-nums; }
277
+ .dm-card-node.compact { padding:9px 11px; cursor:pointer; transition:border-color .12s, box-shadow .12s; }
278
+ .dm-card-node.selected { border-color:rgba(88,166,255,.85) !important; box-shadow:0 0 0 2px rgba(88,166,255,.35), 0 6px 18px rgba(0,0,0,.12) !important; }
279
+ .dm-card-domain { margin-top:6px; font-size:10px; opacity:.72; overflow:visible; text-overflow:clip; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
280
+ .dm-card-dsh { margin-top:6px; }
281
+ /* 2026-11-09-v6:详情卡列数自适应(宽屏 3 列),不再固定 2 列 */
282
+ .dm-devices { display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:10px; align-items:start; }
283
+ /* 2026-11-09:三通道筛选浮层(画布内左上) */
284
+ .dm-topo-wrap { display:flex; flex-direction:column; gap:6px; }
285
+ .dm-topbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
286
+ .dm-topbar-info { font-size:10.5px; opacity:.55; margin-left:auto; white-space:nowrap; }
287
+ .dm-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
288
+ .dm-flt { display:flex; align-items:center; gap:5px; font-size:11.5px; opacity:.55; border:1px solid rgba(128,128,128,.3); border-radius:999px; padding:1px 7px; cursor:pointer; user-select:none; }
289
+ .dm-flt.on { opacity:1; border-color:var(--c); }
290
+ .dm-flt input { margin:0; accent-color:var(--c); }
291
+ .dm-card-node.server { border-radius:12px; background:rgba(128,128,128,.06); }
292
+ .dm-chip.type { border-color:rgba(88,166,255,.5); color:#7fb4ff; }
293
+ /* 2026-11-09:三通道虚线流动动画(位移量必须是 dash 周期 11px 的整数倍,否则循环会抽搐) */
294
+ @keyframes dm-dashflow { to { stroke-dashoffset: -22; } }
295
+ .dm-lines path.dm-edge-frp { animation: dm-dashflow 1.15s linear infinite; stroke-linecap: butt; }
296
+ @media (prefers-reduced-motion: reduce) { .dm-lines path.dm-edge-frp { animation: none; } }
297
+ .dm-infra-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); gap:10px; margin-top:10px; }
298
+ .dm-infra-card { border:1px solid rgba(128,128,128,.3); border-radius:11px; padding:9px 11px; background:rgba(128,128,128,.05); }
299
+ .dm-infra-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:5px; }
300
+ .dm-device { border:1px solid rgba(128,128,128,.22); border-radius:11px; padding:9px 11px; background:rgba(128,128,128,.035); min-width:0; overflow:hidden; }
301
+ .dm-device.selected { border-color:rgba(88,166,255,.6); background:rgba(88,166,255,.07); }
302
+ .dm-device-head { display:flex; align-items:center; gap:7px; font-weight:650; font-size:12.5px; margin-bottom:6px; }
303
+ .dm-device-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
304
+ .dm-device-body { display:flex; flex-direction:column; gap:3px; }
305
+ .dm-device-row { display:flex; align-items:baseline; justify-content:space-between; gap:10px; font-size:11px; min-width:0; }
306
+ .dm-device-k { opacity:.55; flex:0 0 auto; }
307
+ .dm-device-v { flex:1 1 auto; min-width:0; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
308
+ @media (max-width:720px) { .dm-devices { grid-template-columns:1fr; } }
309
+ .dm-fields { display:grid; grid-template-columns:1fr 1fr; gap:4px 10px; margin-top:6px; }
310
+ .dm-field { min-width:0; }
311
+ .dm-field-k { font-size:9.5px; opacity:.5; line-height:1.2; }
312
+ .dm-field-v { font-size:11px; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-variant-numeric:tabular-nums; }
313
+ .dm-tunnel { margin-top:6px; font-size:10px; opacity:.7; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
314
+ .dm-chip { font-size:9.5px; padding:0 6px; border-radius:999px; border:1px solid rgba(128,128,128,.3); opacity:.85; white-space:nowrap; }
315
+ .dm-chip.run { border-color:rgba(63,185,80,.45); color:#2e9e4f; opacity:1; }
316
+ .dm-chip.warn { border-color:rgba(210,153,34,.5); color:#9a6c12; opacity:1; }
317
+ .dm-chip.bad { border-color:rgba(248,81,73,.5); color:#c9362c; opacity:1; }
318
+ .dm-chip.ok { border-color:rgba(63,185,80,.4); color:#2e9e4f; opacity:1; }
319
+
320
+ /* 画布浮层控件 */
321
+ .dm-zoom { position:absolute; right:8px; bottom:8px; display:flex; gap:5px; padding:4px; border-radius:9px;
322
+ background:rgba(128,128,128,.12); backdrop-filter:blur(4px); z-index:5; }
323
+ .dm-zoom button { width:22px; height:22px; border-radius:6px; border:1px solid rgba(128,128,128,.3); background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1; padding:0; display:flex; align-items:center; justify-content:center; }
324
+ .dm-zoom button:hover { background:rgba(128,128,128,.16); }
325
+ .dm-zoom .pct { font-size:10.5px; opacity:.6; align-self:center; padding:0 2px; font-variant-numeric:tabular-nums; }
326
+ .dm-hint { font-size:10.5px; opacity:.45; padding:0 2px; }
327
+
328
+ /* 折叠面板 */
329
+ .dm-details { border:1px solid rgba(128,128,128,.22); border-radius:11px; background:rgba(128,128,128,.035); }
330
+ .dm-details > summary { cursor:pointer; padding:9px 12px; font-weight:600; font-size:12.5px; list-style:none; display:flex; align-items:center; gap:8px; }
331
+ .dm-details > summary::-webkit-details-marker { display:none; }
332
+ .dm-details > summary::before { content:'▸'; font-size:10px; opacity:.5; transition:transform .12s; }
333
+ .dm-details[open] > summary::before { transform:rotate(90deg); }
334
+ .dm-details > .dm-details-body { padding:0 12px 11px; }
335
+ .dm-table { width:100%; border-collapse:collapse; }
336
+ .dm-table th { text-align:left; font-weight:600; font-size:11px; opacity:.5; padding:2px 8px 5px 0; border-bottom:1px solid rgba(128,128,128,.2); white-space:nowrap; }
337
+ .dm-table td { padding:5px 8px 5px 0; border-bottom:1px solid rgba(128,128,128,.1); font-size:12px; }
338
+ .dm-table tr:last-child td { border-bottom:none; }
339
+ /* 2026-11-10:委托台账表改为固定列宽 + 单行省略;非固定布局下 5 列会把「节点/任务」压到
340
+ 每行一个字,就是用户看到的竖排。完整内容看 title 悬浮提示。 */
341
+ .dm-details .dm-table { table-layout:fixed; min-width:420px; }
342
+ .dm-table-wrap { overflow-x:auto; }
343
+ .dm-details .dm-table th, .dm-details .dm-table td { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
344
+ .dm-details .dm-table th:nth-child(1), .dm-details .dm-table td:nth-child(1) { width:16%; }
345
+ .dm-details .dm-table th:nth-child(2), .dm-details .dm-table td:nth-child(2) { width:18%; }
346
+ .dm-details .dm-table th:nth-child(3), .dm-details .dm-table td:nth-child(3) { width:22%; }
347
+ .dm-details .dm-table th:nth-child(4), .dm-details .dm-table td:nth-child(4) { width:10%; }
348
+ /* 目标列不写死百分比:让它吃掉剩余空间(单元格右内边距不参与百分比,写死 34% 会溢出 1 条横向滚动条) */
349
+ .dm-trunc { max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:inline-block; vertical-align:bottom; }
350
+ .dm-instrow { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:6px 0; border-bottom:1px solid rgba(128,128,128,.1); }
351
+ .dm-instrow:last-child { border-bottom:none; }
352
+ .dm-alertrow { display:flex; gap:8px; align-items:baseline; padding:5px 0; border-bottom:1px dashed rgba(128,128,128,.16); font-size:12px; }
353
+ .dm-alertrow:last-child { border-bottom:none; }
354
+ .dm-empty { opacity:.55; font-size:12px; padding:4px 0; }
355
+ .dm-err { color:#c9362c; font-size:12px; }
356
+ .dm-foot { font-size:11px; opacity:.45; }
357
+ @media (prefers-color-scheme: dark) {
358
+ .dm-pill.ok { color:#3fb950; } .dm-pill.bad { color:#ff7b72; } .dm-pill.warn { color:#d29922; }
359
+ .dm-btn.primary { color:#79b8ff; }
360
+ .dm-card-node { background:#1d2025; box-shadow:0 8px 26px rgba(0,0,0,.45); }
361
+ .dm-card-node.local { background:#19222e; }
362
+ .dm-card-node.down { background:rgba(90,32,32,.45); border-color:rgba(248,81,73,.65); }
363
+ .dm-offline-row { color:#ff7b72; }
364
+ .dm-dot.ok { background:#3fb950; } .dm-dot.bad { background:#ff7b72; }
365
+ .dm-chip.run, .dm-chip.ok { color:#3fb950; } .dm-chip.warn { color:#d29922; } .dm-chip.bad { color:#ff7b72; }
366
+ .dm-err { color:#ff7b72; }
367
+ .dm-details { background:rgba(255,255,255,.025); }
368
+ }
369
+ /* 2026-11-09 架构层:公网中转站(frps) */
370
+ /* 2026-11-09 拓扑自定义布局:卡片可拖拽 */
371
+ .dm-card-node.draggable { cursor:grab; touch-action:none; }
372
+ .dm-card-node.draggable:active { cursor:grabbing; }
373
+ .dm-card-node.dragging { z-index:20; border-color:rgba(88,166,255,.85) !important; box-shadow:0 12px 34px rgba(0,0,0,.30) !important; }
374
+ .dm-layout-btn.active { border-color:rgba(88,166,255,.6); color:#3b82d6; }
375
+ .dm-relay { border:1px solid rgba(128,128,128,.22); border-radius:12px; padding:10px 12px; background:rgba(128,128,128,.035); display:flex; flex-direction:column; gap:10px; }
376
+ .dm-relay-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
377
+ .dm-relay-title { font-size:12.5px; font-weight:650; }
378
+ .dm-relay-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:8px; border:1px dashed rgba(128,128,128,.26); border-radius:10px; }
379
+ .dm-relay-end { min-width:120px; display:flex; flex-direction:column; gap:2px; }
380
+ .dm-relay-name { font-size:12px; font-weight:650; }
381
+ .dm-relay-sub { font-size:10.5px; opacity:.55; }
382
+ .dm-relay-hub { min-width:190px; display:flex; flex-direction:column; gap:4px; padding:8px 10px; border:1.4px solid rgba(210,153,34,.55); border-radius:10px; background:rgba(210,153,34,.10); }
383
+ .dm-relay-chips { display:flex; gap:5px; flex-wrap:wrap; }
384
+ .dm-relay-link { display:flex; align-items:center; gap:5px; min-width:56px; color:rgba(128,128,128,.75); }
385
+ .dm-relay-line { flex:1 1 auto; height:0; border-top:1.6px dashed rgba(63,185,80,.55); }
386
+ .dm-relay-dirlabel { font-size:10px; opacity:.6; white-space:nowrap; }
387
+ .dm-relay-peers { display:flex; flex-direction:column; gap:4px; min-width:150px; flex:1 1 auto; }
388
+ .dm-relay-peer { display:flex; align-items:center; gap:6px; font-size:11.5px; }
389
+ .dm-relay-peer.down { opacity:.55; }
390
+ .dm-relay-peer-name { font-weight:600; }
391
+ .dm-relay-peer-meta { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:10.5px; opacity:.7; }
392
+ .dm-relay-direct { display:flex; align-items:center; gap:6px; flex-wrap:wrap; font-size:11px; }
393
+ .dm-relay-tag { margin-top:5px; font-size:10px; opacity:.75; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
394
+ `
395
+
396
+ function fetchStatus(force) {
397
+ return fetch(force ? API + '?force=1' : API, { headers: { accept: 'application/json' } }).then(function (res) { return res.json() })
398
+ }
399
+ function tone(verdict) {
400
+ if (verdict === 'ok') return 'ok'
401
+ if (verdict === 'down') return 'bad'
402
+ if (verdict === 'degraded') return 'warn'
403
+ return 'muted'
404
+ }
405
+ function ago(iso) {
406
+ if (!iso) return '—'
407
+ var diff = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
408
+ if (diff < 60) return diff + ' 秒前'
409
+ if (diff < 3600) return Math.round(diff / 60) + ' 分钟前'
410
+ if (diff < 86400) return Math.round(diff / 3600) + ' 小时前'
411
+ return Math.round(diff / 86400) + ' 天前'
412
+ }
413
+ function clockText(iso) {
414
+ if (!iso) return '—'
415
+ var d = new Date(iso)
416
+ if (isNaN(d.getTime())) return '—'
417
+ function p2(n) { return (n < 10 ? '0' : '') + n }
418
+ return p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds())
419
+ }
420
+ function uptimeText(sec) {
421
+ if (sec == null) return '—'
422
+ var s = Math.max(0, Number(sec))
423
+ var days = Math.floor(s / 86400)
424
+ var hours = Math.floor((s % 86400) / 3600)
425
+ var mins = Math.floor((s % 3600) / 60)
426
+ if (days > 0) return days + '天' + hours + '小时'
427
+ if (hours > 0) return hours + '小时' + mins + '分'
428
+ return mins + '分'
429
+ }
430
+ function dur(sec) {
431
+ if (sec == null) return '—'
432
+ if (sec < 60) return sec + 's'
433
+ if (sec < 3600) return Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
434
+ return Math.floor(sec / 3600) + 'h' + Math.floor((sec % 3600) / 60) + 'm'
435
+ }
436
+ function stateChip(state) {
437
+ // 设置页表格里的状态也统一中文
438
+ var map = { done: 'ok', failed: 'bad', cancelled: 'muted', stalled: 'warn', working: 'run', question: 'warn', submitted: 'muted', accepted: 'muted' }
439
+ return h('span', { className: 'dm-chip ' + (map[state] || 'muted') }, DG_STATE_LABELS[state] || state || '未知')
440
+ }
441
+
442
+ function Spark(props) {
443
+ var values = props.values || []
444
+ var pts = values.filter(function (v) { return v != null })
445
+ if (pts.length < 2) return h('span', { className: 'dm-sub' }, '—')
446
+ var min = Math.min.apply(null, pts)
447
+ var max = Math.max.apply(null, pts)
448
+ var span = max - min || 1
449
+ var SW = 54
450
+ var SH = 15
451
+ var d = []
452
+ var open = false
453
+ values.forEach(function (v, i) {
454
+ if (v == null) { open = false; return }
455
+ var x = (i / Math.max(1, values.length - 1)) * SW
456
+ var y = SH - ((v - min) / span) * (SH - 4) - 2
457
+ d.push((open ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1))
458
+ open = true
459
+ })
460
+ return h('svg', { width: SW, height: SH, viewBox: '0 0 ' + SW + ' ' + SH, style: { overflow: 'visible' } },
461
+ h('path', { d: d.join(' '), fill: 'none', stroke: props.up ? '#2e9e4f' : '#d14b41', strokeWidth: 1.6, strokeLinejoin: 'round', strokeLinecap: 'round' })
462
+ )
463
+ }
464
+
465
+ // ---------- 画布:缩放 / 平移 ----------
466
+ // 2026-11-10:当前筛选下可见的服务器数量(0/1/2),用于拓扑布局
467
+ function visibleServerCount(data, show) {
468
+ var s = show || { lan: true, public: true, sakura: true }
469
+ var d = data || {}
470
+ var peers = filterSelfPeers(dedupePeers(d.peers || []), d.nodeName)
471
+ var hasPublic = Boolean(publicEntryFor(d.localTunnel || {})) || peers.some(function (p) { var pt = p.tunnel || {}; return !isLanPeer(p) && Boolean(pt.host) && (pt.kind === 'frp' || pt.kind === 'wan') })
472
+ var acc = sakuraAccountOf(d)
473
+ var hasSakura = Boolean(acc && ((acc.tunnels || []).length || acc.error))
474
+ return (s.public && hasPublic ? 1 : 0) + (s.sakura && hasSakura ? 1 : 0)
475
+ }
476
+
477
+ function Viewport(props) {
478
+ var layout = props.layout
479
+ // 2026-11-10:按当前筛选的可见服务器数量决定布局(0/1/2)
480
+ var currentFilters = (function () { try { var s = JSON.parse(window.localStorage.getItem('dsh-mesh-ui.filters.v1') || '{}') || {}; return { lan: s.lan !== false, public: s.public !== false, sakura: s.sakura !== false } } catch (_) { return { lan: true, public: true, sakura: true } } })()
481
+ // 2026-11-10:布局服务器数使用快照;筛选切换只隐藏服务器,不触发重排。
482
+ var layoutServerState = useState(function () { return visibleServerCount(props.data, currentFilters) })
483
+ var layoutServerCount = layoutServerState[0]
484
+ var setLayoutServerCount = layoutServerState[1]
485
+ // 2026-11-09-v5:卡片宽度自适应 —— 子组件测量真实卡片宽高后回传,按实际尺寸重算多边形布局
486
+ var sizeState = useState(null)
487
+ var measuredSize = sizeState[0]
488
+ var setMeasuredSize = sizeState[1]
489
+ if (measuredSize && measuredSize.w > 0 && measuredSize.h > 0) {
490
+ layout = computeLayout(layout.count, { cardW: measuredSize.w, cardH: measuredSize.h, serverCount: layoutServerCount })
491
+ } else {
492
+ layout = computeLayout(layout.count, { serverCount: layoutServerCount })
493
+ }
494
+ var infraBoundsRef = useRef([])
495
+ // 2026-11-09:重置拓扑时同时通知 Topology 复位服务器卡片
496
+ var resetNonceState = useState(0)
497
+ var resetNonce = resetNonceState[0]
498
+ var bumpResetNonce = resetNonceState[1]
499
+ function onInfraBounds(boxes) { infraBoundsRef.current = boxes || [] }
500
+ function onCardSize(w, h) {
501
+ setMeasuredSize(function (prev) {
502
+ if (prev && prev.w === w && prev.h === h) return prev
503
+ return { w: w, h: h }
504
+ })
505
+ }
506
+ // 2026-11-09 自定义布局:moved[key] = [x,y](绝对世界坐标;localStorage 持久化,重置清空)
507
+ // 2026-11-09-v2:用户反馈「拖不远」→ 世界画布固定放大(可拖很开),自适应按实际内容 bbox 缩放,
508
+ // 而不是按初始画布尺寸;卡片只能在世界边界内移动(防止拖丢),但世界足够大。
509
+ var WORLD_MIN_W = 10000
510
+ var WORLD_MIN_H = 6000
511
+ var WORLD_PAD = 16
512
+ var nodeKeys = orderedNodeKeys(props.data)
513
+ var layoutState = useState(loadMovedLayout)
514
+ var movedLayout = layoutState[0]
515
+ var setMovedLayout = layoutState[1]
516
+ var worldW = Math.max(layout.W, WORLD_MIN_W)
517
+ var worldH = Math.max(layout.H, WORLD_MIN_H)
518
+ var baseOffsetX = (worldW - layout.W) / 2
519
+ var baseOffsetY = (worldH - layout.H) / 2
520
+ var positions = layout.positions.map(function (p, idx) {
521
+ var m = movedLayout[nodeKeys[idx]]
522
+ if (m && m.length === 2) return [Number(m[0]), Number(m[1])]
523
+ var devKey = devKeyOfNodeKey(nodeKeys[idx], props.data)
524
+ var def = (layout.mode === 'servers-top' || layout.mode === 'servers-top-single') ? DEFAULT_DEVICE_POS[devKey] : null
525
+ if (def) return [def[0], def[1]]
526
+ return [p[0] + baseOffsetX, p[1] + baseOffsetY]
527
+ })
528
+ // 2026-11-10:node positions 会被平移到世界画布中心;服务器中心也必须加同一偏移,否则单服务器布局会隔半个画布
529
+ var worldServerCenter = layout.serverCenter ? [layout.serverCenter[0] + baseOffsetX, layout.serverCenter[1] + baseOffsetY] : null
530
+ var effLayout = Object.assign({}, layout, { W: worldW, H: worldH, positions: positions, baseOffsetX: baseOffsetX, baseOffsetY: baseOffsetY, contentW: layout.W, contentH: layout.H, serverCenter: worldServerCenter })
531
+ function moveNode(key, pos) {
532
+ setMovedLayout(function (prev) {
533
+ var next = Object.assign({}, prev)
534
+ next[key] = pos
535
+ saveMovedLayout(next)
536
+ return next
537
+ })
538
+ }
539
+ function resetLayout() {
540
+ setMovedLayout({})
541
+ saveMovedLayout({})
542
+ // 只有点重置时才读取当前勾选服务器并重排;筛选本身不重绘
543
+ setLayoutServerCount(visibleServerCount(props.data, currentFilters))
544
+ // 2026-11-10:重置只恢复拓扑结构,保留用户手动拖拽的画布高度
545
+ interactedRef.current = false
546
+ bumpResetNonce(function (x) { return x + 1 })
547
+ setTimeout(fit, 160)
548
+ }
549
+ // 2026-11-09-v3:拖卡片到画布边缘时自动平移视角(不用手动挪画面就能拖很远)
550
+ function autoPanAt(clientX, clientY) {
551
+ var el = viewRef.current
552
+ if (!el) return
553
+ var rect = el.getBoundingClientRect()
554
+ var MARGIN = 60
555
+ var SPEED = 16
556
+ var dx = 0
557
+ var dy = 0
558
+ if (clientX < rect.left + MARGIN) dx = SPEED * Math.min(1, (rect.left + MARGIN - clientX) / MARGIN)
559
+ else if (clientX > rect.right - MARGIN) dx = -SPEED * Math.min(1, (clientX - (rect.right - MARGIN)) / MARGIN)
560
+ if (clientY < rect.top + MARGIN) dy = SPEED * Math.min(1, (rect.top + MARGIN - clientY) / MARGIN)
561
+ else if (clientY > rect.bottom - MARGIN) dy = -SPEED * Math.min(1, (clientY - (rect.bottom - MARGIN)) / MARGIN)
562
+ if (!dx && !dy) return
563
+ interactedRef.current = true
564
+ setView(function (prev) { return { scale: prev.scale, x: prev.x + dx, y: prev.y + dy } })
565
+ }
566
+ var viewRef = useRef(null)
567
+ // 2026-11-09:画布高度可上下拖拽调整(持久化;双击恢复默认)
568
+ var heightState = useState(function () { try { var v = Number(window.localStorage.getItem('dsh-mesh-ui.viewportH.v1') || 0); return v > 0 ? v : 0 } catch (e) { return 0 } })
569
+ var customHeight = heightState[0]
570
+ var setCustomHeight = heightState[1]
571
+ // 2026-11-10:画布默认高度自动跟随内容(避免固定 34vh 导致"拓扑区域又高又空");手动拖过则以手动为准
572
+ var autoHState = useState(0)
573
+ var autoH = autoHState[0]
574
+ var setAutoH = autoHState[1]
575
+ var customHeightRef = useRef(0)
576
+ var resizeRef = useRef(null)
577
+ var resizingState = useState(false); var resizing = resizingState[0]; var setResizing = resizingState[1]
578
+ function onResizeDown(e) {
579
+ if (e.button !== 0) return
580
+ e.preventDefault()
581
+ e.stopPropagation()
582
+ var el = viewRef.current
583
+ if (!el) return
584
+ resizeRef.current = { sy: e.clientY, h: el.getBoundingClientRect().height }
585
+ interactedRef.current = true
586
+ setResizing(true)
587
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) { /* ignore */ }
588
+ }
589
+ function onResizeMove(e) {
590
+ var r = resizeRef.current
591
+ if (!r) return
592
+ e.stopPropagation()
593
+ var h2 = Math.max(240, Math.min(1600, Math.round(r.h + (e.clientY - r.sy))))
594
+ setCustomHeight(h2)
595
+ try { window.localStorage.setItem('dsh-mesh-ui.viewportH.v1', String(h2)) } catch (_) { /* ignore */ }
596
+ }
597
+ function onResizeUp(e) {
598
+ if (!resizeRef.current) return
599
+ e.stopPropagation()
600
+ resizeRef.current = null
601
+ setResizing(false)
602
+ interactedRef.current = false
603
+ setTimeout(fit, 0)
604
+ }
605
+ function resetHeight() {
606
+ setCustomHeight(0)
607
+ try { window.localStorage.removeItem('dsh-mesh-ui.viewportH.v1') } catch (_) { /* ignore */ }
608
+ setTimeout(fit, 0)
609
+ }
610
+ var dragRef = useRef(null)
611
+ var interactedRef = useRef(false)
612
+ var s0 = useState({ scale: 1, x: 0, y: 0 }); var view = s0[0]; var setView = s0[1]
613
+ var s1 = useState(false); var dragging = s1[0]; var setDragging = s1[1]
614
+ // 2026-11-09:三通道筛选(局域网/公网/樱花)
615
+ // 2026-11-10:筛选状态持久化;刷新/切页面后不再丢。
616
+ var FILTER_STORE_KEY = 'dsh-mesh-ui.filters.v1'
617
+ var filterState = useState(function () {
618
+ try {
619
+ var saved = JSON.parse(window.localStorage.getItem(FILTER_STORE_KEY) || '{}') || {}
620
+ return { lan: saved.lan !== false, public: saved.public !== false, sakura: saved.sakura !== false }
621
+ } catch (_) { return { lan: true, public: true, sakura: true } }
622
+ })
623
+ var showFilters = filterState[0]
624
+ var setShowFilters = filterState[1]
625
+ function toggleFilter(k) {
626
+ setShowFilters(function (prev) {
627
+ var n = Object.assign({}, prev)
628
+ n[k] = !n[k]
629
+ try { window.localStorage.setItem(FILTER_STORE_KEY, JSON.stringify(n)) } catch (_) { /* ignore */ }
630
+ return n
631
+ })
632
+ }
633
+ // fit 用最新布局的 ref(拖拽中不重算视角;只有初始化 / 窗口变化 / ⟲ 重置 / ⤢ 自适应才重算)
634
+ var effRef = useRef(effLayout)
635
+ effRef.current = effLayout
636
+
637
+ var fit = useCallback(function () {
638
+ var el = viewRef.current
639
+ if (!el) return
640
+ var rect = el.getBoundingClientRect()
641
+ if (!rect.width || !rect.height) return
642
+ var cur = effRef.current || layout
643
+ var halfW = cur.cardW / 2
644
+ var halfH = cur.cardH / 2
645
+ var xs = []
646
+ var ys = []
647
+ ;(cur.positions || []).forEach(function (p) { xs.push(p[0]); ys.push(p[1]) })
648
+ if (!xs.length) { xs = [cur.W / 2]; ys = [cur.H / 2] }
649
+ var minX = Math.min.apply(null, xs) - halfW - WORLD_PAD
650
+ var maxX = Math.max.apply(null, xs) + halfW + WORLD_PAD
651
+ var minY = Math.min.apply(null, ys) - halfH - WORLD_PAD
652
+ var maxY = Math.max.apply(null, ys) + halfH + WORLD_PAD
653
+ // 2026-11-09:中转服务器卡片(阿里云/樱花)位置由 Topology 维护,按真实包围盒纳入自适应
654
+ ;(infraBoundsRef.current || []).forEach(function (b) {
655
+ if (!b) return
656
+ minX = Math.min(minX, b.x - b.w / 2 - WORLD_PAD)
657
+ maxX = Math.max(maxX, b.x + b.w / 2 + WORLD_PAD)
658
+ minY = Math.min(minY, b.y - b.h / 2 - WORLD_PAD)
659
+ maxY = Math.max(maxY, b.y + b.h / 2 + WORLD_PAD)
660
+ })
661
+ var bw = Math.max(1, maxX - minX)
662
+ var bh = Math.max(1, maxY - minY)
663
+ var scale = Math.min(rect.width / bw, rect.height / bh) * 0.94
664
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale))
665
+ setView({ scale: scale, x: (rect.width - bw * scale) / 2 - minX * scale, y: (rect.height - bh * scale) / 2 - minY * scale })
666
+ // 画布默认高度 = 内容渲染高度 + 余量(仅当用户没手动调过高度)
667
+ if (!customHeightRef.current) {
668
+ var wantH = Math.max(150, Math.min(380, Math.round(bh * scale + 64)))
669
+ setAutoH(function (prev) { return Math.abs(prev - wantH) >= 10 ? wantH : prev })
670
+ }
671
+ }, [layout.W, layout.H])
672
+
673
+ useEffect(function () {
674
+ fit()
675
+ var el = viewRef.current
676
+ if (!el || typeof ResizeObserver === 'undefined') return
677
+ var ro = new ResizeObserver(function () {
678
+ if (!interactedRef.current) fit()
679
+ })
680
+ ro.observe(el)
681
+ return function () { try { ro.disconnect() } catch (_) { /* ignore */ } }
682
+ }, [fit])
683
+
684
+ // 2026-11-10:布局变化(节点数/卡片尺寸)后自动 fit(用户未手动缩放/平移时),避免停留在旧的缩放比例
685
+ useEffect(function () {
686
+ if (!interactedRef.current) setTimeout(fit, 40)
687
+ }, [layout.count, layout.cardW, layout.cardH, layout.mode])
688
+
689
+ useEffect(function () {
690
+ var el = viewRef.current
691
+ if (!el) return
692
+ function onWheel(event) {
693
+ event.preventDefault()
694
+ var rect = el.getBoundingClientRect()
695
+ var px = event.clientX - rect.left
696
+ var py = event.clientY - rect.top
697
+ var factor = Math.pow(1.0015, -event.deltaY)
698
+ interactedRef.current = true
699
+ setView(function (prev) {
700
+ var next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * factor))
701
+ var k = next / prev.scale
702
+ return { scale: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k }
703
+ })
704
+ }
705
+ el.addEventListener('wheel', onWheel, { passive: false })
706
+ return function () { el.removeEventListener('wheel', onWheel) }
707
+ }, [])
708
+
709
+ function onPointerDown(event) {
710
+ if (event.button !== 0) return
711
+ if (event.target && event.target.closest && event.target.closest('button,a,summary,.dm-filters,.dm-zoom,.dm-resize')) return
712
+ dragRef.current = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y }
713
+ setDragging(true)
714
+ event.currentTarget.setPointerCapture && event.currentTarget.setPointerCapture(event.pointerId)
715
+ }
716
+ function onPointerMove(event) {
717
+ var drag = dragRef.current
718
+ if (!drag) return
719
+ interactedRef.current = true
720
+ setView(function (prev) { return { scale: prev.scale, x: drag.vx + (event.clientX - drag.x), y: drag.vy + (event.clientY - drag.y) } })
721
+ }
722
+ function endDrag() { dragRef.current = null; setDragging(false) }
723
+
724
+ function zoomBy(multiplier) {
725
+ var el = viewRef.current
726
+ if (!el) return
727
+ var rect = el.getBoundingClientRect()
728
+ var px = rect.width / 2
729
+ var py = rect.height / 2
730
+ interactedRef.current = true
731
+ setView(function (prev) {
732
+ var next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * multiplier))
733
+ var k = next / prev.scale
734
+ return { scale: next, x: px - (px - prev.x) * k, y: py - (py - prev.y) * k }
735
+ })
736
+ }
737
+
738
+ customHeightRef.current = customHeight
739
+ customHeightRef.current = customHeight
740
+ // 2026-11-10:画布内的文字/勾选项全部移到画布外(上方工具栏 = 勾选 + 链路信息;下方 = 缩放/说明)
741
+ return h('div', { className: 'dm-topo-wrap' },
742
+ h('div', { className: 'dm-topbar' },
743
+ h('div', { className: 'dm-filters' },
744
+ [['lan', '局域网'], ['public', '公网中继'], ['sakura', '第三方穿透']].map(function (it) {
745
+ var tr = TRANSPORTS[it[0]]
746
+ return h('label', { key: it[0], className: 'dm-flt' + (showFilters[it[0]] ? ' on' : ''), style: { '--c': tr.color } },
747
+ h('input', { type: 'checkbox', checked: showFilters[it[0]], onChange: function () { toggleFilter(it[0]) } }),
748
+ h('span', null, it[1])
749
+ )
750
+ })
751
+ ),
752
+ h('span', { className: 'dm-topbar-info' }, effLayout.count + ' 节点 · ' + effLayout.edges.length + ' 条链路 · 拖拽卡片自定义布局')
753
+ ),
754
+ h('div', { className: 'dm-viewport' + (dragging ? ' dragging' : '') + (resizing ? ' resizing' : ''), ref: viewRef,
755
+ style: (customHeight || autoH) ? { height: (customHeight || autoH) + 'px' } : undefined,
756
+ onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onPointerLeave: endDrag,
757
+ onDoubleClick: function () { interactedRef.current = false; fit() } },
758
+ // 2026-11-07:缩放改用 CSS zoom(重新布局、文字矢量重绘 → 放大不糊);
759
+ // 平移用外层 transform(不参与光栅缓存)。不再使用 will-change,避免整块被缓存成位图后放大发虚。
760
+ h('div', { className: 'dm-world-outer', style: { transform: 'translate(' + view.x + 'px,' + view.y + 'px)' } },
761
+ h('div', { className: 'dm-world', style: Object.assign({ width: effLayout.W + 'px', height: effLayout.H + 'px' },
762
+ SUPPORTS_ZOOM ? { zoom: view.scale } : { transform: 'scale(' + view.scale + ')' }) },
763
+ h(Topology, { data: props.data, layout: effLayout, selectedKey: props.selectedKey, onSelect: props.onSelect, scale: view.scale, onNodeMove: moveNode, onDragEdge: autoPanAt, onCardSize: onCardSize, show: showFilters, federated: props.federated, onInfraBounds: onInfraBounds, resetNonce: resetNonce })
764
+ )
765
+ ),
766
+ h('div', { className: 'dm-zoom' },
767
+ h('button', { title: '放大', onClick: function () { zoomBy(1.22) } }, '+'),
768
+ h('button', { title: '缩小', onClick: function () { zoomBy(1 / 1.22) } }, '−'),
769
+ h('button', { title: '自适应', onClick: function () { interactedRef.current = false; fit() } }, '⤢'),
770
+ h('button', { className: 'dm-layout-btn', title: '重置为默认拓扑布局', onClick: resetLayout }, '↺'),
771
+ h('span', { className: 'pct' }, Math.round(view.scale * 100) + '%')
772
+ ),
773
+ h('div', { className: 'dm-resize', title: '上下拖拽调整画布高度(双击恢复默认)',
774
+ onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp,
775
+ onDoubleClick: resetHeight },
776
+ h('span', null, '⋯')
777
+ )
778
+ ),
779
+ h('div', { className: 'dm-hint' }, '拓扑 v23 · 内容 ' + Math.round(layout.W) + '×' + Math.round(layout.H) + 'px · 卡 ' + Math.round(layout.cardW) + '×' + Math.round(layout.cardH) + ' · 缩放 ' + Math.round(view.scale * 100) + '% · 滚轮缩放 / 拖拽平移')
780
+ )
781
+ }
782
+
783
+ // ---------- 布局计算 ----------
784
+ // 用户偏好(2026-11-07 明确):
785
+ // 4 节点 = 菱形(上/右/下/左);5 节点 = 顶点朝上的五边形;6 = 六边形;n 节点 = 顶点朝上的正 n 边形。
786
+ // 相邻节点的「轴对齐包围盒」必须留出 GAP,否则菱形斜向两张卡会贴住/重叠。
787
+ // 2026-11-09-v6:默认布局 = 顶部「阿里云 / 樱花」服务器一排 + 设备沿下方弧线扇形展开(用户指定)
788
+
789
+ // 2026-11-10:按可见服务器数量布局
790
+ // 0 个服务器:仅节点,圆环居中;1 个服务器:服务器居中,节点在下方扇形展开;2 个:顶部服务器 + 下方弧线(原布局)
791
+ function polygonEdges(count) {
792
+ var edges = []
793
+ for (var m = 0; m < count; m++) for (var n = m + 1; n < count; n++) edges.push([m, n])
794
+ return edges
795
+ }
796
+ // 2026-11-10:按矩形碰撞检测扩大半径,保证默认布局卡片不重叠
797
+ function fanHasOverlap(points, cardW, cardH, GAP, hasServer) {
798
+ var halfW = cardW / 2
799
+ var halfH = cardH / 2
800
+ for (var i = 0; i < points.length; i++) {
801
+ for (var j = i + 1; j < points.length; j++) {
802
+ if (Math.abs(points[i][0] - points[j][0]) < cardW + GAP && Math.abs(points[i][1] - points[j][1]) < cardH + GAP) return true
803
+ }
804
+ if (hasServer) {
805
+ var serverW = Math.max(cardW * 1.35, 280)
806
+ var serverH = Math.max(cardH, 96)
807
+ if (Math.abs(points[i][0]) < (cardW + serverW) / 2 + GAP && Math.abs(points[i][1]) < (cardH + serverH) / 2 + GAP) return true
808
+ }
809
+ }
810
+ return false
811
+ }
812
+
813
+ function layoutNodesOnly(count, cardW, cardH, GAP, PAD) {
814
+ var n = Math.max(1, count)
815
+ var radius = n > 1 ? Math.max((cardW + GAP) / (2 * Math.sin(Math.PI / n)), cardH * 1.2) : 0
816
+ function nodePoints(r) {
817
+ var pts = []
818
+ for (var i = 0; i < n; i++) {
819
+ var a = -Math.PI / 2 + (i * 2 * Math.PI) / n
820
+ pts.push([r * Math.cos(a), r * Math.sin(a)])
821
+ }
822
+ return pts
823
+ }
824
+ var raw = nodePoints(radius)
825
+ for (var tries = 0; tries < 240 && n > 1 && fanHasOverlap(raw, cardW, cardH, GAP, false); tries++) {
826
+ radius += 8
827
+ raw = nodePoints(radius)
828
+ }
829
+ var minX = Math.min(-cardW / 2, ...raw.map(function (p) { return p[0] - cardW / 2 }))
830
+ var maxX = Math.max(cardW / 2, ...raw.map(function (p) { return p[0] + cardW / 2 }))
831
+ var minY = Math.min(-cardH / 2, ...raw.map(function (p) { return p[1] - cardH / 2 }))
832
+ var maxY = Math.max(cardH / 2, ...raw.map(function (p) { return p[1] + cardH / 2 }))
833
+ var ox = -minX + PAD
834
+ var oy = -minY + PAD
835
+ var positions = raw.map(function (p) { return [Math.round(p[0] + ox), Math.round(p[1] + oy)] })
836
+ var W = Math.ceil(maxX - minX + PAD * 2)
837
+ var H = Math.ceil(maxY - minY + PAD * 2)
838
+ var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
839
+ return { count: n, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: polygonEdges(n), topDeviceY: topDeviceY, mode: 'nodes-only', serverCenter: null }
840
+ }
841
+ // 2026-11-10:一个服务器时用紧凑两排扇形,不再靠扩大半径防重叠
842
+ // 服务器在上方居中;设备分两排(上排少、下排多),列距/行距按卡片尺寸,整体宽度接近双服务器布局。
843
+ function layoutServerCenter(count, cardW, cardH, GAP, PAD) {
844
+ var n = Math.max(1, count)
845
+ var topN = Math.floor(n / 2)
846
+ var botN = n - topN
847
+ var serverH = Math.max(cardH, 96)
848
+ var stepX = cardW + GAP
849
+ var upperY = serverH / 2 + GAP + cardH / 2
850
+ var lowerY = upperY + cardH + GAP
851
+ var raw = []
852
+ function rowXs(k) {
853
+ var xs = []
854
+ for (var i = 0; i < k; i++) xs.push((i - (k - 1) / 2) * stepX)
855
+ return xs
856
+ }
857
+ var ux = rowXs(topN)
858
+ for (var i = 0; i < topN; i++) raw.push([ux[i], upperY])
859
+ var bx = rowXs(botN)
860
+ var by = topN > 0 ? lowerY : upperY
861
+ for (var j = 0; j < botN; j++) raw.push([bx[j], by])
862
+ var minX = Math.min(-cardW / 2, ...raw.map(function (p) { return p[0] - cardW / 2 }))
863
+ var maxX = Math.max(cardW / 2, ...raw.map(function (p) { return p[0] + cardW / 2 }))
864
+ var minY = -serverH / 2
865
+ var maxY = Math.max(cardH / 2, ...raw.map(function (p) { return p[1] + cardH / 2 }))
866
+ var ox = -minX + PAD
867
+ var oy = -minY + PAD
868
+ var positions = raw.map(function (p) { return [Math.round(p[0] + ox), Math.round(p[1] + oy)] })
869
+ var W = Math.ceil(maxX - minX + PAD * 2)
870
+ var H = Math.ceil(maxY - minY + PAD * 2)
871
+ var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
872
+ return { count: n, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: polygonEdges(n), topDeviceY: topDeviceY, mode: 'server-center', serverCenter: [Math.round(ox), Math.round(oy)] }
873
+ }
874
+
875
+ function computeLayout(count, override) {
876
+ var GAP = 30
877
+ var cardW = count <= 4 ? 210 : count <= 6 ? 196 : 176
878
+ var cardH = count <= 4 ? 112 : count <= 6 ? 104 : 96
879
+ if (override && override.cardW > 0) cardW = Math.ceil(override.cardW)
880
+ if (override && override.cardH > 0) cardH = Math.ceil(override.cardH)
881
+ var PAD = 16
882
+ var serverCount = override && Number.isInteger(override.serverCount) ? override.serverCount : 2
883
+ if (serverCount === 0) return layoutNodesOnly(count, cardW, cardH, GAP, PAD)
884
+ var topTierH = Math.round(cardH * 1.1)
885
+ var W, H, positions
886
+ if (count <= 1) {
887
+ W = cardW + PAD * 2
888
+ H = topTierH + cardH + PAD * 2
889
+ positions = [[W / 2, topTierH + cardH / 2 + PAD]]
890
+ } else if (count === 2) {
891
+ W = cardW * 2 + GAP + PAD * 2
892
+ H = topTierH + cardH + PAD * 2
893
+ positions = [[PAD + cardW / 2, topTierH + cardH / 2 + PAD], [PAD + cardW * 1.5 + GAP, topTierH + cardH / 2 + PAD]]
894
+ } else {
895
+ // 2026-11-09-v7:浅扁椭圆弧扇形(横向铺开、纵向贴近顶部服务器,压缩中间空白)
896
+ var START = 150 * Math.PI / 180
897
+ var END = 30 * Math.PI / 180
898
+ var stepA = (START - END) / (count - 1)
899
+ var MARGIN = 22
900
+ var A = (cardW + MARGIN) / (2 * Math.sin(stepA / 2))
901
+ for (var step = 0; step < 400; step++) {
902
+ var ok = true
903
+ for (var i = 0; i < count - 1 && ok; i++) {
904
+ var a1 = START - i * stepA
905
+ var a2 = START - (i + 1) * stepA
906
+ var dx = Math.abs(A * Math.cos(a1) - A * Math.cos(a2))
907
+ var dy = Math.abs(0.35 * A * Math.sin(a1) - 0.35 * A * Math.sin(a2))
908
+ if (dx < cardW + MARGIN * 0.5 && dy < cardH + MARGIN * 0.5) ok = false
909
+ }
910
+ if (ok) break
911
+ A += 4
912
+ }
913
+ var B = A * 0.35
914
+ var halfW = A * Math.cos(END) + cardW / 2
915
+ W = Math.ceil(2 * halfW + PAD * 2)
916
+ var cx = W / 2
917
+ var cy = topTierH + PAD
918
+ H = Math.ceil(cy + B + cardH / 2 + PAD)
919
+ positions = []
920
+ for (var j = 0; j < count; j++) {
921
+ var aj = START - j * stepA
922
+ positions.push([cx + A * Math.cos(aj), cy + B * Math.sin(aj)])
923
+ }
924
+ }
925
+ var edges = []
926
+ for (var m = 0; m < count; m++) for (var n = m + 1; n < count; n++) edges.push([m, n])
927
+ var topDeviceY = positions.length ? Math.min.apply(null, positions.map(function (p) { return p[1] })) : 0
928
+ return { count: count, cardW: cardW, cardH: cardH, gap: GAP, W: W, H: H, positions: positions, edges: edges, topDeviceY: topDeviceY, mode: serverCount === 1 ? 'servers-top-single' : 'servers-top', serverCenter: null }
929
+ }
930
+ // ---------- 架构层:公网中转站(frps)与各节点通信关系(2026-11-09) ----------
931
+ function RelayLayer(props) {
932
+ var data = props.data || {}
933
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
934
+ var groups = relayGroups(peers)
935
+ var relayHosts = relayHostSet(peers)
936
+ var direct = peers.filter(function (p) { var t = p.tunnel || {}; return t.kind === 'lan' || t.kind === 'loopback' })
937
+ var instances = (data.local && data.local.instances) || []
938
+ var localUp = instances.filter(function (x) { return x.up }).length
939
+ // 2026-11-09:樱花账号隧道(PC 端 API 采集,跨设备含手机)
940
+ var sakuraAccount = sakuraAccountOf(data)
941
+ if (!groups.length && !direct.length && !sakuraAccount) return null
942
+ return h('div', { className: 'dm-relay' },
943
+ h('div', { className: 'dm-relay-head' },
944
+ h('span', { className: 'dm-relay-title' }, '架构层 · 公网中转站'),
945
+ h('span', { className: 'dm-sub' }, groups.length ? '虚线 = 经中转;实线/标签 = 直连。端口是各节点在中转站上的隧道入口' : '当前没有共享公网中转的 peer,全部为直连')
946
+ ),
947
+ groups.map(function (g, gi) {
948
+ var ports = Object.keys(g.ports).sort()
949
+ return h('div', { className: 'dm-relay-row', key: 'g' + gi },
950
+ h('div', { className: 'dm-relay-end' },
951
+ h('div', { className: 'dm-relay-name' }, nodeDisplay(data.nodeName) + '(本机)'),
952
+ h('div', { className: 'dm-relay-sub' }, 'DSH ' + localUp + '/' + instances.length + ' 在线')
953
+ ),
954
+ h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '出站')),
955
+ h('div', { className: 'dm-relay-hub' },
956
+ h('div', { className: 'dm-relay-name' }, '公网中转站 · frps'),
957
+ h('div', { className: 'dm-relay-sub dm-mono' }, maskHost(g.host)),
958
+ h('div', { className: 'dm-relay-chips' },
959
+ ports.map(function (p) { return h('span', { className: 'dm-chip', key: p, title: '隧道入口 :' + p }, ':' + p + ' ×' + g.ports[p]) }),
960
+ h('span', { className: 'dm-chip ' + (g.up === g.peers.length ? 'run' : 'bad') }, '在线 ' + g.up + '/' + g.peers.length)
961
+ )
962
+ ),
963
+ h('div', { className: 'dm-relay-link' }, h('span', { className: 'dm-relay-line' }), h('span', { className: 'dm-relay-dirlabel' }, '中转')),
964
+ h('div', { className: 'dm-relay-peers' }, g.peers.map(function (peer) {
965
+ var t = peer.tunnel || {}
966
+ return h('div', { className: 'dm-relay-peer' + (peer.up ? '' : ' down'), key: peer.name },
967
+ h('span', { className: 'dm-dot ' + (peer.up ? 'ok' : 'bad') }),
968
+ h('span', { className: 'dm-relay-peer-name' }, nodeDisplay(peerDeviceKey(peer.name))),
969
+ h('span', { className: 'dm-relay-peer-meta' }, (t.port ? ':' + t.port : '') + (peer.up && peer.latencyMs != null ? ' · ' + peer.latencyMs + 'ms' : ' · 离线'))
970
+ )
971
+ }))
972
+ )
973
+ }),
974
+ sakuraAccount && sakuraAccount.available ? h('div', { className: 'dm-relay-direct' },
975
+ h('span', { className: 'dm-k' }, '樱花隧道:'),
976
+ h('span', { className: 'dm-chip ' + (sakuraAccount.online === sakuraAccount.total ? 'run' : 'bad') }, sakuraAccount.online + '/' + sakuraAccount.total + ' 在线'),
977
+ (sakuraAccount.tunnels || []).map(function (t) {
978
+ return h('span', { className: 'dm-chip ' + (t.online ? 'run' : 'bad'), key: 'st' + t.id, title: (t.nodeName || '') + ' ' + maskText(t.addr) + (t.online ? '' : '(离线)') },
979
+ (t.deviceKey || t.name) + ' ' + maskText(t.addr))
980
+ })
981
+ ) : null,
982
+ direct.length ? h('div', { className: 'dm-relay-direct' },
983
+ h('span', { className: 'dm-k' }, '直连:'),
984
+ direct.map(function (peer) {
985
+ var c = commLabel(peer, relayHosts)
986
+ return h('span', { className: 'dm-chip ' + (peer.up ? 'run' : 'bad'), key: peer.name, title: c.text }, nodeDisplay(peerDeviceKey(peer.name)) + ' · ' + c.text)
987
+ })
988
+ ) : null
989
+ )
990
+ }
991
+
992
+ // ===== 2026-11-09 传输层封装:局域网 / 阿里云(公网) / 樱花穿透 三通道 =====
993
+ var TRANSPORTS = {
994
+ lan: { label: '局域网直连', short: 'LAN', color: '#2e9e4f', dash: false },
995
+ public: { label: '公网服务器', short: 'PUB', color: '#4d9bff', dash: true },
996
+ sakura: { label: '樱花穿透', short: 'SAK', color: '#a371f7', dash: true }
997
+ }
998
+ var DOWN_COLOR = "rgba(209,75,65,.7)"
999
+ // LAN peer 判定:-lan 后缀 / 内网 IP / kind=lan(实际数据里 peer-node-lan 的 kind 是 frp)
1000
+ function isLanPeer(peer) {
1001
+ var t = (peer && peer.tunnel) || {}
1002
+ var name = String((peer && peer.name) || '')
1003
+ var host = String(t.host || '')
1004
+ if (/-lan$/i.test(name)) return true
1005
+ if (t.kind === 'lan' || t.kind === 'loopback') return true
1006
+ if (/^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(host)) return true
1007
+ return false
1008
+ }
1009
+ // publicEntry 可能来自机器级 frpc;只有它映射的 localPort 与当前 mesh server 端口一致才属于本节点。
1010
+ function publicEntryFor(localTunnel) {
1011
+ var lt = localTunnel || {}
1012
+ var entry = lt.publicEntry || lt.selfHosted
1013
+ if (!entry || !entry.host) return null
1014
+ var nodePort = Number(lt.port)
1015
+ if (nodePort && entry.localPort != null && Number(entry.localPort) !== nodePort) return null
1016
+ return entry
1017
+ }
1018
+ function tunnelMatchesNode(port) {
1019
+ var nodePort = Number(port)
1020
+ return function (tunnel) {
1021
+ if (!nodePort || tunnel.localPort == null) return true
1022
+ return Number(tunnel.localPort) === nodePort
1023
+ }
1024
+ }
1025
+ function sakuraAccountOf(data) {
1026
+ var acc = data && data.tunnels && data.tunnels.sakuraAccount
1027
+ var local = (data && data.tunnels && data.tunnels.sakura && data.tunnels.sakura.tunnels) || []
1028
+ if (!acc && !local.length) return null
1029
+ var localMapped = local.map(function (t) {
1030
+ return {
1031
+ id: t.name + ':' + t.host + ':' + t.port,
1032
+ name: t.name,
1033
+ host: t.host,
1034
+ remotePort: t.port,
1035
+ localPort: t.localPort == null ? null : Number(t.localPort),
1036
+ online: t.probe ? t.probe.up === true : t.online === true,
1037
+ deviceKey: peerDeviceKey(t.name),
1038
+ addr: (t.host || '') + ':' + (t.port || '')
1039
+ }
1040
+ })
1041
+ var list = (acc && acc.tunnels && acc.tunnels.length) ? acc.tunnels : localMapped
1042
+ if (!acc && !list.length) return null
1043
+ var base = acc ? Object.assign({}, acc) : { available: false, error: '账号 API 暂不可用', total: list.length, online: list.filter(function (x) { return x.online }).length, tunnels: list }
1044
+ if (localMapped.length && (!acc || acc.available !== true || !(acc.tunnels || []).length)) {
1045
+ base.available = true
1046
+ base.source = 'local-log'
1047
+ base.error = null
1048
+ base.total = list.length
1049
+ base.online = list.filter(function (x) { return x.online }).length
1050
+ base.tunnels = list
1051
+ }
1052
+ var port = Number(data && data.localTunnel && data.localTunnel.port)
1053
+ var filtered = list
1054
+ if (port) filtered = list.filter(function (x) { return x.localPort == null || Number(x.localPort) === port })
1055
+ return Object.assign({}, base, { tunnels: filtered, total: filtered.length, online: filtered.filter(function (x) { return x.online }).length })
1056
+ }
1057
+ function sakuraTunnelOf(data, devKey) {
1058
+ var acc = sakuraAccountOf(data)
1059
+ if (!acc) return null
1060
+ var k = peerDeviceKey(devKey)
1061
+ for (var i = 0; i < (acc.tunnels || []).length; i++) if (acc.tunnels[i].deviceKey === k) return acc.tunnels[i]
1062
+ return null
1063
+ }
1064
+ // 设备状态 = 任一链路存活(全断才离线/红)
1065
+ function deviceLinkState(data, peer, lanMap) {
1066
+ var dk = peerDeviceKey(peer.name)
1067
+ var t = peer.tunnel || {}
1068
+ // 2026-11-10:蓝线状态以隧道端口 TCP 探测为准(HTTP /health 对高延迟节点会误判断连)
1069
+ var probe = t.probe && typeof t.probe.up === "boolean" ? t.probe : null
1070
+ var publicUp = (probe ? probe.up === true : false) || Boolean(peer.up)
1071
+ var lan = lanMap ? lanMap[dk] : (t.kind === 'lan' ? { up: Boolean(peer.up), host: t.host, port: t.port, ip: t.ip || '' } : null)
1072
+ var lanUp = Boolean(lan && (lan.probe && lan.probe.up === true ? true : lan.up))
1073
+ var st = sakuraTunnelOf(data, dk)
1074
+ var sakuraUp = st ? st.online === true : null
1075
+ var anyUp = publicUp || sakuraUp === true || lanUp
1076
+ return { devKey: dk, publicUp: publicUp, lanUp: lanUp, lanTunnel: lan, sakuraUp: sakuraUp, sakuraTunnel: st, anyUp: anyUp }
1077
+ }
1078
+
1079
+ // ---------- 2026-11-09 画布下方:中转服务器情况(阿里云 / 樱花) ----------
1080
+ function InfraPanels(props) {
1081
+ var data = props.data || {}
1082
+ var acc = sakuraAccountOf(data)
1083
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
1084
+ // 2026-11-10:本机自身也有一条阿里云入口(publicEntry/selfHosted 的 7012),之前漏掉了
1085
+ var lt = data.localTunnel || {}
1086
+ var selfHost = lt.selfHosted || {}
1087
+ var localPublic = publicEntryFor(lt)
1088
+ if (!localPublic) return null
1089
+ var entry = localPublic
1090
+ var localPort = entry.port || null
1091
+ var localHost = entry.host || ''
1092
+ var localKey = peerDeviceKey(data.nodeName)
1093
+ var devs = [{ devKey: localKey, name: nodeDisplay(data.nodeName) + "(本机)", up: true, port: localPort, host: localHost }].concat(peers.filter(function (p) { return !isLanPeer(p) }).map(function (p) {
1094
+ var tu = p.tunnel || {}
1095
+ return { devKey: peerDeviceKey(p.name), name: nodeDisplay(peerDeviceKey(p.name)), up: (tu.probe ? Boolean(tu.probe.up) : Boolean(p.up)), port: tu.port || null, host: tu.host || '' }
1096
+ }))
1097
+ var aliyunRows = devs.filter(function (d) { return d.port }).map(function (d) {
1098
+ return h('div', { key: d.devKey, className: 'dm-device-row' },
1099
+ h('span', { className: 'dm-device-k' }, d.name),
1100
+ h('span', { className: 'dm-device-v' }, (d.up ? '✅ ' : '❌ ') + maskHost(d.host) + ':' + d.port)
1101
+ )
1102
+ })
1103
+ var tunnelRows = acc ? (acc.tunnels || []).filter(tunnelMatchesNode(lt.port)).map(function (x, i) {
1104
+ return h('div', { key: 'st' + i, className: 'dm-device-row' },
1105
+ h('span', { className: 'dm-device-k' }, (x.deviceKey ? nodeDisplay(peerDeviceKey(x.deviceKey)) : (x.name || '未归类'))),
1106
+ h('span', { className: 'dm-device-v' }, (x.online ? '✅ ' : '❌ ') + maskText(x.addr || ''))
1107
+ )
1108
+ }) : null
1109
+ if (!aliyunRows.length && !tunnelRows) return null
1110
+ return h('div', { className: 'dm-infra-grid' },
1111
+ h('div', { className: 'dm-infra-card' },
1112
+ h('div', { className: 'dm-infra-head' },
1113
+ h('span', { className: 'dm-dot ok', style: { background: TRANSPORTS.public.color } }),
1114
+ h('span', null, '公网中转'),
1115
+ h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, '自建 frps')
1116
+ ),
1117
+ h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, '隧道 ' + (devs.filter(function (d) { return d.port }).map(function (d) { return d.port }).sort().join(' / ') || '—')),
1118
+ aliyunRows
1119
+ ),
1120
+ acc ? h('div', { className: 'dm-infra-card' },
1121
+ h('div', { className: 'dm-infra-head' },
1122
+ h('span', { className: 'dm-dot', style: { background: TRANSPORTS.sakura.color } }),
1123
+ h('span', null, '第三方 frps 穿透'),
1124
+ h('span', { className: 'dm-sub', style: { marginLeft: 'auto' } }, acc.online + '/' + acc.total + ' 在线')
1125
+ ),
1126
+ h('div', { className: 'dm-sub dm-mono', style: { marginBottom: 4 } }, acc && acc.tunnels && acc.tunnels[0] ? maskText(acc.tunnels[0].addr || '') : '未配置'),
1127
+ tunnelRows
1128
+ ) : null
1129
+ )
1130
+ }
1131
+
1132
+ // ---------- 拓扑:极简卡片(名称 / 穿透域名 / DSH 数)+ 点击选中 ----------
1133
+ function Topology(props) {
1134
+ var data = props.data
1135
+ var layout = props.layout
1136
+ var show = props.show || { lan: true, public: true, sakura: true }
1137
+ // 2026-11-09-v5:卡片宽度自适应 —— 每次渲染后测量真实尺寸,回传父组件重算布局;
1138
+ // 同时把每张卡的实际尺寸给连线用(不同卡片宽窄不同,连线端点贴各自边缘)。
1139
+ var cardRefs = useRef({})
1140
+ var cardSizeState = useState({})
1141
+ var cardSizes = cardSizeState[0]
1142
+ var setCardSizes = cardSizeState[1]
1143
+ useEffect(function () {
1144
+ var next = {}
1145
+ var maxW = 0
1146
+ var maxH = 0
1147
+ var changed = false
1148
+ Object.keys(cardRefs.current).forEach(function (key) {
1149
+ var el = cardRefs.current[key]
1150
+ if (!el) return
1151
+ var w = el.offsetWidth
1152
+ var h = el.offsetHeight
1153
+ if (!w || !h) return
1154
+ next[key] = { w: w, h: h }
1155
+ if (w > maxW) maxW = w
1156
+ if (h > maxH) maxH = h
1157
+ var old = cardSizes[key]
1158
+ if (!old || Math.abs(old.w - w) > 1 || Math.abs(old.h - h) > 1) changed = true
1159
+ })
1160
+ if (!maxW) return
1161
+ if (changed) setCardSizes(next)
1162
+ if (props.onCardSize) props.onCardSize(maxW, maxH)
1163
+ })
1164
+ // 2026-11-09 卡片拖拽:在世界坐标里移动;连线由 layout.positions 派生,移动即自动跟随。
1165
+ var dragState = useState(null)
1166
+ var dragKey = dragState[0]
1167
+ var setDragKey = dragState[1]
1168
+ var dragRef = useRef(null)
1169
+ function cardPointerDown(node, pos, event) {
1170
+ if (event.button !== 0) return
1171
+ event.stopPropagation()
1172
+ dragRef.current = { key: node.key, sx: event.clientX, sy: event.clientY, ox: pos[0], oy: pos[1], moved: false }
1173
+ setDragKey(node.key)
1174
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch (_) { /* ignore */ }
1175
+ }
1176
+ function cardPointerMove(node, event) {
1177
+ var drag = dragRef.current
1178
+ if (!drag || drag.key !== node.key) return
1179
+ event.stopPropagation()
1180
+ var scale = props.scale || 1
1181
+ var dx = (event.clientX - drag.sx) / scale
1182
+ var dy = (event.clientY - drag.sy) / scale
1183
+ if (!drag.moved && (Math.abs(dx) + Math.abs(dy)) > 3) drag.moved = true
1184
+ if (!drag.moved) return
1185
+ if (String(node.key).indexOf('server-') === 0) { infraMove(node.key, [drag.ox + dx, drag.oy + dy]); return }
1186
+ // 2026-11-09-v3:不再限制在画布边界内(用户反馈左侧有「空气墙」);
1187
+ // 坐标允许任意值,.dm-world/.dm-lines 均 overflow:visible,配合拖到边缘自动平移可无限拖。
1188
+ var nx = drag.ox + dx
1189
+ var ny = drag.oy + dy
1190
+ if (props.onNodeMove) props.onNodeMove(node.key, [nx, ny])
1191
+ if (props.onDragEdge) props.onDragEdge(event.clientX, event.clientY)
1192
+ }
1193
+ function cardPointerUp(node, event) {
1194
+ var drag = dragRef.current
1195
+ if (!drag || drag.key !== node.key) return
1196
+ event.stopPropagation()
1197
+ var wasDrag = drag.moved
1198
+ dragRef.current = null
1199
+ setDragKey(null)
1200
+ if (!wasDrag && props.onSelect) props.onSelect(node.key)
1201
+ }
1202
+ function cardPointerCancel() { dragRef.current = null; setDragKey(null) }
1203
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
1204
+ var relayHosts = relayHostSet(peers)
1205
+ var endpoint = data.endpoint || {}
1206
+ var instances = (data.local && data.local.instances) || []
1207
+ var hw = data.hardware || {}
1208
+ var localTunnel = data.localTunnel || {}
1209
+ var localUp = instances.filter(function (x) { return x.up }).length
1210
+ var localTunnelText = localTunnel.port
1211
+ ? (localTunnel.frpRunning ? (localTunnel.frpCount ? localTunnel.frpCount : '') : '') || ''
1212
+ : ''
1213
+
1214
+ function localDomain() {
1215
+ var entry = localTunnel.publicEntry
1216
+ var src = localTunnel.mainSource || (entry && entry.source === 'self-hosted-frps' ? 'self-hosted' : '')
1217
+ if (entry && entry.host) {
1218
+ var tag = src === 'self-hosted' ? '(自建 frps)' : (src === 'sakura' ? '(Sakura 备用)' : '')
1219
+ return maskHost(entry.host) + ':' + entry.port + tag
1220
+ }
1221
+ if (localTunnel.frpRunning) return 'SakuraFrp 已开 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
1222
+ if (localTunnel.port) return '未开穿透 · ' + (localTunnel.bind || '?') + ':' + localTunnel.port
1223
+ return '未配置'
1224
+ }
1225
+ function peerDomain(peer) {
1226
+ var t = peer.tunnel || {}
1227
+ if (!t.host) return '—'
1228
+ return maskHost(t.host) + (t.port ? ':' + t.port : '') + (t.kind === 'lan' ? '(内网)' : '')
1229
+ }
1230
+
1231
+ // LAN peer 单独收集(dedupe 后可能被更快的 frp 记录覆盖,但 LAN 线仍要画)
1232
+ var lanMap = {}
1233
+ filterSelfPeers(data.peers || [], data.nodeName).forEach(function (p) {
1234
+ var t = p.tunnel || {}
1235
+ if (isLanPeer(p)) lanMap[peerDeviceKey(p.name)] = { up: Boolean(p.up), host: t.host, port: t.port, ip: t.ip || '', probe: t.probe || null }
1236
+ })
1237
+ var nodes = [{
1238
+ key: 'local',
1239
+ local: true,
1240
+ devKey: peerDeviceKey(data.nodeName),
1241
+ up: true,
1242
+ name: nodeDisplay(data.nodeName),
1243
+ dsh: localUp + '/' + instances.length,
1244
+ ports: instances.map(function (inst) { return { port: inst.port, up: inst.up, error: inst.error || '' } }),
1245
+ extra: ''
1246
+ }].concat(peers.map(function (peer) {
1247
+ var ls = deviceLinkState(data, peer, lanMap)
1248
+ return {
1249
+ key: 'peer:' + peer.name,
1250
+ local: false,
1251
+ devKey: ls.devKey,
1252
+ up: ls.anyUp,
1253
+ publicUp: ls.publicUp,
1254
+ sakuraUp: ls.sakuraUp,
1255
+ sakuraTunnel: ls.sakuraTunnel,
1256
+ lanUp: ls.lanUp,
1257
+ lanTunnel: ls.lanTunnel,
1258
+ name: nodeDisplay(ls.devKey),
1259
+ dsh: String(peer.instancesCount || 0),
1260
+ ports: peer.instancesList || [],
1261
+ latencyMs: peer.latencyMs,
1262
+ tunnel: peer.tunnel || {},
1263
+ error: peer.error || ''
1264
+ }
1265
+ }))
1266
+ // 2026-11-10:按共用顺序排序(与 Viewport 拖拽位置表一致,修复"拖 A 动 B"的 bug)
1267
+ var orderKeys = orderedNodeKeys(data)
1268
+ nodes.sort(function (a, b) { return orderKeys.indexOf(a.key) - orderKeys.indexOf(b.key) })
1269
+ // 基础设施节点(中转服务器):阿里云 / 樱花
1270
+ var localPub = publicEntryFor(localTunnel)
1271
+ var hasPublic = Boolean(localPub) || peers.some(function (p) { var pt = p.tunnel || {}; return !isLanPeer(p) && Boolean(pt.host) && (pt.kind === 'frp' || pt.kind === 'wan') })
1272
+ var publicVisible = show.public && hasPublic
1273
+ var sakuraRaw = sakuraAccountOf(data)
1274
+ var sakuraAcc = sakuraRaw ? Object.assign({}, sakuraRaw, { tunnels: (sakuraRaw.tunnels || []).filter(tunnelMatchesNode(localTunnel.port)) }) : null
1275
+ var sakuraVisible = show.sakura && Boolean(sakuraAcc && ((sakuraAcc.tunnels || []).length || sakuraAcc.error))
1276
+ var aliyunPorts = peers.map(function (p) { return isLanPeer(p) ? null : (p.tunnel || {}).port }).filter(Boolean)
1277
+ var infra = [
1278
+ { key: 'server-aliyun', transport: 'public', name: '公网中转', host: localPub ? maskHost(localPub.host) : '未配置', color: TRANSPORTS.public.color, detail: '隧道 ' + (aliyunPorts.length ? aliyunPorts.sort().join('/') : '—'), up: Boolean(localPub) || hasPublic },
1279
+ { key: 'server-sakura', transport: 'sakura', name: '第三方 frps 穿透', host: sakuraAcc && sakuraAcc.tunnels && sakuraAcc.tunnels[0] ? maskText(sakuraAcc.tunnels[0].addr || '') : (sakuraAcc && sakuraAcc.error ? '状态不可用' : '未配置'), color: TRANSPORTS.sakura.color, detail: sakuraAcc ? (sakuraAcc.available === false ? ('Sakura 限流/未就绪:' + (sakuraAcc.error || 'unknown')) : (sakuraAcc.online + '/' + sakuraAcc.total + ' 在线')) : '未采集', up: Boolean(sakuraAcc && Number(sakuraAcc.online || 0) > 0) },
1280
+ ]
1281
+ // 2026-11-09-v8:服务器默认位置贴住扇形最高一排设备(用户要求缩小服务器↔设备的垂直间隔)
1282
+ function infraDefaultPos(idx) {
1283
+ var dx = layout.cardW * 1.08
1284
+ var ox = layout.baseOffsetX || 0
1285
+ var oy = layout.baseOffsetY || 0
1286
+ var cx = ox + (layout.contentW || layout.W) / 2
1287
+ var topYAbs = oy + (layout.topDeviceY || 0)
1288
+ return [Math.round(cx + (idx === 0 ? -dx : dx)), Math.round(topYAbs - layout.cardH * 1.05)]
1289
+ }
1290
+ var infraState = useState(function () { try { return JSON.parse(window.localStorage.getItem('dsh-mesh-ui.infra.v2') || '{}') || {} } catch (e) { return {} } })
1291
+ var infraPos = infraState[0]
1292
+ var setInfraPos = infraState[1]
1293
+ function infraPosition(idx) {
1294
+ var k = infra[idx].key
1295
+ var v = infraPos[k]
1296
+ if (v && v.length === 2) return v
1297
+ if (layout.mode === 'server-center') return layout.serverCenter || infraDefaultPos(idx)
1298
+ if (layout.mode === 'servers-top-single') return [Math.round((DEFAULT_INFRA_POS.aliyun[0] + DEFAULT_INFRA_POS.sakura[0]) / 2), Math.round((DEFAULT_INFRA_POS.aliyun[1] + DEFAULT_INFRA_POS.sakura[1]) / 2)]
1299
+ var def = layout.mode === 'servers-top' ? DEFAULT_INFRA_POS[infra[idx].transport === 'public' ? 'aliyun' : 'sakura'] : null
1300
+ if (def) return [def[0], def[1]]
1301
+ return infraDefaultPos(idx)
1302
+ }
1303
+ useEffect(function () {
1304
+ if (!props.resetNonce) return
1305
+ try { window.localStorage.removeItem('dsh-mesh-ui.infra.v2') } catch (e) { /* ignore */ }
1306
+ setInfraPos({})
1307
+ }, [props.resetNonce])
1308
+ function infraMove(key, pos) { setInfraPos(function (prev) { var next = Object.assign({}, prev); next[key] = pos; try { window.localStorage.setItem('dsh-mesh-ui.infra.v2', JSON.stringify(next)) } catch (e) { /* ignore */ } return next }) }
1309
+
1310
+ var GAP_EDGE = 6
1311
+ function edgePoint(from, to, hw, hh) {
1312
+ var dx = to[0] - from[0]
1313
+ var dy = to[1] - from[1]
1314
+ var len = Math.sqrt(dx * dx + dy * dy) || 1
1315
+ var ux = dx / len
1316
+ var uy = dy / len
1317
+ var tx = ux === 0 ? Infinity : (hw + GAP_EDGE) / Math.abs(ux)
1318
+ var ty = uy === 0 ? Infinity : (hh + GAP_EDGE) / Math.abs(uy)
1319
+ var t = Math.min(tx, ty)
1320
+ return [from[0] + ux * t, from[1] + uy * t]
1321
+ }
1322
+
1323
+ // 2026-11-09:三通道连线(设备→阿里云 / 设备→樱花 / 局域网直连)+ 中转服务器卡片 + 筛选
1324
+ // 2026-11-09:悬浮节点 → 只高亮它的连线并显示完整地址(其它线变淡)
1325
+ var hoverState = useState(null)
1326
+ var hoverKey = hoverState[0]
1327
+ var setHoverKey = hoverState[1]
1328
+ function edgeRelated(aKey, bKey) { return hoverKey ? (hoverKey === aKey || hoverKey === bKey) : true }
1329
+ var infraRefs = useRef({})
1330
+ var infraSizeState = useState({})
1331
+ var infraSizes = infraSizeState[0]
1332
+ var setInfraSizes = infraSizeState[1]
1333
+ useEffect(function () {
1334
+ var next = {}
1335
+ var changed = false
1336
+ Object.keys(infraRefs.current).forEach(function (key) {
1337
+ var el = infraRefs.current[key]
1338
+ if (!el) return
1339
+ var w = el.offsetWidth
1340
+ var h2 = el.offsetHeight
1341
+ if (!w || !h2) return
1342
+ next[key] = { w: w, h: h2 }
1343
+ var old = infraSizes[key]
1344
+ if (!old || Math.abs(old.w - w) > 1 || Math.abs(old.h - h2) > 1) changed = true
1345
+ })
1346
+ if (!Object.keys(next).length) return
1347
+ if (changed) setInfraSizes(next)
1348
+ })
1349
+ function sizeOf(key) { return cardSizes[key] || infraSizes[key] || { w: layout.cardW, h: layout.cardH } }
1350
+ function curve(key, from, to, up, transport, label, fromSize, toSize, highlight) {
1351
+ var a = edgePoint(from, to, fromSize.w / 2, fromSize.h / 2)
1352
+ var b = edgePoint(to, from, toSize.w / 2, toSize.h / 2)
1353
+ var mx = (a[0] + b[0]) / 2
1354
+ var my = (a[1] + b[1]) / 2
1355
+ var dx = b[0] - a[0]
1356
+ var dy = b[1] - a[1]
1357
+ var len = Math.sqrt(dx * dx + dy * dy) || 1
1358
+ var bow = Math.min(12, len * 0.07)
1359
+ var color = up ? transport.color : DOWN_COLOR
1360
+ var dash = up ? (transport.dash ? '6 5' : 'none') : '6 5'
1361
+ var related = highlight !== false
1362
+ var els = [h('path', {
1363
+ key: key,
1364
+ d: 'M ' + a[0] + ' ' + a[1] + ' Q ' + (mx + (-dy / len) * bow) + ' ' + (my + (dx / len) * bow) + ' ' + b[0] + ' ' + b[1],
1365
+ fill: 'none', stroke: color, strokeWidth: 1.8, strokeDasharray: dash,
1366
+ className: up ? (transport.dash ? 'dm-edge-frp' : 'dm-edge-solid') : 'dm-edge-down',
1367
+ opacity: related ? 1 : 0.12
1368
+ })]
1369
+ if (label) {
1370
+ els.push(h('text', { key: key + '-t', className: 'dm-edge-label', x: mx + (-dy / len) * bow, y: my + (dx / len) * bow - 4, fill: color, fontSize: 9, textAnchor: 'middle', style: { display: (hoverKey && related) ? 'block' : 'none' } }, label))
1371
+ }
1372
+ return els
1373
+ }
1374
+ var svgEls = []
1375
+ var aliyunPos = infraPosition(0)
1376
+ var sakuraPos = infraPosition(1)
1377
+ var aliyunSize = infraSizes['server-aliyun'] || { w: 300, h: 90 }
1378
+ var sakuraSize = infraSizes['server-sakura'] || { w: 300, h: 90 }
1379
+ var infraBoxes = []
1380
+ if (publicVisible) infraBoxes.push({ x: aliyunPos[0], y: aliyunPos[1], w: aliyunSize.w, h: aliyunSize.h })
1381
+ if (sakuraVisible) infraBoxes.push({ x: sakuraPos[0], y: sakuraPos[1], w: sakuraSize.w, h: sakuraSize.h })
1382
+ if (props.onInfraBounds) props.onInfraBounds(infraBoxes)
1383
+ var localKey = peerDeviceKey(data.nodeName)
1384
+ var localSak = sakuraTunnelOf(data, localKey)
1385
+ // 2026-11-10:LAN 线必须从本机节点出发(排序后 local 可能不在 index 0,之前误连到peer-x)
1386
+ var localNodeIdx = 0
1387
+ nodes.forEach(function (n, ii) { if (n.local) localNodeIdx = ii })
1388
+ nodes.forEach(function (n, i) {
1389
+ var pa = layout.positions[i]
1390
+ if (n.local) {
1391
+ // 主机自己的两条上级链路:自建 frps(阿里云) + 本机樱花隧道
1392
+ if (publicVisible && localPub) svgEls = svgEls.concat(curve('pub-local', pa, aliyunPos, true, TRANSPORTS.public, maskHost(localPub.host) + ':' + localPub.port, sizeOf('local'), aliyunSize, edgeRelated('local', 'server-aliyun')))
1393
+ if (sakuraVisible && localSak) svgEls = svgEls.concat(curve('sak-local', pa, sakuraPos, localSak.online === true, TRANSPORTS.sakura, maskText(localSak.addr || ''), sizeOf('local'), sakuraSize, edgeRelated('local', 'server-sakura')))
1394
+ return
1395
+ }
1396
+ var sizeN = sizeOf(n.key)
1397
+ if (publicVisible && n.tunnel && n.tunnel.host && !isLanPeer(n) && (n.tunnel.kind === 'frp' || n.tunnel.kind === 'wan')) svgEls = svgEls.concat(curve('pub' + i, pa, aliyunPos, n.publicUp, TRANSPORTS.public, maskHost(n.tunnel.host) + ((n.tunnel.port) ? ':' + n.tunnel.port : ''), sizeN, aliyunSize, edgeRelated(n.key, 'server-aliyun')))
1398
+ if (sakuraVisible && n.sakuraTunnel) svgEls = svgEls.concat(curve('sak' + i, pa, sakuraPos, n.sakuraUp === true, TRANSPORTS.sakura, maskText(n.sakuraTunnel.addr || ''), sizeN, sakuraSize, edgeRelated(n.key, 'server-sakura')))
1399
+ if (show.lan && n.lanUp) { var lt = n.lanTunnel || {}; var lanAddr = (lt.ip || lt.host || 'LAN') + (lt.port ? ':' + lt.port : ''); svgEls = svgEls.concat(curve('lan' + i, layout.positions[localNodeIdx], pa, n.lanUp, TRANSPORTS.lan, lanAddr, sizeOf('local'), sizeN, edgeRelated('local', n.key))) }
1400
+ })
1401
+ var fedItems = []
1402
+ try {
1403
+ var directKeys = {}
1404
+ nodes.forEach(function (n) { directKeys[peerDeviceKey(n.devKey)] = true; directKeys[peerDeviceKey(n.name)] = true })
1405
+ var seenFed = {}
1406
+ var fedRaw = (props.federated && props.federated.nodes) || []
1407
+ fedRaw.forEach(function (item) {
1408
+ var dn = peerDeviceKey(item.name)
1409
+ if (!dn || directKeys[dn] || seenFed[dn] || dn === peerDeviceKey(data.nodeName)) return
1410
+ seenFed[dn] = true
1411
+ var gi = -1
1412
+ for (var ni = 0; ni < nodes.length; ni++) if (peerDeviceKey(nodes[ni].devKey) === peerDeviceKey(item.via)) { gi = ni; break }
1413
+ if (gi < 0) return
1414
+ var gp = layout.positions[gi]
1415
+ var idx = fedItems.length
1416
+ fedItems.push({ item: item, x: gp[0] + (idx % 3 - 1) * 170, y: gp[1] + 150 + Math.floor(idx / 3) * 92, gx: gp[0], gy: gp[1] })
1417
+ })
1418
+ } catch (error) { fedItems = [] }
1419
+ fedItems.forEach(function (fed) {
1420
+ svgEls.push(h('line', { key: 'fed-edge-' + fed.item.name + '-' + fed.item.via, x1: fed.gx, y1: fed.gy, x2: fed.x, y2: fed.y, className: 'dm-edge-indirect', strokeDasharray: '4 4' }))
1421
+ })
1422
+ return h('div', null,
1423
+ h('svg', { className: 'dm-lines', width: layout.W, height: layout.H, viewBox: '0 0 ' + layout.W + ' ' + layout.H }, svgEls),
1424
+ nodes.map(function (node, i) {
1425
+ var pos = layout.positions[i]
1426
+ var selected = props.selectedKey === node.key
1427
+ return h('div', {
1428
+ key: node.key,
1429
+ className: 'dm-card-node compact draggable' + (node.local ? ' local' : '') + (node.up ? '' : ' down') + (selected ? ' selected' : '') + (hoverKey === node.key ? ' hovered' : '') + (dragKey === node.key ? ' dragging' : ''),
1430
+ onMouseEnter: function () { setHoverKey(node.key) },
1431
+ onMouseLeave: function () { setHoverKey(null) },
1432
+ style: { left: pos[0] + 'px', top: pos[1] + 'px', maxWidth: '380px', transform: 'translate(-50%,-50%)' },
1433
+ ref: function (el) { if (el) cardRefs.current[node.key] = el; else delete cardRefs.current[node.key] },
1434
+ onPointerDown: function (event) { cardPointerDown(node, pos, event) },
1435
+ onPointerMove: function (event) { cardPointerMove(node, event) },
1436
+ onPointerUp: function (event) { cardPointerUp(node, event) },
1437
+ onPointerCancel: cardPointerCancel,
1438
+ title: '拖拽调整位置 · 单击查看 ' + node.name + ' 的详情'
1439
+ },
1440
+ h('div', { className: 'dm-n-head' },
1441
+ h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (node.up ? 'ok' : 'bad') }), h('span', { className: 'dm-ell' }, node.name)),
1442
+ node.local ? h('span', { className: 'dm-chip ok' }, '本机')
1443
+ : (node.up ? h('span', { className: 'dm-extra' }, node.latencyMs == null ? '' : node.latencyMs + 'ms') : h('span', { className: 'dm-chip bad' }, '离线'))
1444
+ ),
1445
+ node.up ? null : h('div', { className: 'dm-offline-row' }, '全部链路不可用' + (node.error ? ' · ' + node.error : '')),
1446
+ h('div', { className: 'dm-card-dsh' },
1447
+ (node.ports || []).map(function (item, pi) {
1448
+ var portUp = item.up == null ? node.up : Boolean(item.up)
1449
+ var bridgeUnknown = !portUp && node.up && !node.local
1450
+ return h('span', { key: pi, className: 'dm-chip type' + (portUp ? ' run' : (bridgeUnknown ? ' warn' : ' bad')), title: (portUp ? '实例在线' : (bridgeUnknown ? '节点在线,但 host bridge 未连接,实例状态未知' : (item.error ? 'host bridge 不可达:' + item.error : '实例离线 / host bridge 未连接'))) + ' · DSH 端口 ' + item.port }, String(item.port))
1451
+ })
1452
+ )
1453
+ )
1454
+ }),
1455
+ fedItems.map(function (fed) {
1456
+ var item = fed.item
1457
+ return h('div', { key: 'fed:' + item.via + ':' + item.name, className: 'dm-card-node indirect', style: { left: fed.x + 'px', top: fed.y + 'px', width: '172px', transform: 'translate(-50%,-50%)' }, title: '间接可达:经 ' + item.via },
1458
+ h('div', { className: 'dm-n-head' },
1459
+ h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (item.up === true ? 'ok' : (item.up === false ? 'bad' : 'muted')), style: item.up == null ? { opacity: .45 } : undefined }), h('span', { className: 'dm-ell' }, item.name)),
1460
+ h('span', { className: 'dm-fed-badge' }, '经 ' + item.via)
1461
+ ),
1462
+ h('div', { className: 'dm-card-dsh' },
1463
+ h('span', { className: 'dm-chip' }, item.up === true ? (item.latencyMs != null ? item.latencyMs + 'ms' : '可达') : (item.up === false ? '不可达' : '间接 · 只读'))
1464
+ )
1465
+ )
1466
+ }),
1467
+ (publicVisible ? h('div', {
1468
+ key: 'server-aliyun',
1469
+ className: 'dm-card-node server' + (infra[0].up ? '' : ' down') + (hoverKey === 'server-aliyun' ? ' hovered' : ''),
1470
+ onMouseEnter: function () { setHoverKey('server-aliyun') },
1471
+ onMouseLeave: function () { setHoverKey(null) },
1472
+ style: { left: aliyunPos[0] + 'px', top: aliyunPos[1] + 'px', maxWidth: '360px', transform: 'translate(-50%,-50%)', borderColor: TRANSPORTS.public.color },
1473
+ ref: function (el) { if (el) infraRefs.current['server-aliyun'] = el; else delete infraRefs.current['server-aliyun'] },
1474
+ onPointerDown: function (event) { cardPointerDown({ key: 'server-aliyun' }, aliyunPos, event) },
1475
+ onPointerMove: function (event) { cardPointerMove({ key: 'server-aliyun' }, event) },
1476
+ onPointerUp: function (event) { cardPointerUp({ key: 'server-aliyun' }, event) },
1477
+ onPointerCancel: cardPointerCancel
1478
+ },
1479
+ h('div', { className: 'dm-n-head' }, h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ok' }), h('span', {}, infra[0].name))),
1480
+ h('div', { className: 'dm-card-domain' }, infra[0].host),
1481
+ h('div', { className: 'dm-card-dsh' }, h('span', { className: 'dm-chip', style: { borderColor: TRANSPORTS.public.color, color: TRANSPORTS.public.color } }, 'frps'), h('span', { className: 'dm-chip' }, infra[0].detail))
1482
+ ) : null),
1483
+ (sakuraVisible ? h('div', {
1484
+ key: 'server-sakura',
1485
+ className: 'dm-card-node server' + (infra[1].up ? '' : ' down') + (hoverKey === 'server-sakura' ? ' hovered' : ''),
1486
+ onMouseEnter: function () { setHoverKey('server-sakura') },
1487
+ onMouseLeave: function () { setHoverKey(null) },
1488
+ style: { left: sakuraPos[0] + 'px', top: sakuraPos[1] + 'px', maxWidth: '360px', transform: 'translate(-50%,-50%)', borderColor: TRANSPORTS.sakura.color },
1489
+ ref: function (el) { if (el) infraRefs.current['server-sakura'] = el; else delete infraRefs.current['server-sakura'] },
1490
+ onPointerDown: function (event) { cardPointerDown({ key: 'server-sakura' }, sakuraPos, event) },
1491
+ onPointerMove: function (event) { cardPointerMove({ key: 'server-sakura' }, event) },
1492
+ onPointerUp: function (event) { cardPointerUp({ key: 'server-sakura' }, event) },
1493
+ onPointerCancel: cardPointerCancel
1494
+ },
1495
+ h('div', { className: 'dm-n-head' }, h('div', { className: 'dm-n-name' }, h('span', { className: 'dm-dot ' + (infra[1].up ? 'ok' : 'bad') }), h('span', {}, infra[1].name))),
1496
+ h('div', { className: 'dm-card-domain' }, infra[1].host),
1497
+ h('div', { className: 'dm-card-dsh' }, h('span', { className: 'dm-chip', style: { borderColor: TRANSPORTS.sakura.color, color: TRANSPORTS.sakura.color } }, 'Sakura'), h('span', { className: 'dm-chip' }, infra[1].detail))
1498
+ ) : null)
1499
+ )
1500
+ }
1501
+
1502
+ // ---------- 画布下方:设备详情(默认全展示,点卡片高亮对应项) ----------
1503
+ function DeviceDetails(props) {
1504
+ var data = props.data
1505
+ var peers = filterSelfPeers(dedupePeers(data.peers || []), data.nodeName)
1506
+ var relayHosts = relayHostSet(peers)
1507
+ var hw = data.hardware || {}
1508
+ var instances = (data.local && data.local.instances) || []
1509
+ var endpoint = data.endpoint || {}
1510
+ var localTunnel = data.localTunnel || {}
1511
+ var localUp = instances.filter(function (x) { return x.up }).length
1512
+ var sessions = instances.reduce(function (n, x) { return n + (x.sessions || 0) }, 0)
1513
+ var running = instances.reduce(function (n, x) { return n + (x.running || 0) }, 0)
1514
+ var model = instances.length && instances[0].model ? (instances[0].model.provider || '') + ' / ' + (instances[0].model.model || '') : '—'
1515
+ var localTunnelText = null
1516
+ var tunnels = (localTunnel.tunnels || []).filter(tunnelMatchesNode(localTunnel.port))
1517
+ var entry = publicEntryFor(localTunnel) || null
1518
+ var tunnelNames = tunnels.map(function (x) { return x.name + '→' + maskHost(x.host) + ':' + x.port }).join(' / ')
1519
+ var mainSrc = entry ? (localTunnel.mainSource || (entry.source === 'self-hosted-frps' ? 'self-hosted' : (entry.source === 'sakura' ? 'sakura' : 'none'))) : 'none'
1520
+ var entryText = entry && entry.host
1521
+ ? maskHost(entry.host) + ':' + entry.port + (mainSrc === 'self-hosted' ? '(自建 frps · 主用)' : (mainSrc === 'sakura' ? '(Sakura · 备用)' : ''))
1522
+ : '未配置'
1523
+ var localTunnelRows = [
1524
+ ['入口', entryText],
1525
+ ['监听 / frpc', (localTunnel.bind || '?') + ':' + (localTunnel.port || '?') + (localTunnel.tls ? ' (TLS)' : '') + ' · ' + (localTunnel.frpRunning ? 'frpc ×' + (localTunnel.frpcCount || 1) : 'frpc 未运行')]
1526
+ ]
1527
+ if (tunnels.length) localTunnelRows.push(['Sakura 备用', tunnels.map(function (x) { return maskHost(x.host) + ':' + x.port }).join(' / ')])
1528
+ if (mainSrc === 'self-hosted' && localTunnel.selfHosted && localTunnel.selfHosted.configPath) localTunnelRows.push(['frpc 配置', localTunnel.selfHosted.configPath])
1529
+ var localTunnelAll = tunnelNames
1530
+ var memText = hw.memTotalGB ? ((hw.memFreeGB != null ? hw.memFreeGB : '?') + ' / ' + hw.memTotalGB + ' GB') : '—'
1531
+ var cpuText = hw.cpuModel ? hw.cpuModel + ' · ' + (hw.cpuCores || '?') + ' 线程' : '—'
1532
+ function diskText(d) {
1533
+ if (!d || d.totalGB == null) return '—'
1534
+ var p = String(d.path || '').replace(/[\\/]+$/, '')
1535
+ return (d.freeGB != null ? d.freeGB : '?') + ' / ' + d.totalGB + ' GB' + (p ? ' (' + p + ')' : '')
1536
+ }
1537
+ // 设备类型:按平台/CPU 形态泛化推断(不依赖任何个人设备表)
1538
+ function formFactorOf(h) {
1539
+ if (!h) return '—'
1540
+ if (h.platform === 'linux' && String(h.arch || '').indexOf('arm') === 0) return '手机'
1541
+ var cpu = String(h.cpuModel || '')
1542
+ if (/\b(i[3579]-\d{4,5}(U|H|P|HS)|Ryzen \d+ \d+(U|H|HS))\b/i.test(cpu)) return '笔记本'
1543
+ return cpu ? '主机' : '—'
1544
+ }
1545
+ function memTextOf(h) {
1546
+ return h && h.memTotalGB ? ((h.memFreeGB != null ? h.memFreeGB : '?') + ' / ' + h.memTotalGB + ' GB') : '—'
1547
+ }
1548
+ function cpuTextOf(h) {
1549
+ return h && h.cpuModel ? h.cpuModel + ' · ' + (h.cpuCores || '?') + ' 线程' : '—'
1550
+ }
1551
+ function ipKindOf(peer) {
1552
+ var t = peer.tunnel || {}
1553
+ if (t.kind === 'loopback') return '本机回环'
1554
+ if (t.kind === 'lan') return '局域网直连'
1555
+ if (t.kind === 'frp') return '公网穿透(域名)'
1556
+ if (t.kind === 'wan') return relayHosts[String(t.host || '').toLowerCase()] ? '公网(共享中转)' : '公网 IP 直连'
1557
+ return '—'
1558
+ }
1559
+ function relayKindOf(peer) {
1560
+ var t = peer.tunnel || {}
1561
+ if (t.kind === 'loopback') return '无(本机回环)'
1562
+ if (t.kind === 'lan') return '无(局域网直连)'
1563
+ if (t.kind === 'frp') return 'Sakura / 第三方 frp 穿透'
1564
+ if (t.kind === 'wan') return relayHosts[String(t.host || '').toLowerCase()] ? '自建公网中继(frps)' : '公网 IP 直连'
1565
+ return '—'
1566
+ }
1567
+ function dshVerOf(peer) {
1568
+ var v = peer.version ? 'v' + peer.version : '—'
1569
+ if (peer.plugin) v += ' · 插件 ' + peer.plugin
1570
+ return v
1571
+ }
1572
+
1573
+ // 2026-11-10:设备状态与拓扑同口径 —— 任一链路可达即算在线(HTTP 探测偶发超时不再误判红点)
1574
+ var lanUpByDev = {}
1575
+ ;(data.peers || []).forEach(function (p) {
1576
+ if (!isLanPeer(p)) return
1577
+ var tu = p.tunnel || {}
1578
+ lanUpByDev[peerDeviceKey(p.name)] = tu.probe ? tu.probe.up === true : Boolean(p.up)
1579
+ })
1580
+ function peerLinkState(peer) {
1581
+ var t = peer.tunnel || {}
1582
+ var httpUp = Boolean(peer.up)
1583
+ var tcpUp = t.probe ? t.probe.up === true : false
1584
+ var sk = sakuraTunnelOf(data, peerDeviceKey(peer.name))
1585
+ var sakuraUp = sk ? sk.online === true : null
1586
+ var lanUp = lanUpByDev[peerDeviceKey(peer.name)] === true
1587
+ return {
1588
+ httpUp: httpUp, tcpUp: tcpUp, sakuraUp: sakuraUp, lanUp: lanUp,
1589
+ up: httpUp || tcpUp || sakuraUp === true || lanUp
1590
+ }
1591
+ }
1592
+ function linkStateText(ls) {
1593
+ return "公网 " + ((ls.httpUp || ls.tcpUp) ? "✅" : "❌")
1594
+ + " / 樱花 " + (ls.sakuraUp == null ? "—" : (ls.sakuraUp ? "✅" : "❌"))
1595
+ + (ls.lanUp ? " / LAN ✅" : "")
1596
+ + (ls.httpUp ? "" : "(HTTP 超时,隧道" + (ls.tcpUp ? "通" : "断") + ")")
1597
+ }
1598
+
1599
+ // 2026-11-10:点击节点后自动滚动到对应设备卡片,保证卡片完整可见
1600
+ var selectedDeviceRef = useRef(null)
1601
+ useEffect(function () {
1602
+ if (!props.selectedKey || !selectedDeviceRef.current) return
1603
+ var timer = setTimeout(function () {
1604
+ try { selectedDeviceRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch (error) { selectedDeviceRef.current.scrollIntoView() }
1605
+ }, 40)
1606
+ return function () { clearTimeout(timer) }
1607
+ }, [props.selectedKey])
1608
+
1609
+ var devices = [{
1610
+ key: 'local',
1611
+ name: nodeDisplay(data.nodeName) + '(本机)',
1612
+ up: Boolean(endpoint.up),
1613
+ rows: [
1614
+ ['设备类型', formFactorOf(hw)],
1615
+ ['操作系统', hw.os || '—'],
1616
+ ['内存', memTextOf(hw)],
1617
+ ['磁盘', diskText(hw.disk)],
1618
+ ['CPU', cpuTextOf(hw)],
1619
+ ['DSH 版本', data.meshVersion ? 'v' + data.meshVersion : '—'],
1620
+ ['IP 类型', '本机回环'],
1621
+ ['端口号', localTunnel.port != null ? String(localTunnel.port) : '—'],
1622
+ ['公网穿透类型', mainSrc === 'self-hosted' ? '自建 frps 中继(主用)' : (mainSrc === 'sakura' ? 'Sakura 穿透(备用)' : '无(仅局域网)')]
1623
+ ]
1624
+ }].concat(peers.map(function (peer) {
1625
+ var t = peer.tunnel || {}
1626
+ var ls = peerLinkState(peer)
1627
+ var hwp = peer.hardware || {}
1628
+ return {
1629
+ key: 'peer:' + peer.name,
1630
+ name: nodeDisplay(peerDeviceKey(peer.name)),
1631
+ up: ls.up,
1632
+ rows: [
1633
+ ['设备类型', formFactorOf(hwp)],
1634
+ ['操作系统', hwp.os || peer.os || '—'],
1635
+ ['内存', memTextOf(hwp)],
1636
+ ['磁盘', diskText(hwp.disk)],
1637
+ ['CPU', cpuTextOf(hwp)],
1638
+ ['DSH 版本', dshVerOf(peer)],
1639
+ ['IP 类型', ipKindOf(peer)],
1640
+ ['端口号', t.port ? String(t.port) : '—'],
1641
+ ['公网穿透类型', relayKindOf(peer)],
1642
+ ['链路状态', linkStateText(ls)]
1643
+ ]
1644
+ }
1645
+ }))
1646
+
1647
+ return h('div', { className: 'dm-devices' },
1648
+ devices.map(function (device) {
1649
+ return h('div', { key: device.key, ref: props.selectedKey === device.key ? function (el) { selectedDeviceRef.current = el } : null, className: 'dm-device' + (props.selectedKey === device.key ? ' selected' : '') },
1650
+ h('div', { className: 'dm-device-head' },
1651
+ h('span', { className: 'dm-dot ' + (device.up ? 'ok' : 'bad') }),
1652
+ h('span', { className: 'dm-device-name' }, device.name),
1653
+ device.key === 'local' ? h('span', { className: 'dm-chip ok' }, '本机') : null
1654
+ ),
1655
+ h('div', { className: 'dm-device-body' }, device.rows.map(function (row, i) {
1656
+ return h('div', { className: 'dm-device-row', key: i },
1657
+ h('span', { className: 'dm-device-k' }, row[0]),
1658
+ h('span', { className: 'dm-device-v', title: String(row[1]) }, String(row[1]))
1659
+ )
1660
+ }))
1661
+ )
1662
+ })
1663
+ )
1664
+ }
1665
+
1666
+ function Details(props) {
1667
+ var storeKey = 'dsh-mesh-ui.details.' + encodeURIComponent(String(props.title || ''))
1668
+ var sOpen = useState(function () {
1669
+ try {
1670
+ var saved = window.localStorage.getItem(storeKey)
1671
+ return saved == null ? Boolean(props.open) : saved === '1'
1672
+ } catch (_) { return Boolean(props.open) }
1673
+ })
1674
+ var open = sOpen[0]; var setOpen = sOpen[1]
1675
+ function onToggle(event) {
1676
+ var next = Boolean(event.currentTarget.open)
1677
+ setOpen(next)
1678
+ try { window.localStorage.setItem(storeKey, next ? '1' : '0') } catch (_) { /* ignore */ }
1679
+ }
1680
+ return h('details', { className: 'dm-details', open: open, onToggle: onToggle },
1681
+ h('summary', null, props.title, props.badge != null ? h('span', { className: 'dm-pill muted' }, String(props.badge)) : null),
1682
+ // 2026-11-10:折叠面板正文用独立容器整栏铺满;原先借用 .dm-body(62% 双栏 grid)
1683
+ // 会把「委托台账」这类单列表格压到 300px,中文被逐字折成竖排。
1684
+ h('div', { className: 'dm-details-body' }, props.children)
1685
+ )
1686
+ }
1687
+
1688
+ function InstanceList(props) {
1689
+ var instances = (props.data.local && props.data.local.instances) || []
1690
+ if (!instances.length) return h('div', { className: 'dm-empty' }, '无本机实例数据')
1691
+ return h('div', null, instances.map(function (inst) {
1692
+ var model = inst.model ? (inst.model.provider || '') + ' / ' + (inst.model.model || '') : '—'
1693
+ return h('div', { className: 'dm-instrow', key: inst.id },
1694
+ h('div', { style: { minWidth: 0 } },
1695
+ h('div', { style: { fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { className: 'dm-dot ' + (inst.up ? 'ok' : 'bad') }), inst.id),
1696
+ h('div', { className: 'dm-sub', style: { marginTop: 1 } }, model)
1697
+ ),
1698
+ h('div', { className: 'dm-mini', style: { marginTop: 0 } },
1699
+ h('span', { className: 'dm-chip' }, 'pid ' + (inst.pid == null ? '—' : inst.pid)),
1700
+ h('span', { className: 'dm-chip' }, 'tools ' + (inst.tools == null ? '—' : inst.tools)),
1701
+ h('span', { className: 'dm-chip' }, '会话 ' + (inst.sessions || 0)),
1702
+ h('span', { className: 'dm-chip' + (inst.running ? ' run' : '') }, '运行 ' + (inst.running || 0))
1703
+ )
1704
+ )
1705
+ }))
1706
+ }
1707
+
1708
+ function DelegationTable(props) {
1709
+ var d = props.data.delegations || {}
1710
+ var list = (d.recent || []).slice(0, 6)
1711
+ var stats = d.byState || {}
1712
+ if (!list.length) return h('div', { className: 'dm-empty' }, '暂无委托记录')
1713
+ return h('div', null,
1714
+ h('div', { className: 'dm-sub', style: { marginBottom: 5 } }, '共 ' + (d.total || 0) + ' 条 · ' + Object.keys(stats).map(function (k) { return k + ' ' + stats[k] }).join(' · ')),
1715
+ // 2026-11-10:外层给横向滚动兜底——栏窄到连固定列宽都放不下时滚动,而不是压缩成竖排。
1716
+ h('div', { className: 'dm-table-wrap' },
1717
+ h('table', { className: 'dm-table' },
1718
+ h('thead', null, h('tr', null, h('th', null, '状态'), h('th', null, '节点'), h('th', null, '任务'), h('th', null, '耗时'), h('th', null, '目标'))),
1719
+ h('tbody', null, list.map(function (task) {
1720
+ return h('tr', { key: task.taskId },
1721
+ h('td', null, stateChip(task.state)),
1722
+ h('td', { title: nodeLabel(task.node) }, nodeLabel(task.node)),
1723
+ h('td', { className: 'dm-mono', style: { fontSize: 11 }, title: String(task.taskId || '') }, String(task.taskId || '').replace(/^T-\d{8}-/, 'T-…')),
1724
+ h('td', { className: 'dm-mono' }, dur(task.durationSec)),
1725
+ h('td', null, h('span', { className: 'dm-trunc', title: task.goal }, task.goal || ''))
1726
+ )
1727
+ }))
1728
+ )
1729
+ )
1730
+ )
1731
+ }
1732
+
1733
+ function AlertList(props) {
1734
+ var alerts = props.data.alerts || {}
1735
+ var latest = alerts.latest
1736
+ var history = (alerts.history || []).filter(function (item) { return !latest || item.at !== latest.at }).slice().reverse()
1737
+ if (!latest && !history.length) return h('div', { className: 'dm-empty' }, '没有告警记录')
1738
+ return h('div', null,
1739
+ latest ? h('div', { className: 'dm-alertrow' },
1740
+ h('span', { className: 'dm-chip bad' }, latest.kind || 'alert'),
1741
+ h('span', null, h('span', { className: 'dm-mono' }, latest.at), ' · ', latest.summary || '', latest.consecutiveBad ? ' · 连续 ' + latest.consecutiveBad + ' 次' : '')
1742
+ ) : null,
1743
+ history.slice(0, 6).map(function (item, i) {
1744
+ return h('div', { className: 'dm-alertrow', key: i, style: { opacity: 0.75 } },
1745
+ h('span', { className: 'dm-chip' }, item.kind || '?'),
1746
+ h('span', null, h('span', { className: 'dm-mono' }, item.at || ''), ' · ', item.summary || '')
1747
+ )
1748
+ })
1749
+ )
1750
+ }
1751
+
1752
+ function jsonFetch(url, options) {
1753
+ return fetch(url, options).then(function (r) { return r.text() }).then(function (text) { try { return JSON.parse(text) } catch (error) { return { ok: false, error: text.slice(0, 120) } } })
1754
+ }
1755
+
1756
+ // 2026-11-10:节点管理(从本机移除 peer / 吊销入站 token)。
1757
+ function NodeManager(props) {
1758
+ var sp = useState(null); var peers = sp[0]; var setPeers = sp[1]
1759
+ var st = useState(null); var tokens = st[0]; var setTokens = st[1]
1760
+ var sb = useState(false); var busy = sb[0]; var setBusy = sb[1]
1761
+ var sm = useState(''); var msg = sm[0]; var setMsg = sm[1]
1762
+ function refresh() {
1763
+ setBusy(true)
1764
+ Promise.all([jsonFetch('/api/dsh-mesh/peers'), jsonFetch('/api/dsh-mesh/tokens')]).then(function (r) {
1765
+ if (r[0].ok) setPeers(r[0].peers || []); else setMsg(r[0].error || '读取节点失败')
1766
+ if (r[1].ok) setTokens(r[1].tokens || [])
1767
+ }).finally(function () { setBusy(false) })
1768
+ }
1769
+ useEffect(function () { refresh() }, [])
1770
+ function togglePeer(peer) {
1771
+ setBusy(true); setMsg('')
1772
+ fetch('/api/dsh-mesh/peers/toggle', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: peer.id, disabled: !peer.disabled }) })
1773
+ .then(function (r) { return r.text() }).then(function (text) { try { var j = JSON.parse(text); setMsg(j.ok ? ((peer.disabled ? '已启用 ' : '已禁用 ') + peer.name) : ('操作失败:' + (j.stderr || j.error || '').slice(0, 200))) } catch (e) { setMsg(text.slice(0, 120)) } refresh(); if (props.onChanged) props.onChanged() })
1774
+ .finally(function () { setBusy(false) })
1775
+ }
1776
+ function removePeer(peer) {
1777
+ if (!window.confirm('从本机移除节点「' + peer.name + '」?\n这会删除本机到它的连接;对方的入站 token 需在下方吊销。')) return
1778
+ setBusy(true); setMsg('')
1779
+ jsonFetch('/api/dsh-mesh/peers/remove', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: peer.id }) })
1780
+ .then(function (j) { setMsg(j.ok ? '已移除 ' + peer.name : ('移除失败:' + (j.stderr || j.error || '').slice(0, 200))); refresh(); if (props.onChanged) props.onChanged() })
1781
+ .finally(function () { setBusy(false) })
1782
+ }
1783
+ function revokeToken(tok) {
1784
+ if (!window.confirm('吊销入站 token「' + (tok.note || tok.id) + '」?\n该凭据对应的对端会立即失去访问。')) return
1785
+ setBusy(true); setMsg('')
1786
+ jsonFetch('/api/dsh-mesh/tokens/revoke', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: tok.id }) })
1787
+ .then(function (j) { setMsg(j.ok ? '已吊销 ' + tok.id : ('吊销失败:' + (j.stderr || j.error || '').slice(0, 200))); refresh(); if (props.onChanged) props.onChanged() })
1788
+ .finally(function () { setBusy(false) })
1789
+ }
1790
+ return h('div', { className: 'dm-nodes' },
1791
+ h('div', { className: 'dm-lan-row' },
1792
+ h('button', { className: 'dm-btn', disabled: busy, onClick: refresh }, '刷新节点'),
1793
+ h('span', { className: 'dm-sub' }, '移除只影响本机;要让对方也失去访问,请吊销对应入站 token')
1794
+ ),
1795
+ msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
1796
+ peers === null ? h('div', { className: 'dm-sub' }, '加载中…') :
1797
+ peers.length ? peers.map(function (peer) {
1798
+ return h('div', { className: 'dm-node-row', key: peer.id },
1799
+ h('span', { className: 'dm-dot ' + (peer.disabled ? 'muted' : 'ok'), style: peer.disabled ? { opacity: .4 } : undefined }),
1800
+ h('span', { className: 'dm-node-name' }, peer.name + (peer.disabled ? '(已禁用)' : '')),
1801
+ h('span', { className: 'dm-node-url' }, maskText(peer.url || '')),
1802
+ h('button', { className: 'dm-btn', disabled: busy, onClick: function () { togglePeer(peer) } }, peer.disabled ? '启用' : '禁用'),
1803
+ h('button', { className: 'dm-btn bad', disabled: busy, onClick: function () { removePeer(peer) } }, '移除')
1804
+ )
1805
+ }) : h('div', { className: 'dm-sub' }, '没有直接配对的节点'),
1806
+ tokens && tokens.length ? h('details', { className: 'dm-details', style: { marginTop: 8 } },
1807
+ h('summary', null, '入站 token(' + tokens.length + ')'),
1808
+ tokens.map(function (tok) {
1809
+ return h('div', { className: 'dm-node-row', key: tok.id },
1810
+ h('span', { className: 'dm-node-name' }, tok.id),
1811
+ h('span', { className: 'dm-node-url' }, (tok.note || '') + (tok.role ? ' · ' + tok.role : '')),
1812
+ h('button', { className: 'dm-btn bad', disabled: busy, onClick: function () { revokeToken(tok) } }, '吊销')
1813
+ )
1814
+ })
1815
+ ) : null
1816
+ )
1817
+ }
1818
+
1819
+ // 2026-11-11:AI 网络使用说明 / 可复制提示词
1820
+ function UsageHelp(props) {
1821
+ return h('div', { className: 'dm-usage' },
1822
+ h('div', { className: 'dm-sub' }, '这里主要看网络状态;具体操作可以直接让 AI 执行,不需要手动记命令。'),
1823
+ h('div', { className: 'dm-usage-row' }, h('span', { className: 'dm-dot ok' }), h('span', null, '绿色:在线;黄色:节点在线但 host bridge 未连接;红色:离线')),
1824
+ h('div', { className: 'dm-usage-row' }, h('span', { className: 'dm-dot warn' }), h('span', null, '局域网配对仍需对方的一次性配对码,不会自动连接')),
1825
+ h('button', { className: 'dm-btn primary', style: { marginTop: 6 }, onClick: props.onOpen }, '查看可以让 AI 做什么')
1826
+ )
1827
+ }
1828
+
1829
+ function HelpSheet(props) {
1830
+ var cp = useState(''); var copied = cp[0]; var setCopied = cp[1]
1831
+ var prompts = [
1832
+ ['检查网络', '检查当前 AI 网络状态,列出离线节点、host bridge 未连接原因和可执行的修复建议。'],
1833
+ ['委托任务', '委托peer-x检查当前定时任务和日志,汇总异常后汇报,不要重启 3081。'],
1834
+ ['传文件', '把本机文件传到peer-x指定目录,分块传输,传完用 sha256 校验并告诉我结果。'],
1835
+ ['局域网', '扫描局域网节点,自动检查链路;如果我要配对,先让我生成/输入一次性配对码。'],
1836
+ ['更新节点', '把peer-x的 dsh-mesh 插件更新到最新版本,更新前备份,完成后验证三实例和定时任务。']
1837
+ ]
1838
+ function copy(text) {
1839
+ try { navigator.clipboard && navigator.clipboard.writeText(text) } catch (_) {}
1840
+ setCopied(text); setTimeout(function () { setCopied('') }, 1500)
1841
+ }
1842
+ return h('div', { className: 'dm-help-overlay', onClick: props.onClose },
1843
+ h('div', { className: 'dm-help-card', onClick: function (e) { e.stopPropagation() } },
1844
+ h('div', { className: 'dm-help-head' },
1845
+ h('span', { className: 'dm-help-title' }, 'AI 网络 · 使用说明'),
1846
+ h('button', { className: 'dm-btn', onClick: props.onClose }, '关闭')
1847
+ ),
1848
+ h('div', { className: 'dm-help-section' },
1849
+ h('div', { className: 'dm-help-h' }, '可以直接让 AI 做的事'),
1850
+ h('div', { className: 'dm-sub' }, '下面只是示例;实际直接把目标/路径/节点说清楚即可。')
1851
+ ),
1852
+ prompts.map(function (p, i) {
1853
+ return h('div', { className: 'dm-help-prompt', key: i },
1854
+ h('div', { className: 'dm-help-prompt-head' }, h('b', null, p[0]), h('button', { className: 'dm-btn', onClick: function () { copy(p[1]) } }, copied === p[1] ? '已复制' : '复制')),
1855
+ h('div', { className: 'dm-sub' }, p[1])
1856
+ )
1857
+ }),
1858
+ h('div', { className: 'dm-help-section' },
1859
+ h('div', { className: 'dm-help-h' }, '边界'),
1860
+ h('div', { className: 'dm-sub' }, '跨机写入默认受限;文件写入需要目标节点 ACL 允许。配对码不要发给不可信节点;关闭公网地址只影响显示,不改变实际链路。')
1861
+ )
1862
+ )
1863
+ )
1864
+ }
1865
+
1866
+ function TransferHistory() {
1867
+ var s = useState(null); var items = s[0]; var setItems = s[1]
1868
+ var b = useState(false); var busy = b[0]; var setBusy = b[1]
1869
+ var m = useState(''); var msg = m[0]; var setMsg = m[1]
1870
+ function refresh() {
1871
+ setBusy(true)
1872
+ jsonFetch('/api/dsh-mesh/transfers').then(function (j) {
1873
+ if (j.ok) setItems(j.items || []); else setMsg(j.error || '读取传输历史失败')
1874
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1875
+ }
1876
+ useEffect(function () { refresh() }, [])
1877
+ function fmtBytes(n) { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB' }
1878
+ return h('div', { className: 'dm-transfers' },
1879
+ h('div', { className: 'dm-lan-row' },
1880
+ h('button', { className: 'dm-btn', disabled: busy, onClick: refresh }, busy ? '读取中…' : '刷新历史'),
1881
+ h('span', { className: 'dm-sub' }, '记录通过 file_read_chunk / file_write_chunk 完成的传输')
1882
+ ),
1883
+ msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
1884
+ items === null ? h('div', { className: 'dm-sub' }, '加载中…') :
1885
+ items.length ? items.map(function (it, i) {
1886
+ return h('div', { className: 'dm-node-row', key: i },
1887
+ h('span', { className: 'dm-chip ' + (it.direction === 'write' ? 'run' : 'muted') }, it.direction === 'write' ? '写入' : '读取'),
1888
+ h('span', { className: 'dm-node-name' }, String(it.path || '').split(/[\\/]/).pop() || '?'),
1889
+ h('span', { className: 'dm-node-url' }, (it.peer || '本机') + ' · ' + fmtBytes(it.bytes) + (it.totalBytes ? ' / ' + fmtBytes(it.totalBytes) : '')),
1890
+ h('span', { className: 'dm-sub' }, it.at ? new Date(it.at).toLocaleTimeString() : '')
1891
+ )
1892
+ }) : h('div', { className: 'dm-sub' }, '还没有传输记录')
1893
+ )
1894
+ }
1895
+
1896
+ // 2026-11-10:局域网首次接入(扫描 / 生成配对码 / 配对)。
1897
+ function LanOnboarding(props) {
1898
+ var sNodes = useState(null); var nodes = sNodes[0]; var setNodes = sNodes[1]
1899
+ var sBusy = useState(false); var busy = sBusy[0]; var setBusy = sBusy[1]
1900
+ var sMsg = useState(''); var msg = sMsg[0]; var setMsg = sMsg[1]
1901
+ var sCode = useState(''); var code = sCode[0]; var setCode = sCode[1]
1902
+ var sMine = useState(null); var mine = sMine[0]; var setMine = sMine[1]
1903
+ var sStatus = useState({}); var statuses = sStatus[0]; var setStatuses = sStatus[1]
1904
+
1905
+ function lanJson(url, options) {
1906
+ return fetch(url, options).then(function (res) {
1907
+ return res.text().then(function (text) {
1908
+ try { return JSON.parse(text) } catch (error) { return { ok: false, error: '此实例的 dsh-mesh-ui 服务端还是旧版(重启 DSH 后可用):' + text.slice(0, 120) } }
1909
+ })
1910
+ })
1911
+ }
1912
+ function scan() {
1913
+ setBusy(true); setMsg('')
1914
+ lanJson('/api/dsh-mesh/lan/scan').then(function (j) {
1915
+ if (!j.ok) { setMsg(j.error || '扫描失败'); return }
1916
+ setNodes(j.nodes || [])
1917
+ setStatuses({})
1918
+ setMsg((j.nodes || []).length ? '发现 ' + j.nodes.length + ' 个节点,正在自动检查链路…' : '没有发现局域网节点(对方需 bind 0.0.0.0 并放行 8124)')
1919
+ ;(j.nodes || []).forEach(function (node, idx) {
1920
+ lanJson('/api/dsh-mesh/lan/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: String(node.url || '') }) })
1921
+ .then(function (r) { setStatuses(function (prev) { var n = Object.assign({}, prev); n[idx] = r.ok ? 'ok' : 'bad'; return n }); if (r.ok) setMsg('发现 ' + (j.nodes || []).length + ' 个节点 · 链路正常 ' + (node.node || '')) })
1922
+ .catch(function () { setStatuses(function (prev) { var n = Object.assign({}, prev); n[idx] = 'bad'; return n }) })
1923
+ })
1924
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1925
+ }
1926
+ useEffect(function () { scan() }, [])
1927
+ function myCode() {
1928
+ setBusy(true); setMine(null); setMsg('')
1929
+ lanJson('/api/dsh-mesh/lan/code').then(function (j) {
1930
+ if (!j.ok) { setMsg(j.error || '生成配对码失败'); return }
1931
+ setMine(j)
1932
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1933
+ }
1934
+ function checkNode(node) {
1935
+ setBusy(true); setMsg('')
1936
+ lanJson('/api/dsh-mesh/lan/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: String(node.url || '') }) })
1937
+ .then(function (j) {
1938
+ if (!j.ok) { setMsg('链路不可达:' + (j.error || 'unknown')); return }
1939
+ setMsg('链路可达:' + j.host + ':' + j.port + ' · TCP ok · ' + j.latencyMs + 'ms' + (j.tls && j.tls.ok ? ' · TLS ok' : ''))
1940
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1941
+ }
1942
+ function pairWith(node) {
1943
+ var c = String(code || '').trim()
1944
+ if (!c) { setMsg('先填入对方显示的配对码'); return }
1945
+ setBusy(true); setMsg('')
1946
+ lanJson('/api/dsh-mesh/lan/pair', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: String(node.url || '').replace(/\/mcp$/, ''), code: c, name: (node.node || 'lan') + '-lan', fingerprint: node.certFingerprint || '' }) })
1947
+ .then(function (j) {
1948
+ setMsg(j.ok ? '配对成功:' + (node.node || '') + ',正在复查链路…' : ('配对失败:' + (j.stderr || j.error || j.stdout || '').slice(0, 300)))
1949
+ if (j.ok) setTimeout(function () { checkNode(node); scan(); if (props.onChanged) props.onChanged() }, 900)
1950
+ }).catch(function (e) { setMsg(String(e.message || e)) }).finally(function () { setBusy(false) })
1951
+ }
1952
+ return h('div', null,
1953
+ h('div', { className: 'dm-lan-row' },
1954
+ h('button', { className: 'dm-btn', disabled: busy, onClick: scan }, busy ? '处理中…' : '扫描局域网'),
1955
+ h('button', { className: 'dm-btn', disabled: busy, onClick: myCode }, '生成我的配对码'),
1956
+ h('span', { className: 'dm-sub' }, '扫描只发现节点,不会自动连接;配对必须输入对方生成的一次性配对码')
1957
+ ),
1958
+ msg ? h('div', { className: 'dm-sub', style: { marginTop: 6 } }, msg) : null,
1959
+ mine ? h('div', { className: 'dm-lan-code', style: { marginTop: 8 } }, '配对码 ' + mine.pairingCode + ' · ' + (mine.endpoint || '') + (mine.certFingerprint ? ' · 指纹 ' + mine.certFingerprint : '')) : null,
1960
+ nodes && nodes.length ? h('div', { style: { marginTop: 8 } },
1961
+ nodes.map(function (node, i) {
1962
+ return h('div', { className: 'dm-lan-node', key: i },
1963
+ h('span', { className: 'dm-dot ' + (statuses[i] === 'ok' ? 'ok' : statuses[i] === 'bad' ? 'bad' : 'muted'), title: statuses[i] === 'ok' ? '链路可达' : statuses[i] === 'bad' ? '链路不可达' : '检查中' }),
1964
+ h('span', null, (node.node || '?') + ' · ' + (node.url || '').replace('/mcp', '')),
1965
+ h('button', { className: 'dm-btn', disabled: busy, onClick: function () { checkNode(node) } }, '检查链路'),
1966
+ h('button', { className: 'dm-btn primary', disabled: busy || !String(code || '').trim(), onClick: function () { pairWith(node) } }, '用此节点配对')
1967
+ )
1968
+ })
1969
+ ) : null,
1970
+ nodes && nodes.length ? h('div', { className: 'dm-lan-row' },
1971
+ h('input', { placeholder: '对方显示的配对码,如 H5K4-9RXV', value: code, onChange: function (e) { setCode(e.target.value) } })
1972
+ ) : null
1973
+ )
1974
+ }
1975
+
1976
+ function MeshStatusSection() {
1977
+ var s0 = useState(null); var data = s0[0]; var setData = s0[1]
1978
+ var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
1979
+ var sFed = useState(null); var federated = sFed[0]; var setFederated = sFed[1]
1980
+ var federatedRef = useRef({ at: 0 })
1981
+ var s2 = useState(''); var error = s2[0]; var setError = s2[1]
1982
+ var s3 = useState(0); var tick = s3[0]; var setTick = s3[1]
1983
+ var s4 = useState(''); var copied = s4[0]; var setCopied = s4[1]
1984
+ // 2026-11-10:真刷新 —— 点击后等待后台采集完成再渲染;本地冷却 + 忙锁防止连点刷爆请求
1985
+ var s6 = useState(""); var refreshHint = s6[0]; var setRefreshHint = s6[1]
1986
+ var forceRef = useRef({ at: 0, busy: false })
1987
+ // 2026-11-10:verdict 防抖 —— 单次探测失败(远端忙/超时)只显示 degraded,连续 2 次才显示 down
1988
+ var verdictState = useState(''); var verdictShown = verdictState[0]; var setVerdictShown = verdictState[1]
1989
+ var verdictRef = useRef({ raw: '', streak: 0 })
1990
+ var s5 = useState('local'); var selectedKey = s5[0]; var setSelectedKey = s5[1]
1991
+ var sHelp = useState(false); var helpOpen = sHelp[0]; var setHelpOpen = sHelp[1]
1992
+ var sPublic = useState(function () {
1993
+ try { return window.localStorage.getItem('dsh-mesh.show-public-addresses') === '1' } catch (error) { return false }
1994
+ })
1995
+ var showPublic = sPublic[0]; var setShowPublic = sPublic[1]
1996
+ showPublicAddresses = showPublic
1997
+ function toggleShowPublic(event) {
1998
+ var next = Boolean(event && event.target && event.target.checked)
1999
+ setShowPublic(next)
2000
+ showPublicAddresses = next
2001
+ try { window.localStorage.setItem('dsh-mesh.show-public-addresses', next ? '1' : '0') } catch (error) { /* ignore */ }
2002
+ }
2003
+ // 双栏宽度:拖动分隔条调整并记忆,窄屏由 CSS 媒体查询自动堆叠。
2004
+ var dragState = useState(function () {
2005
+ try { var saved = Number(window.localStorage.getItem('dsh-mesh-split-w')); return Number.isFinite(saved) && saved > 0 ? saved : 0 } catch (error) { return 0 }
2006
+ })
2007
+ var splitW = dragState[0]; var setSplitW = dragState[1]
2008
+
2009
+ function startSplit(event) {
2010
+ event.preventDefault()
2011
+ var body = event.currentTarget.parentElement
2012
+ if (!body) return
2013
+ var rect = body.getBoundingClientRect()
2014
+ var startW = splitW > 0 ? splitW : Math.round(rect.width * 0.62)
2015
+ var currentW = startW
2016
+ function onMove(moveEvent) {
2017
+ var width = Math.max(300, Math.min(moveEvent.clientX - rect.left, Math.max(300, rect.width - 360)))
2018
+ currentW = width
2019
+ setSplitW(width)
2020
+ }
2021
+ function onUp() {
2022
+ window.removeEventListener('pointermove', onMove)
2023
+ window.removeEventListener('pointerup', onUp)
2024
+ document.body.style.cursor = ''
2025
+ document.body.style.userSelect = ''
2026
+ try { window.localStorage.setItem('dsh-mesh-split-w', String(currentW)) } catch (error) { /* ignore */ }
2027
+ }
2028
+ document.body.style.cursor = 'col-resize'
2029
+ document.body.style.userSelect = 'none'
2030
+ window.addEventListener('pointermove', onMove)
2031
+ window.addEventListener('pointerup', onUp)
2032
+ }
2033
+
2034
+ function load(force) {
2035
+ if (!force) {
2036
+ setLoading(true)
2037
+ fetchStatus(false)
2038
+ .then(function (json) {
2039
+ setData(json)
2040
+ setError(json && json.ok === false && !json.warming ? json.error || "mesh-health error" : "")
2041
+ })
2042
+ .catch(function (err) { setError(String((err && err.message) || err)) })
2043
+ .finally(function () { setLoading(false) })
2044
+ return
2045
+ }
2046
+ var st = forceRef.current
2047
+ var now = Date.now()
2048
+ if (st.busy) return
2049
+ if (now - st.at < 8000) {
2050
+ var waitSec = Math.ceil((8000 - (now - st.at)) / 1000)
2051
+ setRefreshHint("刚采集过," + waitSec + "s 后可再刷新")
2052
+ setTimeout(function () { setRefreshHint("") }, 2200)
2053
+ return
2054
+ }
2055
+ st.busy = true
2056
+ st.at = now
2057
+ setLoading(true)
2058
+ setRefreshHint("采集中…")
2059
+ var prevAt = data && data.at
2060
+ var attempts = 0
2061
+ function poll() {
2062
+ attempts++
2063
+ fetchStatus(false).then(function (json) {
2064
+ var fresh = json && json.refreshing === false && json.at && json.at !== prevAt
2065
+ if (fresh || attempts >= 15) {
2066
+ setData(json)
2067
+ setError(json && json.ok === false && !json.warming ? json.error || "mesh-health error" : "")
2068
+ setLoading(false)
2069
+ st.busy = false
2070
+ setRefreshHint(fresh ? "已刷新" : "无新数据(采集超时)")
2071
+ setTimeout(function () { setRefreshHint("") }, 2000)
2072
+ return
2073
+ }
2074
+ setTimeout(poll, 800)
2075
+ }).catch(function () {
2076
+ st.busy = false
2077
+ setLoading(false)
2078
+ setRefreshHint("刷新失败")
2079
+ setTimeout(function () { setRefreshHint("") }, 2500)
2080
+ })
2081
+ }
2082
+ fetchStatus(true)
2083
+ .catch(function () { /* continue polling */ })
2084
+ .then(function () { setTimeout(poll, 500) })
2085
+ }
2086
+
2087
+ useEffect(function () {
2088
+ load(false)
2089
+ var timer = setInterval(function () { setTick(function (n) { return n + 1 }) }, REFRESH_MS)
2090
+ return function () { clearInterval(timer) }
2091
+ }, [])
2092
+
2093
+ useEffect(function () { if (tick > 0 && !forceRef.current.busy) load(false) }, [tick])
2094
+ useEffect(function () {
2095
+ if (!data) return
2096
+ var now = Date.now()
2097
+ if (now - federatedRef.current.at < 60000) return
2098
+ federatedRef.current.at = now
2099
+ fetch('/api/dsh-mesh/federated').then(function (r) { return r.text() }).then(function (text) { try { setFederated(JSON.parse(text)) } catch (error) {} }).catch(function () {})
2100
+ }, [data])
2101
+ useEffect(function () {
2102
+ var raw = String((data && data.verdict) || '')
2103
+ if (!raw) return
2104
+ var st = verdictRef.current
2105
+ var at = String((data && data.at) || '')
2106
+ var fresh = Boolean(at) && at !== st.lastAt
2107
+ if (raw === 'down') {
2108
+ if (fresh && !st.downAt) st.downAt = Date.now()
2109
+ st.lastAt = at
2110
+ var persisted = Boolean(st.downAt) && Date.now() - st.downAt >= 15000
2111
+ setVerdictShown(persisted ? 'down' : 'degraded')
2112
+ } else {
2113
+ st.downAt = 0
2114
+ st.lastAt = at
2115
+ setVerdictShown(raw)
2116
+ }
2117
+ st.raw = raw
2118
+ }, [data])
2119
+
2120
+ function copyJson() {
2121
+ var text = JSON.stringify(data || {}, null, 2)
2122
+ var done = function () { setCopied('已复制'); setTimeout(function () { setCopied('') }, 1600) }
2123
+ try {
2124
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done)
2125
+ else done()
2126
+ } catch (_) { done() }
2127
+ }
2128
+
2129
+ if (!data) {
2130
+ return h('div', { className: 'dm-root' },
2131
+ h('div', { className: 'dm-head' }, h('span', { className: 'dm-title' }, 'AI 网络'), h('span', { className: 'dm-pill muted' }, loading ? '加载中…' : '无数据')),
2132
+ error ? h('div', { className: 'dm-err' }, error) : null
2133
+ )
2134
+ }
2135
+
2136
+ var versions = data.versionMismatch || []
2137
+ var issues = data.issues || []
2138
+ var hints = data.hints || []
2139
+
2140
+ return h('div', { className: 'dm-root' },
2141
+ h('style', null, CSS),
2142
+ h('div', { className: 'dm-head' },
2143
+ h('span', { className: 'dm-title' }, 'AI 网络 · dsh-mesh'),
2144
+ h('span', {
2145
+ className: 'dm-pill ' + (data.warming ? 'muted' : tone(verdictShown || data.verdict)),
2146
+ title: data.warming ? '' : (verdictShown === 'degraded' ? '探测波动:需持续 ≥15s 且新快照仍 down 才显示 down' : ''),
2147
+ style: verdictShown === 'degraded' ? { opacity: 0.75 } : undefined
2148
+ }, data.warming ? '预热中' : (verdictShown || data.verdict || '?')),
2149
+ h('span', { className: 'dm-sub' }, (data.meshVersion ? 'mesh ' + data.meshVersion + ' · ' : '') + (data.at ? ago(data.at) : '')),
2150
+ h('span', { style: { flex: 1 } }),
2151
+ h('label', { className: 'dm-toggle', title: '默认隐藏公网 IP/域名;勾选后显示真实地址并记住选择' },
2152
+ h('input', { type: 'checkbox', checked: Boolean(showPublic), onChange: toggleShowPublic }),
2153
+ h('span', null, '显示公网地址')
2154
+ ),
2155
+ h('a', { className: 'dm-btn', href: API, target: '_blank', rel: 'noreferrer', style: { textDecoration: 'none' } }, '原始 JSON'),
2156
+ h('button', { className: 'dm-btn', onClick: copyJson }, copied || '复制诊断'),
2157
+ h('button', { className: 'dm-btn', onClick: function () { setHelpOpen(true) } }, '使用说明'),
2158
+ h('button', { className: 'dm-btn primary', onClick: function () { load(true) }, title: '立即采集一次并等待新快照(8s 冷却)' }, loading ? '采集中…' : '刷新'),
2159
+ refreshHint ? h('span', { className: 'dm-sub' }, refreshHint) : null
2160
+ ),
2161
+ error ? h('div', { className: 'dm-err' }, error) : null,
2162
+
2163
+ helpOpen ? h(HelpSheet, { onClose: function () { setHelpOpen(false) } }) : null,
2164
+
2165
+ h('div', { className: 'dm-body', style: splitW > 0 ? { '--dm-main-w': splitW + 'px' } : undefined },
2166
+ h('div', { className: 'dm-pane' },
2167
+ h(Viewport, { data: data, federated: federated, layout: computeLayout(1 + filterSelfPeers(dedupePeers(data.peers || []), data.nodeName).length), selectedKey: selectedKey, onSelect: setSelectedKey }),
2168
+ h(InfraPanels, { data: data }),
2169
+ h(DeviceDetails, { data: data, selectedKey: selectedKey })
2170
+ ),
2171
+ h('div', { className: 'dm-divider', onPointerDown: startSplit, title: '↔ 拖拽调整左右宽度(双击恢复默认)', onDoubleClick: function () { setSplitW(0); try { window.localStorage.removeItem('dsh-mesh-split-w') } catch (error) { /* ignore */ } } }),
2172
+ h('div', { className: 'dm-pane' },
2173
+ h(Details, { title: '使用说明', badge: 'AI 能做什么', open: true }, h(UsageHelp, { onOpen: function () { setHelpOpen(true) } })),
2174
+ h(Details, { title: '局域网连接', badge: '扫描 / 配对' }, h(LanOnboarding, { onChanged: function () { load(true) } })),
2175
+ h(Details, { title: '节点管理', badge: '移除 / 吊销' }, h(NodeManager, { onChanged: function () { load(true) } })),
2176
+ h(Details, { title: '文件传输', badge: '历史' }, h(TransferHistory)),
2177
+ h(Details, { title: '本机实例', badge: ((data.local && data.local.instances) || []).length, open: true }, h(InstanceList, { data: data })),
2178
+ h(Details, { title: '版本一致性', badge: versions.length ? versions.length + ' 处升级' : '一致' },
2179
+ versions.length
2180
+ ? versions.map(function (item, i) { return h('div', { key: i, className: 'dm-sub', style: { marginTop: 4 } }, '· ' + (typeof item === 'string' ? item : JSON.stringify(item))) })
2181
+ : h('div', { className: 'dm-empty' }, '所有节点/实例版本一致')
2182
+ ),
2183
+ h(Details, { title: '委托台账', badge: (data.delegations && data.delegations.total) || 0 }, h(DelegationTable, { data: data })),
2184
+ h(Details, { title: '告警历史', badge: ((data.alerts && data.alerts.history) || []).length }, h(AlertList, { data: data })),
2185
+ (issues.length || hints.length) ? h(Details, { title: '诊断建议', badge: issues.length + hints.length },
2186
+ h('div', null,
2187
+ issues.slice(0, 6).map(function (issue, i) { return h('div', { key: 'i' + i, className: 'dm-err', style: { marginTop: 3 } }, '· ' + maskText(issue)) }),
2188
+ hints.slice(0, 6).map(function (hint, i) { return h('div', { key: 'h' + i, className: 'dm-sub', style: { marginTop: 3 } }, '· ' + maskText(hint)) })
2189
+ )
2190
+ ) : null
2191
+ )
2192
+ ),
2193
+ h('div', { className: 'dm-foot' }, '数据源:mesh-health / host status / peer host_self / 委托台账 / 告警历史 · ' + (data.repo || '') + ' · node ' + (data.nodeVersion || '') + ' · ' + REFRESH_MS / 1000 + 's 自动刷新(只读)')
2194
+ )
2195
+ }
2196
+
2197
+ // ================= 委托面板(只读,方案 A / v2 视觉重做) =================
2198
+ var DG_API_LIST = '/api/dsh-mesh/delegations'
2199
+ var DG_API_TASK = '/api/dsh-mesh/task'
2200
+ var DG_API_SESSION = '/api/dsh-mesh/session'
2201
+
2202
+ var DG_STATE_TONE = {
2203
+ done: 'ok', working: 'run', accepted: 'run', submitted: 'run',
2204
+ question: 'warn', stalled: 'warn', failed: 'bad', cancelled: 'muted'
2205
+ }
2206
+
2207
+ var DG_CSS = `
2208
+ .dm-dg-root { display:flex; flex-direction:column; gap:12px; height:100%; min-height:0; padding:16px; box-sizing:border-box; font-size:13px; color:inherit; }
2209
+ .dm-dg-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
2210
+ .dm-dg-title { font-size:16px; font-weight:650; letter-spacing:.2px; }
2211
+ .dm-dg-sub { font-size:11.5px; opacity:.55; }
2212
+ .dm-dg-pill { font-size:10.5px; padding:1px 8px; border-radius:999px; border:1px solid rgba(128,128,128,.32); opacity:.85; }
2213
+ .dm-dg-pill.readonly { border-color:rgba(88,166,255,.5); color:#3b82d6; opacity:1; }
2214
+ .dm-dg-stats { display:flex; gap:6px; flex-wrap:wrap; }
2215
+ .dm-dg-stat { font-size:10.5px; padding:1px 8px; border-radius:999px; border:1px solid rgba(128,128,128,.28); display:flex; gap:5px; align-items:center; }
2216
+ .dm-dg-stat b { font-weight:650; }
2217
+ .dm-dg-spacer { flex:1; }
2218
+ .dm-dg-btn { font-size:11.5px; padding:4px 11px; border-radius:8px; border:1px solid rgba(128,128,128,.4); background:transparent; color:inherit; cursor:pointer; white-space:nowrap; display:inline-flex; align-items:center; gap:5px; }
2219
+ .dm-dg-btn:hover:not(:disabled) { background:rgba(128,128,128,.12); }
2220
+ .dm-dg-btn:disabled { opacity:.45; cursor:default; }
2221
+ .dm-dg-btn.primary { border-color:rgba(88,166,255,.6); color:#3b82d6; }
2222
+ .dm-dg-notice { font-size:11.5px; padding:7px 10px; border-radius:8px; border:1px solid transparent; display:flex; gap:8px; align-items:center; }
2223
+ .dm-dg-notice.info { background:rgba(88,166,255,.1); border-color:rgba(88,166,255,.35); }
2224
+ .dm-dg-notice.ok { background:rgba(63,185,80,.12); border-color:rgba(63,185,80,.4); color:#2e9e4f; }
2225
+ .dm-dg-notice.bad { background:rgba(248,81,73,.12); border-color:rgba(248,81,73,.4); color:#c9362c; }
2226
+ .dm-dg-body { display:grid; grid-template-columns:var(--dm-dg-list-w, 360px) 7px minmax(0,1fr); gap:10px; min-height:0; flex:1; }
2227
+ @media (max-width: 960px) { .dm-dg-body { grid-template-columns:1fr; } .dm-dg-divider { display:none; } }
2228
+ .dm-dg-pane { display:flex; flex-direction:column; min-height:0; border:1px solid rgba(128,128,128,.25); border-radius:12px; background:rgba(128,128,128,.035); overflow:hidden; }
2229
+ .dm-dg-pane-head { flex:none; display:flex; align-items:center; gap:8px; padding:9px 12px; border-bottom:1px solid rgba(128,128,128,.2); background:rgba(128,128,128,.05); font-weight:650; font-size:12px; }
2230
+ .dm-dg-pane-head .dm-dg-sub { font-weight:400; }
2231
+ .dm-dg-scroll { flex:1; min-height:0; overflow:auto; }
2232
+ .dm-dg-row { position:relative; display:flex; flex-direction:column; gap:5px; padding:10px 12px 10px 15px; border-bottom:1px solid rgba(128,128,128,.14); cursor:pointer; }
2233
+ .dm-dg-row:last-child { border-bottom:none; }
2234
+ .dm-dg-row::before { content:''; position:absolute; left:0; top:0; bottom:0; width:3px; background:transparent; }
2235
+ .dm-dg-row.ok::before { background:#2e9e4f; }
2236
+ .dm-dg-row.run::before { background:#3b82d6; }
2237
+ .dm-dg-row.warn::before { background:#d29922; }
2238
+ .dm-dg-row.bad::before { background:#d14b41; }
2239
+ .dm-dg-row:hover { background:rgba(128,128,128,.09); }
2240
+ .dm-dg-row.active { background:rgba(88,166,255,.13); }
2241
+ .dm-dg-divider { position:relative; cursor:col-resize; min-height:0; border-radius:4px; }
2242
+ .dm-dg-divider::before { content:''; position:absolute; top:0; bottom:0; left:3px; width:1px; background:rgba(128,128,128,.28); }
2243
+ .dm-dg-divider:hover::before, .dm-dg-divider:active::before { background:rgba(88,166,255,.7); width:2px; left:2.5px; }
2244
+ .dm-dg-spinner { flex:none; color:var(--dsw-alias-label-tertiary, rgba(128,128,128,.85)); }
2245
+ .dm-dg-spinner-motion { transform-origin:center; animation:dm-dg-spin 1.5s linear infinite; }
2246
+ .dm-dg-spinner-track, .dm-dg-spinner-arc { fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; }
2247
+ .dm-dg-spinner-track { opacity:.25; }
2248
+ .dm-dg-spinner-arc { stroke-dasharray:12 150; animation:dm-dg-dash 1.5s ease-in-out infinite; }
2249
+ @keyframes dm-dg-spin { to { transform:rotate(360deg); } }
2250
+ @keyframes dm-dg-dash {
2251
+ 0% { stroke-dasharray:12 150; stroke-dashoffset:0; }
2252
+ 50% { stroke-dasharray:24 150; stroke-dashoffset:-6; }
2253
+ 100% { stroke-dasharray:12 150; stroke-dashoffset:0; }
2254
+ }
2255
+ @media (prefers-reduced-motion: reduce) {
2256
+ .dm-dg-spinner-motion, .dm-dg-spinner-arc { animation:none; }
2257
+ .dm-dg-spinner-arc { stroke-dasharray:18 150; stroke-dashoffset:-3; }
2258
+ }
2259
+ .dm-dg-row-top { display:flex; align-items:center; gap:8px; min-width:0; }
2260
+ .dm-dg-row-node { font-weight:650; white-space:nowrap; }
2261
+ .dm-dg-route { display:inline-flex; align-items:center; gap:6px; min-width:0; }
2262
+ .dm-dg-from, .dm-dg-to { font-weight:650; font-size:12.5px; white-space:nowrap; }
2263
+ .dm-dg-from { color:inherit; opacity:.85; }
2264
+ .dm-dg-to { color:#3b82d6; }
2265
+ .dm-dg-arrow { opacity:.5; font-size:11px; }
2266
+ .dm-dg-me { font-size:9.5px; padding:0 5px; border-radius:999px; border:1px solid rgba(63,185,80,.55); color:#2e9e4f; line-height:15px; }
2267
+ .dm-dg-filters { position:relative; z-index:5; display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:7px 10px; border-bottom:1px solid rgba(128,128,128,.16); background:rgba(128,128,128,.03); }
2268
+ .dm-dg-fgroup { display:flex; align-items:center; gap:5px; flex-wrap:wrap; font-size:10px; opacity:.8; }
2269
+ .dm-dg-fs { position:relative; display:inline-flex; align-items:center; gap:4px; }
2270
+ .dm-dg-fs-label { font-size:10px; opacity:.6; }
2271
+ .dm-dg-fs-btn { display:inline-flex; align-items:center; gap:6px; font-size:10.5px; padding:2px 8px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:rgba(128,128,128,.08); color:inherit; cursor:pointer; }
2272
+ .dm-dg-fs-btn:hover { background:rgba(128,128,128,.16); }
2273
+ .dm-dg-fs-caret { font-size:8px; opacity:.55; }
2274
+ .dm-dg-fs-menu { position:absolute; top:calc(100% + 4px); left:0; min-width:132px; padding:4px; border-radius:8px; border:1px solid rgba(128,128,128,.3); background:#ffffff; color:#1a1a1a; box-shadow:0 8px 24px rgba(0,0,0,.18); z-index:90; display:flex; flex-direction:column; gap:2px; }
2275
+ .dm-dg-fs-item { text-align:left; font-size:11px; padding:4px 9px; border-radius:5px; border:none; background:transparent; color:inherit; cursor:pointer; white-space:nowrap; }
2276
+ .dm-dg-fs-item:hover { background:rgba(128,128,128,.18); }
2277
+ .dm-dg-fs-item.active { background:rgba(88,166,255,.2); color:#2b6fc4; }
2278
+ body[data-ds-dark-theme] .dm-dg-fs-menu { background:#24262b; color:#e6e6e6; border-color:rgba(255,255,255,.16); box-shadow:0 8px 24px rgba(0,0,0,.55); }
2279
+ body[data-ds-dark-theme] .dm-dg-fs-item:hover { background:rgba(255,255,255,.1); }
2280
+ body[data-ds-dark-theme] .dm-dg-fs-item.active { background:rgba(88,166,255,.28); color:#79b8ff; }
2281
+ .dm-dg-fbtn { font-size:10px; padding:2px 8px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:transparent; color:inherit; cursor:pointer; }
2282
+ .dm-dg-fhint { font-size:9.5px; opacity:.45; }
2283
+ .dm-dg-fchip { font-size:10px; padding:0 8px; line-height:16px; border-radius:999px; border:1px solid rgba(128,128,128,.3); background:transparent; color:inherit; cursor:pointer; }
2284
+ .dm-dg-fchip.active { border-color:rgba(88,166,255,.7); color:#3b82d6; background:rgba(88,166,255,.12); }
2285
+ .dm-dg-badge { padding:0 6px; border-radius:5px; border:1px solid rgba(128,128,128,.32); font-size:10px; opacity:.8; }
2286
+ .dm-dg-row-time { margin-left:auto; font-size:10.5px; opacity:.5; font-variant-numeric:tabular-nums; white-space:nowrap; }
2287
+ .dm-dg-chip { font-size:10px; padding:0 8px; border-radius:999px; border:1px solid rgba(128,128,128,.32); white-space:nowrap; line-height:17px; display:inline-block; }
2288
+ .dm-dg-chip.ok { border-color:rgba(63,185,80,.55); color:#2e9e4f; }
2289
+ .dm-dg-chip.bad { border-color:rgba(248,81,73,.55); color:#c9362c; }
2290
+ .dm-dg-chip.run { border-color:rgba(88,166,255,.55); color:#3b82d6; }
2291
+ .dm-dg-chip.warn { border-color:rgba(210,153,34,.6); color:#9a6c12; }
2292
+ .dm-dg-chip.muted { opacity:.7; }
2293
+ .dm-dg-goal { font-size:11.5px; opacity:.8; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2294
+ .dm-dg-meta { display:flex; gap:8px; flex-wrap:wrap; font-size:10px; opacity:.5; font-variant-numeric:tabular-nums; }
2295
+ .dm-dg-empty { padding:22px 14px; text-align:center; opacity:.55; font-size:12px; line-height:1.7; }
2296
+ .dm-dg-skel { padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
2297
+ .dm-dg-skel i { display:block; height:10px; border-radius:6px; background:rgba(128,128,128,.18); animation:dm-dg-pulse 1.2s ease-in-out infinite; }
2298
+ .dm-dg-skel i:nth-child(2) { width:70%; } .dm-dg-skel i:nth-child(3) { width:45%; }
2299
+ @keyframes dm-dg-pulse { 0%,100% { opacity:.45 } 50% { opacity:.9 } }
2300
+ .dm-dg-detail { display:flex; flex-direction:column; gap:12px; padding:12px; }
2301
+ .dm-dg-sec { border:1px solid rgba(128,128,128,.22); border-radius:10px; overflow:hidden; }
2302
+ .dm-dg-sec-head { display:flex; align-items:center; gap:8px; padding:7px 11px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); font-weight:650; font-size:11.5px; }
2303
+ .dm-dg-sec-body { padding:10px 11px; display:flex; flex-direction:column; gap:7px; }
2304
+ .dm-dg-kv { display:grid; grid-template-columns:92px minmax(0,1fr); gap:6px 10px; align-items:baseline; font-size:11.5px; }
2305
+ .dm-dg-k { opacity:.55; }
2306
+ .dm-dg-v { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; display:flex; gap:6px; align-items:center; }
2307
+ .dm-dg-copy { font-size:10px; padding:0 6px; border-radius:6px; border:1px solid rgba(128,128,128,.35); background:transparent; color:inherit; cursor:pointer; opacity:.6; flex:none; }
2308
+ .dm-dg-copy:hover { opacity:1; }
2309
+ .dm-dg-pre { white-space:pre-wrap; word-break:break-word; font-size:11px; line-height:1.55; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; max-height:220px; overflow:auto; border:1px dashed rgba(128,128,128,.25); border-radius:8px; padding:8px; background:rgba(128,128,128,.04); }
2310
+ .dm-dg-msgs { display:flex; flex-direction:column; gap:8px; max-height:380px; overflow:auto; padding-right:2px; }
2311
+ .dm-dg-msg { border:1px solid rgba(128,128,128,.18); border-radius:9px; padding:8px 10px; background:rgba(128,128,128,.03); }
2312
+ .dm-dg-msg.user { border-left:3px solid rgba(63,185,80,.6); }
2313
+ .dm-dg-msg.assistant { border-left:3px solid rgba(88,166,255,.6); }
2314
+ .dm-dg-msg-head { display:flex; gap:8px; align-items:baseline; margin-bottom:5px; font-size:10px; opacity:.6; text-transform:uppercase; letter-spacing:.4px; }
2315
+ .dm-dg-msg-text { white-space:pre-wrap; word-break:break-word; font-size:11.5px; line-height:1.55; }
2316
+ .dm-dg-loading { display:inline-flex; gap:6px; align-items:center; font-size:11.5px; opacity:.7; }
2317
+ .dm-dg-spin { width:11px; height:11px; border:2px solid rgba(128,128,128,.35); border-top-color:currentColor; border-radius:50%; animation:dm-dg-rot .8s linear infinite; }
2318
+ @keyframes dm-dg-rot { to { transform:rotate(360deg) } }
2319
+ @media (prefers-color-scheme: dark) {
2320
+ .dm-dg-pill.readonly { color:#79b8ff; }
2321
+ .dm-dg-btn.primary { color:#79b8ff; }
2322
+ .dm-dg-notice.ok { color:#3fb950; } .dm-dg-notice.bad { color:#ff7b72; }
2323
+ .dm-dg-chip.ok { color:#3fb950; } .dm-dg-to { color:#79b8ff; } .dm-dg-chip.run { color:#79b8ff; } .dm-dg-chip.warn { color:#d29922; } .dm-dg-chip.bad { color:#ff7b72; }
2324
+ }
2325
+ `
2326
+
2327
+ // 仿 DSH StateDot 的 ongoing 转圈环(1.5s 转 + 弧线呼吸)
2328
+ function LoadingSpinner(props) {
2329
+ var size = (props && props.size) || 14
2330
+ return h('svg', {
2331
+ className: 'dm-dg-spinner',
2332
+ width: size,
2333
+ height: size,
2334
+ viewBox: '0 0 24 24',
2335
+ 'aria-hidden': 'true',
2336
+ 'data-state': 'ongoing'
2337
+ },
2338
+ h('g', { className: 'dm-dg-spinner-motion' },
2339
+ h('circle', { className: 'dm-dg-spinner-track', cx: 12, cy: 12, r: 9.5 }),
2340
+ h('circle', { className: 'dm-dg-spinner-arc', cx: 12, cy: 12, r: 9.5 })
2341
+ )
2342
+ )
2343
+ }
2344
+
2345
+ function dgFetch(path) {
2346
+ return fetch(path, { headers: { accept: 'application/json' } })
2347
+ .then(function (res) {
2348
+ return res.text().then(function (text) {
2349
+ var type = res.headers.get('content-type') || ''
2350
+ if (res.status === 404 || !/json/i.test(type)) {
2351
+ var routeError = new Error('route unavailable (HTTP ' + res.status + ')')
2352
+ routeError.code = 'E_NO_ROUTE'
2353
+ routeError.body = String(text || '').slice(0, 80)
2354
+ throw routeError
2355
+ }
2356
+ try {
2357
+ return JSON.parse(text)
2358
+ } catch (error) {
2359
+ var parseError = new Error('服务端返回的不是 JSON(可能需要重启该 DSH 实例)')
2360
+ parseError.code = 'E_PARSE'
2361
+ parseError.body = String(text || '').slice(0, 80)
2362
+ throw parseError
2363
+ }
2364
+ })
2365
+ })
2366
+ }
2367
+ function dgFriendly(err) {
2368
+ if (err && (err.code === 'E_NO_ROUTE' || err.code === 'E_PARSE')) return '当前 DSH 实例的服务端还是旧版本(缺接口),重启该实例后可用'
2369
+ return String((err && err.message) || err)
2370
+ }
2371
+ function dgDur(sec) {
2372
+ if (sec == null) return '—'
2373
+ if (sec < 60) return sec + 's'
2374
+ if (sec < 3600) return Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
2375
+ return Math.floor(sec / 3600) + 'h' + Math.floor((sec % 3600) / 60) + 'm'
2376
+ }
2377
+ function dgClock(iso) {
2378
+ if (!iso) return '—'
2379
+ var d = new Date(iso)
2380
+ if (isNaN(d.getTime())) return '—'
2381
+ function p2(n) { return (n < 10 ? '0' : '') + n }
2382
+ return p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes())
2383
+ }
2384
+ function dgTone(state) { return DG_STATE_TONE[state] || 'muted' }
2385
+ // 状态/策略展示统一中文(内部值保持英文,避免影响筛选与接口)
2386
+ var DG_STATE_LABELS = {
2387
+ done: '已完成', working: '执行中', submitted: '已提交', accepted: '已受理',
2388
+ question: '待回复', stalled: '疑似卡住', failed: '失败', cancelled: '已取消',
2389
+ timeout: '超时', running: '运行中', provisioning: '准备中', inactive: '空闲', unknown: '未知'
2390
+ }
2391
+ var DG_POLICY_LABELS = { new: '新会话', continue: '续会话', auto: '自动', session: '指定会话' }
2392
+ function dgStateLabel(state) { return DG_STATE_LABELS[String(state || '')] || String(state || '未知') }
2393
+ function dgPolicyLabel(policy) { return DG_POLICY_LABELS[String(policy || '')] || String(policy || '—') }
2394
+ function dgChip(state) { return h('span', { className: 'dm-dg-chip ' + dgTone(state) }, dgStateLabel(state)) }
2395
+
2396
+ // 必须是 React 组件(返回元素),不能返回 DOM 节点
2397
+ function meshPanelIcon(props) {
2398
+ var size = (props && props.size) || 16
2399
+ return h('svg', {
2400
+ 'data-dsh-panel-entry': 'mesh-delegations', viewBox: '0 0 16 16', width: size, height: size,
2401
+ fill: 'none', stroke: 'currentColor', strokeWidth: '1.4', strokeLinecap: 'round', 'aria-hidden': 'true',
2402
+ style: { color: 'var(--dsh-brand-color, currentColor)' }
2403
+ },
2404
+ h('path', { d: 'M8 3.2 L3.2 8 M8 3.2 L12.8 8 M3.2 8 L12.8 8', opacity: '.7' }),
2405
+ h('circle', { cx: 8, cy: 3.2, r: 1.6, fill: 'currentColor', stroke: 'none' }),
2406
+ h('circle', { cx: 3.2, cy: 8, r: 1.6, fill: 'currentColor', stroke: 'none' }),
2407
+ h('circle', { cx: 12.8, cy: 8, r: 1.6, fill: 'currentColor', stroke: 'none' })
2408
+ )
2409
+ }
2410
+
2411
+ /** 左侧栏「AI 网络」专用图标:中心节点 + 四向设备,和「委托」区分开。 */
2412
+ function networkPanelIcon(props) {
2413
+ var size = (props && props.size) || 16
2414
+ return h('svg', {
2415
+ 'data-dsh-panel-entry': 'mesh-network', viewBox: '0 0 16 16', width: size, height: size,
2416
+ fill: 'none', stroke: 'currentColor', strokeWidth: '1.3', strokeLinecap: 'round', 'aria-hidden': 'true',
2417
+ style: { color: 'var(--dsh-brand-color, currentColor)' }
2418
+ },
2419
+ h('path', { d: 'M8 8 L8 2.6 M8 8 L13.4 8 M8 8 L8 13.4 M8 8 L2.6 8', opacity: '.55' }),
2420
+ h('circle', { cx: 8, cy: 8, r: 2.1, fill: 'none', strokeWidth: '1.4' }),
2421
+ h('circle', { cx: 8, cy: 2.6, r: 1.25, fill: 'currentColor', stroke: 'none' }),
2422
+ h('circle', { cx: 13.4, cy: 8, r: 1.25, fill: 'currentColor', stroke: 'none' }),
2423
+ h('circle', { cx: 8, cy: 13.4, r: 1.25, fill: 'currentColor', stroke: 'none' }),
2424
+ h('circle', { cx: 2.6, cy: 8, r: 1.25, fill: 'currentColor', stroke: 'none' })
2425
+ )
2426
+ }
2427
+
2428
+ function MeshDelegationPanel() {
2429
+ var s0 = useState(null); var list = s0[0]; var setList = s0[1]
2430
+ var s1 = useState(true); var loading = s1[0]; var setLoading = s1[1]
2431
+ var s2 = useState(null); var notice = s2[0]; var setNotice = s2[1]
2432
+ var s3 = useState(null); var selectedId = s3[0]; var setSelectedId = s3[1]
2433
+ var s4 = useState(null); var detail = s4[0]; var setDetail = s4[1]
2434
+ var s5 = useState(false); var taskLoading = s5[0]; var setTaskLoading = s5[1]
2435
+ var s6 = useState(null); var session = s6[0]; var setSession = s6[1]
2436
+ var s7 = useState(false); var sessionLoading = s7[0]; var setSessionLoading = s7[1]
2437
+ var s8 = useState(''); var copied = s8[0]; var setCopied = s8[1]
2438
+ var s10 = useState(null); var artifacts = s10[0]; var setArtifacts = s10[1]
2439
+ var s11 = useState(''); var localNode = s11[0]; var setLocalNode = s11[1]
2440
+ var s12 = useState(''); var lastListAt = s12[0]; var setLastListAt = s12[1]
2441
+ var s13 = useState(''); var lastTaskAt = s13[0]; var setLastTaskAt = s13[1]
2442
+ var s14 = useState(''); var filterFrom = s14[0]; var setFilterFrom = s14[1]
2443
+ var s15 = useState(''); var filterTo = s15[0]; var setFilterTo = s15[1]
2444
+ var s16 = useState(''); var filterFromNode = s16[0]; var setFilterFromNode = s16[1]
2445
+ var s17 = useState(''); var filterToNode = s17[0]; var setFilterToNode = s17[1]
2446
+ var s19 = useState(null); var rich = s19[0]; var setRich = s19[1]
2447
+ var s18 = useState(function () {
2448
+ try {
2449
+ var saved = window.localStorage.getItem('dsh-mesh-list-width')
2450
+ var value = saved ? Number(saved) : NaN
2451
+ if (Number.isFinite(value)) return Math.max(260, Math.min(640, value))
2452
+ } catch (error) { /* ignore */ }
2453
+ return 360
2454
+ })
2455
+ var listWidth = s18[0]; var setListWidth = s18[1]
2456
+ function startResize(event) {
2457
+ event.preventDefault()
2458
+ var startX = event.clientX
2459
+ var startW = listWidth
2460
+ var currentW = startW
2461
+ function onMove(moveEvent) {
2462
+ currentW = Math.max(260, Math.min(640, startW + (moveEvent.clientX - startX)))
2463
+ setListWidth(currentW)
2464
+ }
2465
+ function onUp() {
2466
+ window.removeEventListener('pointermove', onMove)
2467
+ window.removeEventListener('pointerup', onUp)
2468
+ document.body.style.cursor = ''
2469
+ document.body.style.userSelect = ''
2470
+ try { window.localStorage.setItem('dsh-mesh-list-width', String(currentW)) } catch (error) { /* ignore */ }
2471
+ }
2472
+ window.addEventListener('pointermove', onMove)
2473
+ window.addEventListener('pointerup', onUp)
2474
+ document.body.style.cursor = 'col-resize'
2475
+ document.body.style.userSelect = 'none'
2476
+ }
2477
+ function resetResize() {
2478
+ setListWidth(360)
2479
+ try { window.localStorage.setItem('dsh-mesh-list-width', '360') } catch (error) { /* ignore */ }
2480
+ }
2481
+
2482
+ // 只保留「错误」提示(持久显示,下一次成功操作/切换任务时清除);成功不再弹自动消失的提示
2483
+ function flash(type, text) {
2484
+ if (type === 'bad') setNotice({ type: 'bad', text: text })
2485
+ else setNotice(null)
2486
+ }
2487
+ function clearNotice() { setNotice(null) }
2488
+
2489
+ function loadList(silent) {
2490
+ if (!silent) setLoading(true)
2491
+ dgFetch(DG_API_LIST + '?limit=100')
2492
+ .then(function (json) {
2493
+ if (!json || !json.ok) throw new Error((json && json.error) || 'load failed')
2494
+ setList(Object.assign({}, json, { degraded: false }))
2495
+ if (json.localNode) setLocalNode(json.localNode)
2496
+ setLastListAt(new Date().toISOString())
2497
+ })
2498
+ .catch(function (err) {
2499
+ // 2026-11-07 兼容:旧实例没有 /delegations 路由 → 用 /status 里的 delegations.recent 降级
2500
+ if (err && (err.code === 'E_NO_ROUTE' || err.code === 'E_PARSE')) {
2501
+ return dgFetch('/api/dsh-mesh/status')
2502
+ .then(function (st) {
2503
+ var recent = (st && st.delegations && st.delegations.recent) || []
2504
+ var tasks = recent.map(function (item) {
2505
+ return {
2506
+ taskId: item.taskId,
2507
+ conversationId: item.conversationId || '',
2508
+ sessionId: item.sessionId || '',
2509
+ from: '',
2510
+ fromLabel: '',
2511
+ node: item.node,
2512
+ nodeLabel: nodeLabel(item.node),
2513
+ instance: '',
2514
+ state: item.state,
2515
+ policy: item.policy || '',
2516
+ goal: item.goal || '',
2517
+ createdAt: item.createdAt || '',
2518
+ updatedAt: item.updatedAt || '',
2519
+ durationSec: item.durationSec == null ? null : (item.durationSec > 43200 ? null : item.durationSec)
2520
+ }
2521
+ })
2522
+ if (st && st.nodeName) setLocalNode(st.nodeName)
2523
+ setList({
2524
+ total: (st && st.delegations && st.delegations.total) || tasks.length,
2525
+ byState: (st && st.delegations && st.delegations.byState) || {},
2526
+ tasks: tasks,
2527
+ degraded: true
2528
+ })
2529
+ flash('bad', '当前实例服务端未重启:已降级显示最近 ' + tasks.length + ' 条(重启后显示完整列表/详情)')
2530
+ })
2531
+ .catch(function () { throw err })
2532
+ }
2533
+ throw err
2534
+ })
2535
+ .catch(function (err) { flash('bad', '列表加载失败:' + dgFriendly(err)) })
2536
+ .finally(function () { setLoading(false) })
2537
+ }
2538
+
2539
+ useEffect(function () {
2540
+ loadList(true)
2541
+ var timer = setInterval(function () { loadList(true) }, 20000)
2542
+ return function () { clearInterval(timer) }
2543
+ }, [])
2544
+
2545
+ var tasks = (list && list.tasks) || []
2546
+ var selected = null
2547
+ for (var i = 0; i < tasks.length; i++) { if (tasks[i].taskId === selectedId) { selected = tasks[i]; break } }
2548
+
2549
+ function selectTask(task) {
2550
+ setSelectedId(task.taskId)
2551
+ clearNotice()
2552
+ setDetail(null)
2553
+ setArtifacts(null)
2554
+ setRich(null)
2555
+ setSession(null)
2556
+ setTaskLoading(true)
2557
+ dgFetch(DG_API_TASK + '?taskId=' + encodeURIComponent(task.taskId))
2558
+ .then(function (json) {
2559
+ if (!json || !json.ok) throw new Error((json && json.error) || 'task status failed')
2560
+ setDetail(json.status || null)
2561
+ setArtifacts(json.artifacts || null)
2562
+ setRich(json.detail || null)
2563
+ setLastTaskAt(new Date().toISOString())
2564
+ clearNotice()
2565
+ })
2566
+ .catch(function (err) { flash('bad', '查询任务状态失败:' + dgFriendly(err)) })
2567
+ .finally(function () { setTaskLoading(false) })
2568
+ }
2569
+
2570
+ function loadSession() {
2571
+ if (!selected || !selected.sessionId) return
2572
+ // 2026-11-10:本机任务(local)没有对端会话,不要发 peer call
2573
+ if (/^(local|self|localhost)$/i.test(String(selected.node || ''))) {
2574
+ flash('bad', '本机任务(local)没有对端会话;请在 DSH 侧栏查看本机会话')
2575
+ return
2576
+ }
2577
+ setSessionLoading(true)
2578
+ dgFetch(DG_API_SESSION + '?node=' + encodeURIComponent(selected.node) + '&sessionId=' + encodeURIComponent(selected.sessionId) + '&limit=60')
2579
+ .then(function (json) {
2580
+ if (!json || !json.ok) throw new Error((json && json.error) || 'session history failed')
2581
+ setSession(json.payload || null)
2582
+ clearNotice()
2583
+ })
2584
+ .catch(function (err) { flash('bad', '加载对端会话失败:' + dgFriendly(err)) })
2585
+ .finally(function () { setSessionLoading(false) })
2586
+ }
2587
+
2588
+ function copyText(key, text) {
2589
+ try {
2590
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { setCopied(key) }, function () { flash('bad', '复制失败') })
2591
+ else { setCopied(key) }
2592
+ } catch (_) { flash('bad', '复制失败') }
2593
+ setTimeout(function () { setCopied('') }, 1500)
2594
+ }
2595
+
2596
+ var byState = (list && list.byState) || {}
2597
+ // 2026-11-07:节点筛选(委托方/接收方)+ 实例筛选(源实例仅新委托记录;旧数据为空 → 未记录)
2598
+ var fromNodeOptions = []
2599
+ var toNodeOptions = []
2600
+ var fromOptions = []
2601
+ var toOptions = []
2602
+ for (var ti = 0; ti < tasks.length; ti++) {
2603
+ var task0 = tasks[ti]
2604
+ if (fromNodeOptions.indexOf(task0.from || '') < 0) fromNodeOptions.push(task0.from || '')
2605
+ if (toNodeOptions.indexOf(task0.node || '') < 0) toNodeOptions.push(task0.node || '')
2606
+ var fi = task0.fromInstance || ''
2607
+ if (fromOptions.indexOf(fi) < 0) fromOptions.push(fi)
2608
+ var ii = task0.instance || ''
2609
+ if (toOptions.indexOf(ii) < 0) toOptions.push(ii)
2610
+ }
2611
+ fromNodeOptions.sort()
2612
+ toNodeOptions.sort()
2613
+ fromOptions.sort()
2614
+ toOptions.sort()
2615
+ function nodeOptionLabel(value, isFrom) {
2616
+ if (!value) return '未知节点'
2617
+ for (var k = 0; k < tasks.length; k++) {
2618
+ var item = tasks[k]
2619
+ if (isFrom && (item.from || '') === value) return item.fromLabel || value
2620
+ if (!isFrom && (item.node || '') === value) return (item.nodeLabel || value) + (item.fromIsLocal !== undefined ? '' : '')
2621
+ }
2622
+ return value
2623
+ }
2624
+ var visibleTasks = tasks.filter(function (task) {
2625
+ if (filterFromNode && (task.from || '') !== filterFromNode) return false
2626
+ if (filterToNode && (task.node || '') !== filterToNode) return false
2627
+ if (filterFrom && (task.fromInstance || '') !== filterFrom) return false
2628
+ if (filterTo && (task.instance || '') !== filterTo) return false
2629
+ return true
2630
+ })
2631
+ function resetFilters() { setFilterFromNode(''); setFilterToNode(''); setFilterFrom(''); setFilterTo('') }
2632
+ // 自绘下拉:不用原生 select(暗色主题下原生弹层会保持白底)
2633
+ function FilterSelect(props) {
2634
+ var fs = useState(false)
2635
+ var open = fs[0]
2636
+ var setOpen = fs[1]
2637
+ useEffect(function () {
2638
+ if (!open) return
2639
+ function close() { setOpen(false) }
2640
+ document.addEventListener('mousedown', close)
2641
+ return function () { document.removeEventListener('mousedown', close) }
2642
+ }, [open])
2643
+ return h('div', { className: 'dm-dg-fs' },
2644
+ h('span', { className: 'dm-dg-fs-label' }, props.label),
2645
+ h('button', {
2646
+ type: 'button',
2647
+ className: 'dm-dg-fs-btn',
2648
+ 'aria-expanded': open ? 'true' : 'false',
2649
+ onClick: function (event) { event.stopPropagation(); setOpen(!open) }
2650
+ },
2651
+ h('span', { className: 'dm-dg-fs-val' }, props.renderLabel(props.value)),
2652
+ h('span', { className: 'dm-dg-fs-caret' }, '▾')
2653
+ ),
2654
+ open ? h('div', { className: 'dm-dg-fs-menu', onMouseDown: function (event) { event.stopPropagation() } },
2655
+ props.options.map(function (opt) {
2656
+ return h('button', {
2657
+ key: opt,
2658
+ type: 'button',
2659
+ className: 'dm-dg-fs-item' + (opt === props.value ? ' active' : ''),
2660
+ onClick: function (event) { event.stopPropagation(); props.onChange(opt); setOpen(false) }
2661
+ }, props.renderLabel(opt))
2662
+ })
2663
+ ) : null
2664
+ )
2665
+ }
2666
+ var stats = ['done', 'working', 'submitted', 'question', 'stalled', 'failed'].filter(function (key) { return byState[key] })
2667
+ var selectedTone = selected ? dgTone(selected.state) : 'muted'
2668
+ var sessionMessages = (session && session.messages) || []
2669
+ var st = detail || {}
2670
+
2671
+ function copyBtn(key, text) {
2672
+ if (!text) return null
2673
+ return h('button', { type: 'button', className: 'dm-dg-copy', title: '复制 ' + key, onClick: function (e) { e.stopPropagation(); copyText(key, text) } }, copied === key ? '已复制' : '复制')
2674
+ }
2675
+ function kv(label, value, copyKey) {
2676
+ return h('div', { className: 'dm-dg-kv' },
2677
+ h('span', { className: 'dm-dg-k' }, label),
2678
+ h('span', { className: 'dm-dg-v', title: String(value == null ? '' : value) }, String(value == null || value === '' ? '—' : value), copyKey ? copyBtn(copyKey, String(value || '')) : null)
2679
+ )
2680
+ }
2681
+
2682
+ return h('div', { className: 'dm-dg-root' },
2683
+ h('style', null, DG_CSS),
2684
+ h('div', { className: 'dm-dg-head' },
2685
+ h('span', { className: 'dm-dg-title' }, '委托'),
2686
+ h('span', { className: 'dm-dg-pill readonly' }, '只读'),
2687
+ h('div', { className: 'dm-dg-stats' },
2688
+ h('span', { className: 'dm-dg-stat' }, '共 ', h('b', null, String((list && list.total) || tasks.length || 0)), ' 条'),
2689
+ stats.map(function (key) { return h('span', { key: key, className: 'dm-dg-stat' }, h('b', null, String(byState[key])), ' ', dgStateLabel(key)) })
2690
+ ),
2691
+ h('span', { className: 'dm-dg-spacer' }),
2692
+ h('span', { className: 'dm-dg-sub' }, '20s 自动刷新 · 数据来自本地台账' + (lastListAt ? ' · 上次 ' + dgClock(lastListAt) : '')),
2693
+ h('button', { type: 'button', className: 'dm-dg-btn', onClick: function () { loadList(false) }, disabled: loading },
2694
+ loading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '刷新中') : '刷新')
2695
+ ),
2696
+ notice ? h('div', { className: 'dm-dg-notice ' + notice.type }, notice.text) : null,
2697
+ selected && selected.staleActive ? h('div', { className: 'dm-dg-notice warn' }, '该任务已超过 ' + Math.round(selected.staleAfterSec / 60) + ' 分钟没有更新,可能已失联;可点「刷新状态」再查对端,或重新发起委托。') : null,
2698
+ h('div', { className: 'dm-dg-body', style: { '--dm-dg-list-w': listWidth + 'px' } },
2699
+ h('section', { className: 'dm-dg-pane' },
2700
+ h('div', { className: 'dm-dg-pane-head' }, '委托列表', h('span', { className: 'dm-dg-sub' }, '(' + visibleTasks.length + (visibleTasks.length !== tasks.length ? '/' + tasks.length : '') + ')')),
2701
+ h('div', { className: 'dm-dg-filters' },
2702
+ h(FilterSelect, { label: '委托方', value: filterFromNode, options: [''].concat(fromNodeOptions), onChange: setFilterFromNode, renderLabel: function (opt) { return opt === '' ? '全部节点' : nodeOptionLabel(opt, true) } }),
2703
+ h(FilterSelect, { label: '接收方', value: filterToNode, options: [''].concat(toNodeOptions), onChange: setFilterToNode, renderLabel: function (opt) { return opt === '' ? '全部节点' : nodeOptionLabel(opt, false) } }),
2704
+ h(FilterSelect, { label: '源实例', value: filterFrom, options: [''].concat(fromOptions), onChange: setFilterFrom, renderLabel: function (opt) { return opt === '' ? '全部' : (opt || '未记录') } }),
2705
+ h(FilterSelect, { label: '目标实例', value: filterTo, options: [''].concat(toOptions), onChange: setFilterTo, renderLabel: function (opt) { return opt === '' ? '全部' : (opt || '未记录') } }),
2706
+ h('button', { type: 'button', className: 'dm-dg-fbtn', onClick: resetFilters }, '重置筛选'),
2707
+ h('span', { className: 'dm-dg-fhint' }, '源实例仅对 2026-11-07 之后新发的委托有效,历史记录显示未记录')
2708
+ ),
2709
+ h('div', { className: 'dm-dg-scroll' },
2710
+ loading && tasks.length === 0 ? h('div', { className: 'dm-dg-skel' }, h('i'), h('i'), h('i'), h('i')) : null,
2711
+ !loading && tasks.length === 0 ? h('div', { className: 'dm-dg-empty' }, '暂无委托记录', h('br'), '有委托后这里会自动出现') : null,
2712
+ !loading && tasks.length > 0 && visibleTasks.length === 0 ? h('div', { className: 'dm-dg-empty' }, '当前筛选无匹配委托') : null,
2713
+ visibleTasks.map(function (task) {
2714
+ var tone = dgTone(task.state)
2715
+ return h('div', {
2716
+ key: task.taskId,
2717
+ className: 'dm-dg-row ' + tone + (task.taskId === selectedId ? ' active' : ''),
2718
+ onClick: function () { if (task.taskId !== selectedId) selectTask(task) },
2719
+ title: task.staleActive ? '超过 ' + Math.round(task.staleAfterSec / 60) + ' 分钟无更新(疑似失联,可点刷新状态)' : (task.goal || '')
2720
+ },
2721
+ h('div', { className: 'dm-dg-row-top' },
2722
+ task.activeFresh ? h(LoadingSpinner, { size: 12 }) : null,
2723
+ h('span', { className: 'dm-dg-route' },
2724
+ h('span', { className: 'dm-dg-from' }, task.fromLabel || task.from || '?'),
2725
+ (task.fromIsLocal || (localNode && task.from === localNode)) ? h('span', { className: 'dm-dg-me' }, task.fromInstance ? '本机·' + task.fromInstance : '本机') : null,
2726
+ h('span', { className: 'dm-dg-arrow' }, '→'),
2727
+ h('span', { className: 'dm-dg-to' }, (task.nodeLabel || task.node || '?') + (task.instance ? ' · ' + task.instance : ''))
2728
+ ),
2729
+ dgChip(task.displayState || task.state),
2730
+ h('span', { className: 'dm-dg-row-time' }, (task.isActive ? '更新 ' : '完成 ') + dgClock(task.displayAt || task.updatedAt))
2731
+ ),
2732
+ h('div', { className: 'dm-dg-goal' }, task.goal || '(无目标)'),
2733
+ h('div', { className: 'dm-dg-meta' },
2734
+ task.instance ? h('span', { className: 'dm-dg-badge' }, task.instance) : null,
2735
+ h('span', null, '历时 ' + dgDur(task.durationSec)),
2736
+ h('span', null, task.taskId),
2737
+ task.policy ? h('span', null, dgPolicyLabel(task.policy)) : null
2738
+ )
2739
+ )
2740
+ })
2741
+ )
2742
+ ),
2743
+ h('div', { className: 'dm-dg-divider', title: '拖动调整列表宽度,双击复位', onPointerDown: startResize, onDoubleClick: resetResize }),
2744
+ h('section', { className: 'dm-dg-pane' },
2745
+ h('div', { className: 'dm-dg-pane-head' },
2746
+ '任务详情',
2747
+ h('span', { className: 'dm-dg-sub' }, selected ? selected.taskId + (lastTaskAt ? ' · 状态更新于 ' + dgClock(lastTaskAt) : '') : '未选择'),
2748
+ h('span', { className: 'dm-dg-spacer' }),
2749
+ selected ? h('button', { type: 'button', className: 'dm-dg-btn', onClick: function () { selectTask(selected) }, disabled: taskLoading },
2750
+ taskLoading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '查询中') : '刷新状态') : null
2751
+ ),
2752
+ h('div', { className: 'dm-dg-scroll' },
2753
+ selected === null
2754
+ ? h('div', { className: 'dm-dg-empty' }, '在左侧选择一条委托', h('br'), '可查看任务详情与对端会话(只读)')
2755
+ : h('div', { className: 'dm-dg-detail' },
2756
+ h('div', { className: 'dm-dg-sec' },
2757
+ h('div', { className: 'dm-dg-sec-head' }, '概要'),
2758
+ h('div', { className: 'dm-dg-sec-body' },
2759
+ h('div', { className: 'dm-dg-kv' },
2760
+ h('span', { className: 'dm-dg-k' }, '状态'),
2761
+ h('span', { className: 'dm-dg-v' },
2762
+ selected.activeFresh ? h(LoadingSpinner, { size: 13 }) : null,
2763
+ dgChip(selected.displayState || selected.state)
2764
+ )
2765
+ ),
2766
+ kv('委托方', (selected.fromLabel || selected.from || '—') + ((selected.fromIsLocal || (localNode && selected.from === localNode)) ? '(本机)' : '')),
2767
+ kv('接收方', (selected.nodeLabel || selected.node || '—') + (selected.instance ? ' · ' + selected.instance : '')),
2768
+ kv('目标实例', selected.instance || '—'),
2769
+ kv('策略', dgPolicyLabel(selected.policy)),
2770
+ kv('历时' + (artifacts && artifacts.durationSec != null ? '(实测)' : ''), artifacts && artifacts.durationSec != null ? dgDur(artifacts.durationSec) : dgDur(selected.durationSec)),
2771
+ kv('开始时间', artifacts && artifacts.requestedAt ? dgClock(artifacts.requestedAt) : dgClock(selected.createdAt)),
2772
+ kv('结束时间', artifacts && artifacts.finishedAt ? dgClock(artifacts.finishedAt) : '—'),
2773
+ kv('台账更新', dgClock(selected.updatedAt))
2774
+ )
2775
+ ),
2776
+ h('div', { className: 'dm-dg-sec' },
2777
+ h('div', { className: 'dm-dg-sec-head' }, '标识'),
2778
+ h('div', { className: 'dm-dg-sec-body' },
2779
+ kv('taskId', selected.taskId, 'taskId'),
2780
+ kv('conversation', selected.conversationId, 'conversationId'),
2781
+ kv('sessionId', selected.sessionId, 'sessionId')
2782
+ )
2783
+ ),
2784
+ h('div', { className: 'dm-dg-sec' },
2785
+ h('div', { className: 'dm-dg-sec-head' }, '目标'),
2786
+ h('div', { className: 'dm-dg-sec-body' }, h('div', { className: 'dm-dg-pre' }, selected.goal || '(无目标)'))
2787
+ ),
2788
+ rich ? h('div', { className: 'dm-dg-sec' },
2789
+ h('div', { className: 'dm-dg-sec-head' }, '时间线'),
2790
+ h('div', { className: 'dm-dg-sec-body' },
2791
+ kv('委托发出', rich.timeline ? dgClock(rich.timeline.requestedAt) : '—'),
2792
+ kv('对端心跳', rich.timeline ? dgClock(rich.timeline.heartbeatAt) : '—'),
2793
+ kv('结束时间', rich.timeline ? dgClock(rich.timeline.finishedAt) : '—'),
2794
+ kv('响应耗时', rich.timeline && rich.timeline.responseSec != null ? dgDur(rich.timeline.responseSec) : '—'),
2795
+ kv('执行耗时', rich.timeline && rich.timeline.execSec != null ? dgDur(rich.timeline.execSec) : '—'),
2796
+ kv('总耗时', rich.timeline && rich.timeline.totalSec != null ? dgDur(rich.timeline.totalSec) : dgDur(selected.durationSec))
2797
+ )
2798
+ ) : null,
2799
+ rich && (rich.operations && (rich.operations.commandsRun.length > 0 || rich.operations.evidence.length > 0 || rich.operations.notes)) ? h('div', { className: 'dm-dg-sec' },
2800
+ h('div', { className: 'dm-dg-sec-head' }, '对端操作',
2801
+ rich.operations.readonly ? h('span', { className: 'dm-dg-chip ok' }, '只读') : null
2802
+ ),
2803
+ h('div', { className: 'dm-dg-sec-body' },
2804
+ rich.operations.notes ? h('div', { className: 'dm-dg-pre' }, rich.operations.notes) : null,
2805
+ rich.operations.commandsRun.length > 0 ? h('div', null,
2806
+ h('div', { className: 'dm-dg-sub' }, '执行过的命令(对端回报)'),
2807
+ h('div', { className: 'dm-dg-pre' }, rich.operations.commandsRun.map(function (item) { return '· ' + item }).join('\n'))
2808
+ ) : null,
2809
+ rich.operations.evidence.length > 0 ? h('div', null,
2810
+ h('div', { className: 'dm-dg-sub' }, '产物/证据'),
2811
+ h('div', { className: 'dm-dg-pre' }, rich.operations.evidence.map(function (item) { return '· ' + item }).join('\n'))
2812
+ ) : null
2813
+ )
2814
+ ) : null,
2815
+ rich && (rich.operations && (rich.operations.scope || rich.operations.acceptance || rich.operations.sessionPolicy)) ? h('div', { className: 'dm-dg-sec' },
2816
+ h('div', { className: 'dm-dg-sec-head' }, '权限 / 范围'),
2817
+ h('div', { className: 'dm-dg-sec-body' },
2818
+ kv('会话策略', rich.operations.sessionPolicy ? dgPolicyLabel(rich.operations.sessionPolicy) : '—'),
2819
+ kv('目标实例', rich.operations.targetInstance || '—'),
2820
+ kv('工作区', rich.operations.workspace || '—'),
2821
+ kv('授权范围', rich.operations.scope || '(未声明)'),
2822
+ kv('验收标准', rich.operations.acceptance || '(未声明)')
2823
+ )
2824
+ ) : null,
2825
+ h('div', { className: 'dm-dg-sec' },
2826
+ h('div', { className: 'dm-dg-sec-head' }, '对端状态',
2827
+ h('span', { className: 'dm-dg-sub' }, taskLoading ? '查询中…' : detail ? '已取到 status.json' : '未查询')),
2828
+ h('div', { className: 'dm-dg-sec-body' },
2829
+ st.degraded ? h('div', { className: 'dm-dg-notice bad' }, '对端查询失败,已降级显示本地台账:' + String(st.error || '')) : null,
2830
+ kv('状态', st.state ? dgStateLabel(st.state) : '—'),
2831
+ kv('会话状态', st.sessionState ? dgStateLabel(st.sessionState) : '—'),
2832
+ kv('心跳延迟', st.staleSeconds == null ? '—' : String(st.staleSeconds) + ' 秒'),
2833
+ kv('error', st.error || '—'),
2834
+ st.result ? h('div', { className: 'dm-dg-pre' }, JSON.stringify(st.result, null, 2)) : null
2835
+ )
2836
+ ),
2837
+ h('div', { className: 'dm-dg-sec' },
2838
+ h('div', { className: 'dm-dg-sec-head' },
2839
+ '对端会话',
2840
+ h('span', { className: 'dm-dg-sub' }, session ? ('轮次 ' + (session.totalMessages || sessionMessages.length) + ' · 帧 ' + (session.frameCount || '—') + ' · ' + Math.round((session.bytes || 0) / 1024) + ' KB') : '只读'),
2841
+ h('span', { className: 'dm-dg-spacer' }),
2842
+ h('button', { type: 'button', className: 'dm-dg-btn primary', onClick: loadSession, disabled: sessionLoading || !selected.sessionId || /^(local|self|localhost)$/i.test(String(selected.node || '')) },
2843
+ sessionLoading ? h('span', { className: 'dm-dg-loading' }, h('span', { className: 'dm-dg-spin' }), '加载中') : (session ? '重新加载' : '加载会话'))
2844
+ ),
2845
+ h('div', { className: 'dm-dg-sec-body' },
2846
+ session ? (session.title ? h('div', { className: 'dm-dg-sub' }, '标题:' + session.title) : null) : null,
2847
+ session
2848
+ ? h('div', { className: 'dm-dg-msgs' },
2849
+ sessionMessages.length === 0 ? h('div', { className: 'dm-dg-empty' }, '无消息') : null,
2850
+ sessionMessages.map(function (msg, idx) {
2851
+ return h('div', { key: idx, className: 'dm-dg-msg ' + (msg.role || '') },
2852
+ h('div', { className: 'dm-dg-msg-head' }, h('span', null, msg.role || '?'), msg.time ? h('span', null, msg.time) : null),
2853
+ h('div', { className: 'dm-dg-msg-text' }, String(msg.text || ''))
2854
+ )
2855
+ })
2856
+ )
2857
+ : h('div', { className: 'dm-dg-empty' }, selected.sessionId ? '点击右上「加载会话」查看对端会话正文' : '该委托没有 sessionId')
2858
+ )
2859
+ )
2860
+ )
2861
+ )
2862
+ )
2863
+ )
2864
+ )
2865
+ }
2866
+
2867
+ // 2026-11-10:AI 网络现在是左侧栏主页面,不再受设置面板的窄容器约束。
2868
+ // 2026-11-10(二次):内边距从 .dm-page 挪到滚动容器上——滚动条属于滚动容器,只有在滚动容器
2869
+ // 铺满整页时才会像 DSH 对话区那样贴到最右边;内容留白靠滚动容器自己的 padding 提供。
2870
+ var PAGE_CSS = `
2871
+ .dm-page { box-sizing:border-box; width:100%; height:100%; overflow:hidden; padding:0; --dm-pad-x:clamp(18px, 2.6vw, 42px); --dm-pad-y:clamp(16px, 2.2vw, 30px); --dm-scroll-inset:2px; }
2872
+ .dm-content-shell { width:100%; height:100%; position:relative; --dm-content-w:920px; }
2873
+ /* 纵向滚动条:对齐 DSH 对话区 wSkVaW_scrollBody 的观感——滚动容器 margin-right:2px
2874
+ (滚动条离面板边 2px,不贴死),滑块 track 留 2px(滑块上下不顶到边)。
2875
+ 滚动条只占右侧会把内容挤得偏左,所以左侧补等宽内边距(--dm-sb-pad)。 */
2876
+ /* border-box 必须加:height:100% + 上下 padding 否则把滚动容器撑出面板高度,
2877
+ 底部 padding 和页脚会被 .dm-page 的 overflow:hidden 裁掉(2026-11-10 实测 1051 > 985)。 */
2878
+ .dm-content-scroll { box-sizing:border-box; height:100%; overflow:auto; scrollbar-gutter:stable; margin-right:var(--dm-scroll-inset); padding:var(--dm-pad-y) var(--dm-pad-x) 36px; padding-left:calc(var(--dm-pad-x) + var(--dm-sb-pad, 0px) + var(--dm-scroll-inset)); }
2879
+ .dm-content-scroll::-webkit-scrollbar-track { margin:2px; }
2880
+ .dm-content { width:min(100%, var(--dm-content-w)); max-width:100%; margin:0 auto; box-sizing:border-box; container-type:inline-size; container-name:dm-content; }
2881
+ .dm-content > .dm-root { width:100%; max-width:100%; margin:0 auto; }
2882
+ /* 宽度手柄:位置由 JS 实测的「内容边距 + 页面留白」算出高亮线中心点,写进 --dm-bar-l / --dm-bar-r。
2883
+ 高亮线在 10px 手柄内偏内侧 5px(::after 4px + 2px 线宽的一半),所以手柄 = 中心点 ∓ 5px。
2884
+ 这样留白窄时(拖到最大)高亮线自动居中在「内容边 ↔ 页面边」中间,留白宽时保持 24px 间隙。 */
2885
+ .dm-width-handle { z-index:1; position:absolute; top:0; bottom:0; cursor:col-resize; width:10px; }
2886
+ .dm-width-handle[data-side="left"] { left:calc(var(--dm-bar-l, -100px) - 5px); }
2887
+ .dm-width-handle[data-side="right"] { right:calc(var(--dm-bar-r, -100px) - 5px); }
2888
+ .dm-width-handle::after { content:''; pointer-events:none; opacity:0; position:absolute; top:0; bottom:0; width:2px; border-radius:2px; background:linear-gradient(to bottom, transparent calc(var(--dm-width-handle-pointer-y, 50%) - 36px), var(--dsw-alias-scrollbar-bg-l1, rgba(128,128,128,.55)) calc(var(--dm-width-handle-pointer-y, 50%) - 8px), var(--dsw-alias-scrollbar-bg-l1, rgba(128,128,128,.55)) calc(var(--dm-width-handle-pointer-y, 50%) + 8px), transparent calc(var(--dm-width-handle-pointer-y, 50%) + 36px)); }
2889
+ .dm-width-handle[data-side="left"]::after { right:4px; }
2890
+ .dm-width-handle[data-side="right"]::after { left:4px; }
2891
+ .dm-width-handle[data-dragging]::after { opacity:1; }
2892
+ @media (hover:hover) { .dm-width-handle:hover::after { opacity:1; } }
2893
+ @media (max-width: 1000px) { .dm-width-handle { display:none; } }
2894
+ .dm-body { display:grid; grid-template-columns:minmax(300px, var(--dm-main-w, 62%)) 8px minmax(0,1fr); gap:10px; align-items:start; }
2895
+ .dm-lan { border:1px solid rgba(128,128,128,.25); border-radius:12px; padding:10px 12px; background:rgba(128,128,128,.035); }
2896
+ .dm-lan-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
2897
+ .dm-lan input { font:inherit; font-size:12px; padding:5px 8px; border:1px solid rgba(128,128,128,.35); border-radius:7px; background:transparent; color:inherit; min-width:180px; }
2898
+ .dm-lan-node { display:flex; gap:8px; align-items:center; border-top:1px solid rgba(128,128,128,.16); padding:7px 0; font-size:12px; }
2899
+ .dm-lan-node:first-child { border-top:none; }
2900
+ .dm-lan-code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; padding:6px 8px; border-radius:8px; background:rgba(88,166,255,.1); border:1px solid rgba(88,166,255,.35); word-break:break-all; }
2901
+ .dm-toggle { display:inline-flex; align-items:center; gap:5px; font-size:11.5px; opacity:.8; cursor:pointer; user-select:none; white-space:nowrap; }
2902
+ .dm-toggle input { margin:0; }
2903
+ .dm-nodes { display:flex; flex-direction:column; gap:4px; }
2904
+ .dm-node-row { display:flex; align-items:center; gap:8px; padding:6px 0; border-top:1px solid rgba(128,128,128,.14); font-size:12px; }
2905
+ .dm-node-row:first-child { border-top:none; }
2906
+ .dm-node-name { font-weight:600; min-width:80px; }
2907
+ .dm-node-url { opacity:.6; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2908
+ .dm-usage { display:flex; flex-direction:column; gap:5px; font-size:12px; }
2909
+ .dm-usage-row { display:flex; align-items:center; gap:7px; }
2910
+ .dm-help-overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; }
2911
+ .dm-help-card { width:min(760px,100%); max-height:86vh; overflow:auto; background:#161a20; color:#d8dee9; border:1px solid rgba(128,128,128,.3); border-radius:14px; padding:16px 18px; box-shadow:0 24px 80px rgba(0,0,0,.55); }
2912
+ .dm-help-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
2913
+ .dm-help-title { font-weight:700; font-size:15px; }
2914
+ .dm-help-section { margin-top:12px; }
2915
+ .dm-help-h { font-weight:650; font-size:12.5px; margin-bottom:4px; }
2916
+ .dm-help-prompt { border-top:1px solid rgba(128,128,128,.16); padding:9px 0; }
2917
+ .dm-help-prompt-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:3px; }
2918
+ .dm-btn.bad { border-color:rgba(209,75,65,.55); color:#d14b41; }
2919
+
2920
+ .dm-card-node.indirect { opacity:.62; border-style:dashed; border-color:rgba(128,128,128,.45); background:rgba(128,128,128,.03); }
2921
+ .dm-fed-badge { font-size:10px; opacity:.6; padding:1px 5px; border:1px dashed rgba(128,128,128,.5); border-radius:999px; white-space:nowrap; }
2922
+ .dm-edge-indirect { stroke:rgba(128,128,128,.5); stroke-width:1.1; fill:none; }
2923
+
2924
+ .dm-pane { min-width:0; display:flex; flex-direction:column; gap:12px; }
2925
+ .dm-divider { cursor:col-resize; align-self:stretch; min-height:120px; border-radius:4px; position:relative; }
2926
+ .dm-divider::before { content:''; position:absolute; top:0; bottom:0; left:3px; width:1px; background:rgba(128,128,128,.28); }
2927
+ .dm-divider:hover::before, .dm-divider:active::before { background:rgba(88,166,255,.7); width:2px; left:2.5px; }
2928
+ @container dm-content (max-width: 860px) { .dm-body { grid-template-columns:1fr; } .dm-divider { display:none; } }
2929
+ `
2930
+ function MeshNetworkPage() {
2931
+ // 宽度偏好:与 DSH 对话区相同语义——空 = 默认宽度,数字 = 用户拖拽固定值。
2932
+ var prefState = useState(function () {
2933
+ try { var saved = Number(window.localStorage.getItem('dsh-mesh.contentWidth')); return Number.isFinite(saved) && saved > 0 ? saved : 0 } catch (error) { return 0 }
2934
+ })
2935
+ var pref = prefState[0]; var setPref = prefState[1]
2936
+ var shellState = useState(0); var containerW = shellState[0]; var setContainerW = shellState[1]
2937
+ // 滚动条实际占位宽度(px):左侧补等宽内边距,让内容在 shell 上真正居中。
2938
+ var sbState = useState(0); var sbPad = sbState[0]; var setSbPad = sbState[1]
2939
+ // 手柄几何:内容元素到 shell 左右边的实测 px(null = 还没量到)。
2940
+ var geoState = useState(null); var geo = geoState[0]; var setGeo = geoState[1]
2941
+ var shellRef = useRef(null)
2942
+
2943
+ // 2026-11-10:宽度基准 =「内容可用宽度」= 滚动容器内容盒宽(clientWidth - 左右 padding);
2944
+ // 滚动条贴页面右边,其占位由 CSS 在左侧补等宽 padding 抵消(--dm-sb-pad)。
2945
+ useEffect(function () {
2946
+ var el = shellRef.current
2947
+ if (!el) return
2948
+ function measure() {
2949
+ var scroller = el.querySelector('.dm-content-scroll')
2950
+ if (!scroller) { setContainerW(el.clientWidth || 0); return }
2951
+ var sb = Math.max(0, (scroller.offsetWidth || 0) - (scroller.clientWidth || 0))
2952
+ var scs = getComputedStyle(scroller)
2953
+ var padL = parseFloat(scs.paddingLeft) || 0
2954
+ var padR = parseFloat(scs.paddingRight) || 0
2955
+ // 内容盒宽 = clientWidth - 实际左右 padding(左侧含滚动条补偿与 2px 滚动条外留白)
2956
+ var avail = Math.max(0, (scroller.clientWidth || 0) - padL - padR)
2957
+ setContainerW(avail)
2958
+ setSbPad(function (prev) { return Math.abs(prev - sb) >= 0.5 ? sb : prev })
2959
+ }
2960
+ measure()
2961
+ if (typeof ResizeObserver !== 'undefined') {
2962
+ var observer = new ResizeObserver(measure)
2963
+ observer.observe(el)
2964
+ return function () { observer.disconnect() }
2965
+ }
2966
+ window.addEventListener('resize', measure)
2967
+ return function () { window.removeEventListener('resize', measure) }
2968
+ }, [sbPad])
2969
+
2970
+ // 手柄定位:高亮线在「内容边 ↔ 页面边」的留白里自动居中(留白宽时退回 24px 间隙)。
2971
+ // 滚动容器已铺满整页,所以内容到 shell 左右边的距离就是完整留白(右侧含滚动条)。 */
2972
+ useEffect(function () {
2973
+ var el = shellRef.current
2974
+ if (!el) return
2975
+ var raf = 0
2976
+ function sync() {
2977
+ var content = el.querySelector('.dm-content')
2978
+ if (!content) return
2979
+ var sr = el.getBoundingClientRect()
2980
+ var cr = content.getBoundingClientRect()
2981
+ // 留白量到 shell(页面)边:滚动条外那 2px 也算留白,高亮线才居中在视觉留白里
2982
+ var offL = cr.left - sr.left
2983
+ var offR = sr.right - cr.right
2984
+ // 高亮线中心到内容边的距离:留白的一半,但夹在 [12, 29](29 = 24px 间隙 + 5px 线中心偏移)
2985
+ var gapL = Math.max(12, Math.min(29, offL / 2))
2986
+ var gapR = Math.max(12, Math.min(29, offR / 2))
2987
+ var bl = Math.round((offL - gapL) * 10) / 10
2988
+ var br = Math.round((offR - gapR) * 10) / 10
2989
+ setGeo(function (prev) {
2990
+ if (prev && Math.abs(prev.l - bl) < 0.5 && Math.abs(prev.r - br) < 0.5) return prev
2991
+ return { l: bl, r: br }
2992
+ })
2993
+ }
2994
+ sync()
2995
+ raf = window.requestAnimationFrame(sync)
2996
+ if (typeof ResizeObserver !== 'undefined') {
2997
+ var observer = new ResizeObserver(sync)
2998
+ var content = el.querySelector('.dm-content')
2999
+ if (content) observer.observe(content)
3000
+ observer.observe(el)
3001
+ return function () { window.cancelAnimationFrame(raf); observer.disconnect() }
3002
+ }
3003
+ return function () { window.cancelAnimationFrame(raf) }
3004
+ }, [contentW, sbPad])
3005
+
3006
+ // 默认宽度 = 内容可用宽度 x DEFAULT_W_RATIO;拖拽限制在 [640, 可用宽度],
3007
+ // 双击手柄清掉偏好后回到默认。想留一点内边距就把 DEFAULT_W_RATIO 调到 0.98/0.96。
3008
+ var DEFAULT_W_RATIO = 1
3009
+ var widthNow = containerW || 920
3010
+ var maxW = Math.max(640, widthNow)
3011
+ var contentW = pref > 0 ? Math.min(Math.max(pref, 640), maxW) : Math.round(widthNow * DEFAULT_W_RATIO)
3012
+
3013
+ function beginWidthDrag(side, event) {
3014
+ event.preventDefault()
3015
+ var handle = event.currentTarget
3016
+ var shell = handle.closest ? handle.closest('.dm-content-shell') : (handle.parentElement && handle.parentElement.parentElement)
3017
+ if (!shell) return
3018
+ var scroller = shell.querySelector('.dm-content-scroll')
3019
+ var max = 920
3020
+ if (scroller) {
3021
+ var scs2 = getComputedStyle(scroller)
3022
+ var padL2 = parseFloat(scs2.paddingLeft) || 0
3023
+ var padR2 = parseFloat(scs2.paddingRight) || 0
3024
+ max = Math.max(0, (scroller.clientWidth || 0) - padL2 - padR2)
3025
+ } else if (shell.clientWidth) {
3026
+ max = shell.clientWidth
3027
+ }
3028
+ max = Math.max(640, max || 920)
3029
+ var base = contentW
3030
+ var originX = event.clientX
3031
+ var latest = contentW
3032
+ var firstBox = handle.getBoundingClientRect()
3033
+ handle.style.setProperty('--dm-width-handle-pointer-y', (event.clientY - firstBox.top) + 'px')
3034
+ handle.toggleAttribute('data-dragging', true)
3035
+ document.body.style.cursor = 'col-resize'
3036
+ document.body.style.userSelect = 'none'
3037
+ function onMove(moveEvent) {
3038
+ var dx = moveEvent.clientX - originX
3039
+ var outward = side === 'right' ? dx : -dx
3040
+ // 只有拖拽时才更新高亮位置(与 DSH 一致);hover 时保持手柄高度的 50%。
3041
+ var box = handle.getBoundingClientRect()
3042
+ handle.style.setProperty('--dm-width-handle-pointer-y', (moveEvent.clientY - box.top) + 'px')
3043
+ // 两侧同时外扩/内收,所以内容宽度变化是手柄位移的 2 倍(与 DSH 一致)。
3044
+ latest = Math.min(Math.max(base + outward * 2, 640), max)
3045
+ setPref(latest)
3046
+ }
3047
+ function onUp() {
3048
+ handle.toggleAttribute('data-dragging', false)
3049
+ window.removeEventListener('pointermove', onMove)
3050
+ window.removeEventListener('pointerup', onUp)
3051
+ document.body.style.cursor = ''
3052
+ document.body.style.userSelect = ''
3053
+ try { window.localStorage.setItem('dsh-mesh.contentWidth', String(latest)) } catch (error) { /* ignore */ }
3054
+ }
3055
+ window.addEventListener('pointermove', onMove)
3056
+ window.addEventListener('pointerup', onUp)
3057
+ }
3058
+ function resetContentWidth() {
3059
+ setPref(0)
3060
+ try { window.localStorage.removeItem('dsh-mesh.contentWidth') } catch (error) { /* ignore */ }
3061
+ }
3062
+
3063
+ return h('div', { className: 'dm-page' },
3064
+ h('style', null, PAGE_CSS),
3065
+ h('div', {
3066
+ className: 'dm-content-shell',
3067
+ ref: shellRef,
3068
+ style: {
3069
+ '--dm-content-w': contentW + 'px',
3070
+ '--dm-sb-pad': sbPad + 'px',
3071
+ '--dm-bar-l': (geo ? geo.l : -100) + 'px',
3072
+ '--dm-bar-r': (geo ? geo.r : -100) + 'px'
3073
+ }
3074
+ },
3075
+ h('div', { className: 'dm-width-handle', 'data-side': 'left', title: '拖拽调整内容宽度(双击恢复默认)', onPointerDown: function (event) { beginWidthDrag('left', event) }, onDoubleClick: resetContentWidth }),
3076
+ h('div', { className: 'dm-width-handle', 'data-side': 'right', title: '拖拽调整内容宽度(双击恢复默认)', onPointerDown: function (event) { beginWidthDrag('right', event) }, onDoubleClick: resetContentWidth }),
3077
+ h('div', { className: 'dm-content-scroll' },
3078
+ h('div', { className: 'dm-content', style: { width: 'min(100%, ' + contentW + 'px)' } }, h(MeshStatusSection))
3079
+ )
3080
+ )
3081
+ )
3082
+ }
3083
+
3084
+ var SECTION = { name: 'settings.section', id: 'dsh-mesh', order: 90, label: 'AI 网络' }
3085
+ var inject = ['slots']
3086
+
3087
+ // 2026-11-07:设置左侧导航里「AI 网络」这一项,把壳的默认齿轮换成三节点图标,颜色跟随 --dsh-brand-color
3088
+ function navMeshIconDom(referenceClass) {
3089
+ var NS = 'http://www.w3.org/2000/svg'
3090
+ var svg = document.createElementNS(NS, 'svg')
3091
+ if (referenceClass) svg.setAttribute('class', referenceClass)
3092
+ svg.setAttribute('width', '16')
3093
+ svg.setAttribute('height', '16')
3094
+ svg.setAttribute('viewBox', '0 0 16 16')
3095
+ svg.setAttribute('fill', 'none')
3096
+ svg.setAttribute('stroke', 'currentColor')
3097
+ svg.setAttribute('stroke-width', '1.4')
3098
+ svg.setAttribute('stroke-linecap', 'round')
3099
+ svg.setAttribute('aria-hidden', 'true')
3100
+ svg.setAttribute('data-dsh-mesh-nav-icon', '1')
3101
+ svg.style.color = 'var(--dsh-brand-color, currentColor)'
3102
+ var line = document.createElementNS(NS, 'path')
3103
+ line.setAttribute('d', 'M8 3.2 L3.2 8 M8 3.2 L12.8 8 M3.2 8 L12.8 8')
3104
+ line.setAttribute('opacity', '.7')
3105
+ svg.appendChild(line)
3106
+ var dots = [[8, 3.2], [3.2, 8], [12.8, 8]]
3107
+ for (var i = 0; i < dots.length; i++) {
3108
+ var circle = document.createElementNS(NS, 'circle')
3109
+ circle.setAttribute('cx', dots[i][0])
3110
+ circle.setAttribute('cy', dots[i][1])
3111
+ circle.setAttribute('r', '1.6')
3112
+ circle.setAttribute('fill', 'currentColor')
3113
+ circle.setAttribute('stroke', 'none')
3114
+ svg.appendChild(circle)
3115
+ }
3116
+ return svg
3117
+ }
3118
+ function decorateMeshNav() {
3119
+ try {
3120
+ var buttons = document.querySelectorAll('[role="dialog"] nav button')
3121
+ for (var i = 0; i < buttons.length; i++) {
3122
+ var button = buttons[i]
3123
+ var text = (button.textContent || '').trim()
3124
+ if (text !== 'AI 网络' && text !== 'AI 网络(dsh-mesh)') continue
3125
+ // 0.2.2 的默认图标可能包在 span 里;在默认图标的父容器里原地替换,
3126
+ // 保留间距/对齐,并清掉所有旧 SVG,避免齿轮 + 自绘图标双图标。
3127
+ var existingIcon = button.querySelector('svg[data-dsh-mesh-nav-icon]')
3128
+ var old = []
3129
+ var all = button.querySelectorAll('svg')
3130
+ for (var j = 0; j < all.length; j++) if (all[j] !== existingIcon) old.push(all[j])
3131
+ var reference = old[0] || null
3132
+ var parent = reference ? reference.parentNode : button
3133
+ var className = reference ? reference.getAttribute('class') || '' : ''
3134
+ if (!existingIcon) {
3135
+ var icon = navMeshIconDom(className)
3136
+ if (reference) parent.insertBefore(icon, reference)
3137
+ else if (button.firstChild) button.insertBefore(icon, button.firstChild)
3138
+ else button.appendChild(icon)
3139
+ }
3140
+ for (var k = 0; k < old.length; k++) if (old[k].parentNode) old[k].parentNode.removeChild(old[k])
3141
+ }
3142
+ } catch (error) {
3143
+ /* ignore */
3144
+ }
3145
+ }
3146
+
3147
+ function apply(ctx) {
3148
+ var disposers = []
3149
+ // 2026-11-10:AI 网络从「设置」迁到左侧栏独立页面,使用更大的主区域。
3150
+ try {
3151
+ disposers.push(ctx.slots.inject('sidebar.panellist', function () {
3152
+ return ctx.slots.register({ name: 'sidebar.panellist', id: 'mesh-network', order: 54, label: function () { return 'AI 网络' } }, networkPanelIcon)
3153
+ }))
3154
+ disposers.push(ctx.slots.inject('main', function () {
3155
+ return ctx.slots.register({ name: 'main', key: 'mesh-network', inject: function () { return {} } }, MeshNetworkPage)
3156
+ }))
3157
+ } catch (error) {
3158
+ console.warn('[dsh-mesh-ui] sidebar network page registration failed:', error)
3159
+ }
3160
+ // 2026-11-07 方案 A:左侧栏「委托」面板(sidebar.panellist + main)
3161
+ try {
3162
+ disposers.push(ctx.slots.inject('sidebar.panellist', function () {
3163
+ return ctx.slots.register({ name: 'sidebar.panellist', id: 'mesh-delegations', order: 55, label: function () { return '委托' } }, meshPanelIcon)
3164
+ }))
3165
+ disposers.push(ctx.slots.inject('main', function () {
3166
+ return ctx.slots.register({ name: 'main', key: 'mesh-delegations', inject: function () { return {} } }, MeshDelegationPanel)
3167
+ }))
3168
+ } catch (error) {
3169
+ console.warn('[dsh-mesh-ui] delegation panel registration failed:', error)
3170
+ }
3171
+ var navObserver = null
3172
+ var navTimer = null
3173
+ try {
3174
+ if (typeof MutationObserver !== 'undefined' && document.body) {
3175
+ navObserver = new MutationObserver(function () { decorateMeshNav() })
3176
+ navObserver.observe(document.body, { childList: true, subtree: true })
3177
+ }
3178
+ navTimer = setInterval(decorateMeshNav, 1500)
3179
+ if (navTimer && typeof navTimer.unref === 'function') navTimer.unref()
3180
+ } catch (error) {
3181
+ /* ignore */
3182
+ }
3183
+ ctx.effect(function () {
3184
+ return function () {
3185
+ for (var i = 0; i < disposers.length; i++) { try { disposers[i]() } catch (_) { /* ignore */ } }
3186
+ try { if (navObserver) navObserver.disconnect() } catch (_) { /* ignore */ }
3187
+ try { if (navTimer) clearInterval(navTimer) } catch (_) { /* ignore */ }
3188
+ }
3189
+ }, 'dsh-mesh-ui: settings section + nav icon')
3190
+ }
3191
+
3192
+ exports.apply = apply
3193
+ exports.inject = inject
3194
+ return module.exports
3195
+ }
3196
+ })