@having5548/dsh-downloader 0.5.3 → 0.7.1

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 CHANGED
@@ -1,14 +1,16 @@
1
1
  // ============================================================================
2
- // dsh-downloader —— DSH 下载代理插件(浏览器/客户端半部)
2
+ // dsh-downloader —— DSH 下载代理插件(客户端半部)
3
3
  //
4
- // 职责:在「设置 → 下载代理」里挂一个管理面板
5
- // 1. 编辑插件 Config(经 ctx.get('configForms').get('dsh-downloader'))
6
- // 2. 轮询 /dsh-downloader/status 与 /proxies 显示实时状态
7
- // 3. 更新订阅 / 重启内核 / 逐节点测速与选择
4
+ // 两个入口,共用同一套组件与样式(要求「两个入口的 UI 风格一致」):
5
+ // 1. 左侧栏导航页 —— sidebar.panellist(图标行)+ main(keyed 页面)
6
+ // 2. 设置页 —— settings.section(同一套 Page 组件,variant='settings')
7
+ // 另外保留 sidebar.footer.action 的实时流量徽标。
8
8
  //
9
- // 以预打包 bundle 形式随插件安装,格式与官方 client 插件一致
10
- // (window.__ModuleLoader__)。注意:0.1.7 起 ctx.settingsScope 已删除,
11
- // 必须惰性走 ctx.configForms;且 scope.subscribe / getSnapshot 是类原型方法,
9
+ // 文案:LOCALE_ZH / LOCALE_EN 两份完整字典,经 ctx.locale 注册(DSH 会按系统语言
10
+ // 自动选中,用户也能在 设置 → 语言 里改)。页内另有 自动/中/EN 三态切换,只影响
11
+ // 本插件页面,不劫持全局语言。
12
+ //
13
+ // 必须惰性走 ctx.configForms;scope.subscribe / getSnapshot 是类原型方法,
12
14
  // 直接传给 useSyncExternalStore 会因 this 丢失而崩,必须包一层闭包。
13
15
  // ============================================================================
14
16
  window.__ModuleLoader__.load({
@@ -26,6 +28,9 @@ window.__ModuleLoader__.load({
26
28
  var ENTRY_ID = 'dsh-downloader'
27
29
  var LOCALE_NS = 'dsh-downloader'
28
30
  var API_BASE = '/dsh-downloader/'
31
+ var PANEL_ID = 'dsh-downloader'
32
+ var LS_ADVANCED = 'dsh-downloader.advanced'
33
+ var LS_LANG = 'dsh-downloader.lang'
29
34
 
30
35
  var DEFAULTS = {
31
36
  enabled: true,
@@ -50,308 +55,817 @@ window.__ModuleLoader__.load({
50
55
  threads: 4,
51
56
  insecureTls: false,
52
57
  allowOutsideWorkspace: false,
53
- maxRedirects: 10
58
+ maxRedirects: 10,
59
+ harnessProxy: false,
60
+ harnessProxyPort: 17890
54
61
  }
55
62
 
63
+ // ------------------------------------------------------------------
64
+ // 文案
65
+ // ------------------------------------------------------------------
56
66
  var LOCALE_ZH = {
57
67
  nav: '下载代理',
58
68
  title: '下载代理',
59
- intro: '让模型下载境外文件时自动走代理、境内直连。代理出口由插件自带的订阅内核提供,不依赖本机 Clash。',
60
- upstream: '上游',
61
- nodes: '节点',
62
- rules: '分流规则',
63
- download: '下载',
64
- refresh: '刷新',
65
- restart: '重启内核',
66
- updateSubscription: '更新订阅',
67
- testAll: '全部测速',
68
- select: '选择',
69
- test: '测速',
70
- clearLatency: '清空延迟',
71
- sessionGuard: '会话保护',
72
- guardIntro: '把国内 AI 平台域名写进 $DSH_HOME/.env 的 NO_PROXY,让模型连接绕开全局代理 —— 代理程序关掉或节点抖动都不会断链。写入前会自动备份。',
73
- guardApply: '写入 NO_PROXY',
74
- guardRestore: '还原备份',
75
- guardCovered: '已覆盖',
76
- guardMissing: '缺少',
77
- guardNoProxyVars: '该文件没有配置 HTTP_PROXY / HTTPS_PROXY,此项暂时无效',
78
- guardRestart: '改完需要重启 DSH 才生效',
79
- notRunning: '未运行',
69
+ subtitle: '境外文件走代理、境内直连,由插件自带的订阅内核提供出口',
70
+ entryLabel: '下载代理',
71
+ langAuto: '自动',
72
+ langZh: '中文',
73
+ langEn: 'English',
74
+
75
+ // 状态
80
76
  stRunning: '运行中',
81
77
  stDisabled: '已关闭',
82
- stNoSubscription: '无可用订阅',
83
78
  stFailed: '失败',
84
79
  stIdle: '未启动',
85
- mdExplicit: '上游:配置的 proxyUrl',
86
- mdEnvironment: '上游:启动环境代理',
87
- mdSubscription: '上游:自包含订阅',
88
- mdNone: '上游:未配置',
89
- nodesBadge: '节点',
80
+ stNoSubscription: '未配置订阅',
81
+ stUnavailable: '不可用',
82
+ refresh: '刷新',
83
+ restartCore: '重启内核',
84
+ corePort: '内核端口',
85
+ coreNone: '未运行',
86
+ nodesCount: '节点',
87
+ currentNode: '当前节点',
88
+ nodeAuto: '自动选择',
89
+ modeExplicit: '固定代理地址',
90
+ modeSubscription: '订阅',
91
+ modeEnvironment: '环境代理',
92
+ modeNone: '未配置',
93
+ upstream: '上游',
94
+ upstreamNone: '既没有配置订阅,也没有代理地址',
95
+ notRunningHint: '内核没有运行,下载会直接失败。先看下面的上游配置。',
96
+
97
+ // 配置(节点配置)
90
98
  profiles: '节点配置',
91
- profilesIntro: '一份配置 = 一条订阅地址或一个导入的配置文件,带自己的名字、上次更新时间与自动更新间隔(对齐 FlClash 的配置模型)。',
92
- importUrl: '从订阅地址导入',
99
+ profilesHint: '一份配置 = 一条订阅地址或一个导入的配置文件',
100
+ addProfile: '添加配置',
101
+ importUrl: '订阅地址',
93
102
  importUrlPlaceholder: 'https://… 或 clash://install-config?url=…',
94
- importLabelPlaceholder: '名称(留空则自动取响应头文件名,再退回域名)',
95
- importFile: '从文件导入',
96
- importBtn: '导入',
97
- noProfiles: '还没有导入任何配置。可以从订阅地址或本地文件导入。',
98
- profileCurrent: '当前',
99
- profileFile: '本地文件',
100
- profileAuto: '自动更新',
101
- profileSelect: '选用',
102
- profileUpdate: '更新',
103
- profileRemove: '删除',
104
- scopeTitle: '代理作用域',
105
- scopeIntro: '只服务 DSH 自己发起的下载请求:内核只绑 127.0.0.1 并要求本进程的随机令牌,其它程序连不上也借不走;系统代理与环境变量从未被修改。',
106
- trafficTitle: '经过本插件内核的实时流量',
103
+ importLabel: '名称(可留空)',
104
+ importFile: '导入文件',
105
+ import: '导入',
106
+ cancel: '取消',
107
+ emptyProfiles: '还没有配置。点右下角「添加配置」导入订阅或文件。',
108
+ current: '当前',
109
+ fileSource: '本地文件',
110
+ updatedAt: '更新于',
111
+ never: '从未更新',
112
+ use: '选用',
113
+ update: '更新',
114
+ remove: '删除',
115
+ autoUpdate: '自动更新',
116
+ autoUpdateOn: '自动更新 · 每 %s 小时',
117
+ trafficQuota: '流量',
118
+
119
+ // 节点
120
+ nodes: '代理节点',
121
+ nodesHint: '点一个节点即选用;「自动选择」按延迟自动挑',
122
+ testAll: '全部测速',
123
+ testing: '测速中…',
124
+ ms: '%s ms',
125
+ timeout: '超时',
126
+ untested: '未测',
127
+ needsNative: '需连接器',
128
+ nodeType: '类型',
129
+
130
+ // 下载
131
+ download: '下载',
132
+ downloadDir: '下载目录',
133
+ downloadDirHint: '留空 = DSH 的 downloads 目录',
134
+ threads: '线程数',
135
+ threadsHint: '服务端支持时按此并发下载;1 = 单连接',
136
+ maxMb: '单文件上限(MB)',
137
+ dirPlaceholder: '留空使用默认目录',
138
+
139
+ // 高级
140
+ advanced: '高级设置',
141
+ advancedHint: '打开后显示全部可调项',
142
+ advUpstream: '上游与订阅',
143
+ proxyUrl: '固定代理地址',
144
+ proxyUrlHint: '显式上游,支持 http:// 与 socks5://;填了就不再使用订阅',
145
+ subscriptionUrl: '订阅地址(旧版)',
146
+ subscriptionUrlHint: '只有在没有任何「节点配置」时才会用它',
147
+ fetchProxyUrl: '拉取订阅用的代理',
148
+ fetchProxyUrlHint: '仅当订阅本身被墙时填,必须是 http(s):// 代理',
149
+ groupType: '分组类型',
150
+ preferredNode: '首选节点',
151
+ preferredNodeHint: '按名字固定一个节点,优先级最高',
152
+ autoUpdateHours: '订阅自动更新(小时)',
153
+ latencyTestUrl: '测速地址',
154
+ latencyTimeoutMs: '测速超时(ms)',
155
+ advRouting: '分流',
156
+ domesticDirect: '境内直连',
157
+ domesticDirectHint: '关闭后所有目标都走代理',
158
+ protectAiPlatforms: '国内 AI 平台强制直连',
159
+ protectAiPlatformsHint: '最高优先级 DIRECT,连代理抖动也不会断会话',
160
+ extraDirectDomains: '额外直连域名',
161
+ extraDirectDomainsHint: '逗号或换行分隔',
162
+ extraRules: '追加规则',
163
+ extraRulesHint: '每行一条,如 DOMAIN-SUFFIX,example.com,DIRECT',
164
+ excludeRules: '剔除规则',
165
+ excludeRulesHint: '每行一条,子串匹配订阅规则',
166
+ advEngine: '下载引擎',
167
+ maxRedirects: '最大重定向次数',
168
+ stallTimeoutS: '停滞判定(秒)',
169
+ downloadTimeoutS: '总时长预算(秒)',
170
+ insecureTls: '跳过 TLS 校验',
171
+ insecureTlsHint: '仅企业 TLS 解密环境需要',
172
+ allowOutsideWorkspace: '允许写工作区外',
173
+ allowOutsideWorkspaceHint: '否则只写到工作区与下载目录',
174
+ guardShellDownloads: '拦截未走代理的境外 shell 下载',
175
+ guardShellDownloadsHint: '拦到就改用 dsh_run_proxied 重跑',
176
+ advHarness: 'harness 出网',
177
+ harnessProxy: '开放给 harness',
178
+ harnessProxyHint: '让 web_fetch / shell 子进程 / MCP 的 HTTP 也走内核。写入 $DSH_HOME/.env 后需重启 DSH',
179
+ harnessProxyPort: '固定端口',
180
+ harnessProxyPortHint: '写进 .env,不能随机;被占用时自动退回随机端口',
181
+
182
+ // 会话保护
183
+ guard: '会话保护',
184
+ guardHint: '把国内 AI 平台域名写进 NO_PROXY,代理关掉也不断会话',
185
+ guardCovered: '已覆盖 %s 个域名',
186
+ guardMissing: '缺少 %s 个',
187
+ guardApply: '写入',
188
+ guardRestore: '还原',
189
+ guardNoVars: '该文件还没有配置 HTTP_PROXY,这项暂时不起作用',
190
+ guardRestart: '改完需要重启 DSH',
191
+
192
+ // 流量 / 页脚
193
+ traffic: '流量',
107
194
  trafficUp: '上行',
108
195
  trafficDown: '下行',
109
196
  trafficTotal: '累计',
110
- unavailable: '当前环境无法持久化设置,本次修改仅当前页面生效。'
197
+ trafficIdle: '内核没在跑,没有流量',
198
+
199
+ // 其它
200
+ unavailable: '当前环境无法持久化设置,本次修改仅当前页面生效。',
201
+ scopeNote: '内核只绑 127.0.0.1 并要求本进程令牌,其它程序连不上也借不走。',
202
+ about: '关于',
203
+ dataDir: '数据目录',
204
+ saved: '已保存',
205
+ failed: '失败'
111
206
  }
207
+
112
208
  var LOCALE_EN = {
113
209
  nav: 'Download proxy',
114
210
  title: 'Download proxy',
115
- intro: 'Routes foreign downloads through a proxy and keeps domestic targets direct. The proxy exit comes from the plugin\'s own subscription core — no local Clash needed.',
116
- upstream: 'Upstream',
117
- nodes: 'Nodes',
118
- rules: 'Rules',
119
- download: 'Download',
211
+ subtitle: 'Foreign files go through the proxy, domestic stay direct — the exit comes from the plugin own subscription core',
212
+ entryLabel: 'Download proxy',
213
+ langAuto: 'Auto',
214
+ langZh: '中文',
215
+ langEn: 'English',
216
+
217
+ stRunning: 'Running',
218
+ stDisabled: 'Disabled',
219
+ stFailed: 'Failed',
220
+ stIdle: 'Not started',
221
+ stNoSubscription: 'No subscription',
222
+ stUnavailable: 'Unavailable',
120
223
  refresh: 'Refresh',
121
- restart: 'Restart core',
122
- updateSubscription: 'Update subscription',
123
- testAll: 'Test all',
124
- select: 'Select',
125
- test: 'Test',
126
- clearLatency: 'Clear latencies',
127
- sessionGuard: 'Session guard',
128
- guardIntro: 'Writes the Chinese AI platform domains into NO_PROXY in $DSH_HOME/.env so model connections bypass the global proxy — closing the proxy app or losing a node cannot break the session. The file is backed up before any write.',
129
- guardApply: 'Write NO_PROXY',
130
- guardRestore: 'Restore backup',
131
- guardCovered: 'covered',
132
- guardMissing: 'missing',
133
- guardNoProxyVars: 'That file sets no HTTP_PROXY / HTTPS_PROXY, so this entry is inert for now',
134
- guardRestart: 'Restart DSH for the change to take effect',
135
- notRunning: 'not running',
136
- stRunning: 'running',
137
- stDisabled: 'disabled',
138
- stNoSubscription: 'no usable subscription',
139
- stFailed: 'failed',
140
- stIdle: 'idle',
141
- mdExplicit: 'upstream: configured proxyUrl',
142
- mdEnvironment: 'upstream: launch environment',
143
- mdSubscription: 'upstream: self-contained subscription',
144
- mdNone: 'upstream: not configured',
145
- nodesBadge: 'nodes',
224
+ restartCore: 'Restart core',
225
+ corePort: 'Core port',
226
+ coreNone: 'not running',
227
+ nodesCount: 'Nodes',
228
+ currentNode: 'Current node',
229
+ nodeAuto: 'Auto select',
230
+ modeExplicit: 'Fixed proxy URL',
231
+ modeSubscription: 'Subscription',
232
+ modeEnvironment: 'Environment proxy',
233
+ modeNone: 'Not configured',
234
+ upstream: 'Upstream',
235
+ upstreamNone: 'Neither a subscription nor a proxy URL is configured',
236
+ notRunningHint: 'The core is not running, so downloads will fail. Check the upstream settings below.',
237
+
146
238
  profiles: 'Node profiles',
147
- profilesIntro: 'One profile is either a subscription URL or an imported file, each with its own label, last-update time and auto-update interval — the same model FlClash uses.',
148
- importUrl: 'Import from a subscription URL',
239
+ profilesHint: 'One profile = a subscription URL or an imported config file',
240
+ addProfile: 'Add profile',
241
+ importUrl: 'Subscription URL',
149
242
  importUrlPlaceholder: 'https://… or clash://install-config?url=…',
150
- importLabelPlaceholder: 'Name (blank: use the response filename, then the host)',
151
- importFile: 'Import from a file',
152
- importBtn: 'Import',
153
- noProfiles: 'No profiles yet. Import one from a subscription URL or a local file.',
154
- profileCurrent: 'current',
155
- profileFile: 'local file',
156
- profileAuto: 'auto-update',
157
- profileSelect: 'Use',
158
- profileUpdate: 'Update',
159
- profileRemove: 'Delete',
160
- scopeTitle: 'Proxy scope',
161
- scopeIntro: 'Serves only downloads this plugin starts: the core binds 127.0.0.1 and requires a per-process random token, so no other program can reach or borrow it. The system proxy and the environment are never modified.',
162
- trafficTitle: 'Live traffic through this plugin’s rule core',
243
+ importLabel: 'Label (optional)',
244
+ importFile: 'Import file',
245
+ import: 'Import',
246
+ cancel: 'Cancel',
247
+ emptyProfiles: 'No profiles yet. Use "Add profile" in the corner to import one.',
248
+ current: 'Current',
249
+ fileSource: 'Local file',
250
+ updatedAt: 'Updated',
251
+ never: 'never',
252
+ use: 'Use',
253
+ update: 'Update',
254
+ remove: 'Remove',
255
+ autoUpdate: 'Auto update',
256
+ autoUpdateOn: 'Auto update · every %s h',
257
+ trafficQuota: 'Traffic',
258
+
259
+ nodes: 'Nodes',
260
+ nodesHint: 'Click a node to use it; "Auto select" picks by latency',
261
+ testAll: 'Test all',
262
+ testing: 'Testing…',
263
+ ms: '%s ms',
264
+ timeout: 'Timeout',
265
+ untested: 'untested',
266
+ needsNative: 'needs connector',
267
+ nodeType: 'Type',
268
+
269
+ download: 'Download',
270
+ downloadDir: 'Download folder',
271
+ downloadDirHint: 'Empty = the DSH downloads folder',
272
+ threads: 'Threads',
273
+ threadsHint: 'Concurrent chunks when the server supports ranges; 1 = single stream',
274
+ maxMb: 'Max file size (MB)',
275
+ dirPlaceholder: 'Empty uses the default folder',
276
+
277
+ advanced: 'Advanced',
278
+ advancedHint: 'Reveal every tunable option',
279
+ advUpstream: 'Upstream and subscription',
280
+ proxyUrl: 'Fixed proxy URL',
281
+ proxyUrlHint: 'Explicit upstream, http:// or socks5://; when set, the subscription is unused',
282
+ subscriptionUrl: 'Subscription URL (legacy)',
283
+ subscriptionUrlHint: 'Only used when no "node profile" exists',
284
+ fetchProxyUrl: 'Proxy for fetching the subscription',
285
+ fetchProxyUrlHint: 'Only when the subscription itself is blocked; must be an http(s):// proxy',
286
+ groupType: 'Group type',
287
+ preferredNode: 'Preferred node',
288
+ preferredNodeHint: 'Pin one node by name; highest priority',
289
+ autoUpdateHours: 'Subscription auto update (h)',
290
+ latencyTestUrl: 'Latency test URL',
291
+ latencyTimeoutMs: 'Latency timeout (ms)',
292
+ advRouting: 'Routing',
293
+ domesticDirect: 'Domestic direct',
294
+ domesticDirectHint: 'Turn off to proxy every target',
295
+ protectAiPlatforms: 'Pin domestic AI platforms direct',
296
+ protectAiPlatformsHint: 'Highest priority DIRECT, so a flapping proxy cannot break the session',
297
+ extraDirectDomains: 'Extra direct domains',
298
+ extraDirectDomainsHint: 'Comma or newline separated',
299
+ extraRules: 'Extra rules',
300
+ extraRulesHint: 'One per line, e.g. DOMAIN-SUFFIX,example.com,DIRECT',
301
+ excludeRules: 'Excluded rules',
302
+ excludeRulesHint: 'One per line, substring match against subscription rules',
303
+ advEngine: 'Download engine',
304
+ maxRedirects: 'Max redirects',
305
+ stallTimeoutS: 'Stall timeout (s)',
306
+ downloadTimeoutS: 'Total budget (s)',
307
+ insecureTls: 'Skip TLS verification',
308
+ insecureTlsHint: 'Only for enterprise TLS inspection',
309
+ allowOutsideWorkspace: 'Write outside the workspace',
310
+ allowOutsideWorkspaceHint: 'Otherwise only the workspace and the download folder',
311
+ guardShellDownloads: 'Block unproxied overseas shell downloads',
312
+ guardShellDownloadsHint: 'When blocked, re-run through dsh_run_proxied',
313
+ advHarness: 'Harness egress',
314
+ harnessProxy: 'Expose the core to the harness',
315
+ harnessProxyHint: 'Also route web_fetch / shell children / MCP HTTP. Written to $DSH_HOME/.env; restart DSH',
316
+ harnessProxyPort: 'Fixed port',
317
+ harnessProxyPortHint: 'Must be fixed because .env pins it; falls back to a random port when taken',
318
+
319
+ guard: 'Session guard',
320
+ guardHint: 'Write the AI platform domains into NO_PROXY so a dead proxy cannot drop the session',
321
+ guardCovered: '%s domains covered',
322
+ guardMissing: '%s missing',
323
+ guardApply: 'Write',
324
+ guardRestore: 'Restore',
325
+ guardNoVars: 'That file has no HTTP_PROXY yet, so this has no effect',
326
+ guardRestart: 'Restart DSH to apply',
327
+
328
+ traffic: 'Traffic',
163
329
  trafficUp: 'Up',
164
330
  trafficDown: 'Down',
165
331
  trafficTotal: 'total',
166
- unavailable: 'Settings cannot be persisted in this environment; changes apply to this page only.'
332
+ trafficIdle: 'The core is not running, so there is no traffic',
333
+
334
+ unavailable: 'Settings cannot be persisted here; changes apply to this page only.',
335
+ scopeNote: 'The core binds 127.0.0.1 and requires this process token, so other programs cannot borrow it.',
336
+ about: 'About',
337
+ dataDir: 'Data folder',
338
+ saved: 'Saved',
339
+ failed: 'failed'
167
340
  }
168
341
 
169
342
  // ------------------------------------------------------------------
170
- // 工具函数
343
+ // 小工具
171
344
  // ------------------------------------------------------------------
172
345
  function formatBytes(value) {
173
- var n = Number(value) || 0
346
+ var n = Number(value)
347
+ if (!isFinite(n) || n <= 0) return '0 B'
174
348
  var units = ['B', 'KB', 'MB', 'GB', 'TB']
175
349
  var i = 0
176
- while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
177
- return (i === 0 ? n : n.toFixed(n >= 100 ? 0 : n >= 10 ? 1 : 2)) + ' ' + units[i]
350
+ while (n >= 1024 && i < units.length - 1) { n = n / 1024; i += 1 }
351
+ return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + ' ' + units[i]
352
+ }
353
+
354
+ function formatRate(value) {
355
+ var n = Number(value)
356
+ if (!isFinite(n) || n <= 0) return '0 B/s'
357
+ return formatBytes(n) + '/s'
358
+ }
359
+
360
+ function formatRateTiny(value) {
361
+ var n = Number(value)
362
+ if (!isFinite(n) || n <= 0) return '0'
363
+ if (n < 1024) return Math.round(n) + 'B'
364
+ if (n < 1024 * 1024) return Math.round(n / 1024) + 'K'
365
+ return (n / 1024 / 1024).toFixed(1) + 'M'
178
366
  }
179
367
 
180
- function formatTime(ts) {
181
- if (ts === null || ts === undefined) return '—'
182
- try { return new Date(ts).toLocaleString() } catch (e) { return String(ts) }
368
+ function formatTime(value) {
369
+ if (!value) return ''
370
+ try {
371
+ var d = new Date(value)
372
+ if (isNaN(d.getTime())) return ''
373
+ var pad = function (n) { return (n < 10 ? '0' : '') + n }
374
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
375
+ } catch (e) { return '' }
183
376
  }
184
377
 
185
- /** 速率:在 formatBytes 后面加 /s。 */
186
- function formatRate(bytesPerSecond) {
187
- return formatBytes(bytesPerSecond) + '/s'
378
+ function readStore(key, fallback) {
379
+ try {
380
+ var raw = window.localStorage.getItem(key)
381
+ return raw === null ? fallback : raw
382
+ } catch (e) { return fallback }
188
383
  }
189
384
 
190
- /** 收起栏(56px)用的极简写法:12.4M / 980K。 */
191
- function formatRateTiny(bytesPerSecond) {
192
- var n = Number(bytesPerSecond) || 0
193
- if (n < 1024) return n.toFixed(0) + 'B'
194
- if (n < 1024 * 1024) return (n / 1024).toFixed(0) + 'K'
195
- if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + 'M'
196
- return (n / 1024 / 1024 / 1024).toFixed(1) + 'G'
385
+ function writeStore(key, value) {
386
+ try { window.localStorage.setItem(key, value) } catch (e) { /* ignore */ }
197
387
  }
198
388
 
199
- /** 与后端同源的请求封装(面板内外都要用,所以放在模块层)。 */
200
389
  function apiFetch(path, body) {
201
- return fetch(API_BASE + path, {
202
- method: body === undefined ? 'GET' : 'POST',
203
- headers: body === undefined ? undefined : { 'content-type': 'application/json' },
204
- body: body === undefined ? undefined : JSON.stringify(body)
205
- }).then(function (response) {
390
+ var init = { method: body === undefined ? 'GET' : 'POST' }
391
+ if (body !== undefined) {
392
+ init.headers = { 'content-type': 'application/json' }
393
+ init.body = JSON.stringify(body)
394
+ }
395
+ return fetch(API_BASE + path, init).then(function (response) {
206
396
  return response.text().then(function (text) {
207
- var payload = text.length > 0 ? JSON.parse(text) : {}
397
+ var payload = {}
398
+ if (text.length > 0) {
399
+ try { payload = JSON.parse(text) } catch (e) { payload = { error: text } }
400
+ }
208
401
  if (!response.ok) throw new Error(payload.error || ('HTTP ' + response.status))
209
402
  return payload
210
403
  })
211
404
  })
212
405
  }
213
406
 
214
- function linesToArray(text) {
215
- return String(text || '')
216
- .split(/\r?\n/)
217
- .map(function (line) { return line.trim() })
218
- .filter(function (line) { return line.length > 0 })
407
+ function cls() {
408
+ var parts = []
409
+ for (var i = 0; i < arguments.length; i += 1) {
410
+ if (arguments[i]) parts.push(arguments[i])
411
+ }
412
+ return parts.join(' ')
219
413
  }
220
414
 
221
- function arrayToLines(list) {
222
- return Array.isArray(list) ? list.join('\n') : ''
415
+ /**
416
+ * 取 locale 服务。
417
+ *
418
+ * `ctx.locale` 只在 `inject` 里声明了硬依赖 `locale` 时才存在;本插件只 inject
419
+ * `slots`,所以必须走可选访问 `ctx.get('locale')` —— 否则下面全部降级成英文,
420
+ * 而界面其它地方仍是中文(实测踩过)。
421
+ */
422
+ function localeService(ctx) {
423
+ if (!ctx) return null
424
+ try {
425
+ if (ctx.locale) return ctx.locale
426
+ } catch (e) { /* ignore */ }
427
+ try {
428
+ var service = ctx.get('locale')
429
+ if (service) return service
430
+ } catch (e) { /* ignore */ }
431
+ return null
223
432
  }
224
433
 
225
- // 状态 / 上游模式的展示名(原始值仍是英文标识符,只翻译给人看的那一份)
226
- var STATE_KEYS = { running: 'stRunning', disabled: 'stDisabled', 'no-subscription': 'stNoSubscription', failed: 'stFailed', idle: 'stIdle' }
227
- var MODE_KEYS = { explicit: 'mdExplicit', environment: 'mdEnvironment', subscription: 'mdSubscription', none: 'mdNone' }
434
+ // ------------------------------------------------------------------
435
+ // 样式(全部走 DSH 主题令牌,自动适配明暗)
436
+ // ------------------------------------------------------------------
437
+ var CSS_ID = 'dshdl-styles'
228
438
 
229
- function labelOf(map, value, translate) {
230
- var key = map[value]
231
- return key ? translate(key) : (value || '?')
439
+ function ensureCss() {
440
+ if (document.getElementById(CSS_ID)) return
441
+ var style = document.createElement('style')
442
+ style.id = CSS_ID
443
+ style.textContent = [
444
+ '.dshdl-root{display:flex;flex-direction:column;gap:22px;padding:22px 26px 40px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;}',
445
+ '.dshdl-root.dshdl-compact{padding:16px 16px 32px;gap:18px;}',
446
+ '.dshdl-head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;}',
447
+ '.dshdl-headText{flex:1 1 auto;min-width:180px;}',
448
+ '.dshdl-h1{margin:0;font-size:20px;font-weight:600;letter-spacing:.2px;}',
449
+ '.dshdl-sub{margin:3px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px;}',
450
+ '.dshdl-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}',
451
+ '.dshdl-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);}',
452
+ '.dshdl-pill.ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}',
453
+ '.dshdl-pill.err{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);}',
454
+ '.dshdl-pill.warn{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary);}',
455
+ '.dshdl-dot{width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block;}',
456
+ '.dshdl-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;cursor:pointer;}',
457
+ '.dshdl-btn:hover:not(:disabled){border-color:var(--dsw-alias-border-l2);}',
458
+ '.dshdl-btn:disabled{opacity:.5;cursor:default;}',
459
+ '.dshdl-btn.primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:#fff;}',
460
+ '.dshdl-btn.ghost{background:transparent;}',
461
+ '.dshdl-btn.danger{color:var(--dsw-alias-state-error-primary);}',
462
+ '.dshdl-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;}',
463
+ '.dshdl-seg button{padding:4px 10px;font-size:12px;background:transparent;border:0;color:var(--dsw-alias-label-secondary);cursor:pointer;}',
464
+ '.dshdl-seg button.on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}',
465
+ '.dshdl-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:16px 18px;}',
466
+ '.dshdl-cardTitle{display:flex;align-items:center;gap:10px;margin:0 0 4px;font-size:14px;font-weight:600;}',
467
+ '.dshdl-cardHint{margin:0 0 14px;color:var(--dsw-alias-label-secondary);font-size:12px;}',
468
+ '.dshdl-kv{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:12px;}',
469
+ '.dshdl-kv dt{color:var(--dsw-alias-label-secondary);}',
470
+ '.dshdl-kv dd{margin:0;word-break:break-all;}',
471
+ '.dshdl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(196px,1fr));gap:10px;}',
472
+ '.dshdl-tile{position:relative;display:flex;flex-direction:column;gap:8px;padding:12px 14px;border-radius:10px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);cursor:pointer;transition:border-color .12s ease,transform .12s ease;}',
473
+ '.dshdl-tile:hover{border-color:var(--dsw-alias-border-l2);}',
474
+ '.dshdl-tile.on{border-color:var(--dsw-alias-brand-primary);}',
475
+ '.dshdl-tile.dead{opacity:.62;}',
476
+ '.dshdl-tileName{font-size:13px;font-weight:600;padding-right:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
477
+ '.dshdl-tileMeta{font-size:11px;color:var(--dsw-alias-label-secondary);}',
478
+ '.dshdl-tileFoot{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--dsw-alias-label-secondary);}',
479
+ '.dshdl-lat{font-variant-numeric:tabular-nums;}',
480
+ '.dshdl-lat.ok{color:var(--dsw-alias-state-success-primary);}',
481
+ '.dshdl-lat.bad{color:var(--dsw-alias-state-error-primary);}',
482
+ '.dshdl-bar{height:3px;border-radius:2px;background:var(--dsw-alias-border-l1);overflow:hidden;}',
483
+ '.dshdl-bar span{display:block;height:100%;background:var(--dsw-alias-brand-primary);}',
484
+ '.dshdl-more{position:absolute;top:8px;right:8px;width:22px;height:22px;border-radius:6px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;line-height:1;}',
485
+ '.dshdl-more:hover{background:var(--dsw-alias-bg-layer-1);}',
486
+ '.dshdl-menu{position:absolute;top:32px;right:8px;z-index:5;min-width:132px;padding:4px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);box-shadow:0 8px 24px rgba(0,0,0,.28);}',
487
+ '.dshdl-menu button{display:block;width:100%;text-align:left;padding:6px 10px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font-size:12px;cursor:pointer;}',
488
+ '.dshdl-menu button:hover{background:var(--dsw-alias-bg-layer-2);}',
489
+ '.dshdl-menu button.danger{color:var(--dsw-alias-state-error-primary);}',
490
+ '.dshdl-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:9px 0;border-top:1px solid var(--dsw-alias-border-l1);}',
491
+ '.dshdl-row:first-child{border-top:0;}',
492
+ '.dshdl-rowText{flex:1 1 auto;min-width:0;}',
493
+ '.dshdl-rowLabel{font-size:12.5px;}',
494
+ '.dshdl-rowHint{margin-top:2px;font-size:11px;color:var(--dsw-alias-label-secondary);}',
495
+ '.dshdl-rowCtl{flex:0 0 auto;display:flex;align-items:center;gap:8px;}',
496
+ '.dshdl-input,.dshdl-textarea,.dshdl-select{width:100%;min-width:140px;padding:6px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit;}',
497
+ '.dshdl-textarea{min-height:58px;resize:vertical;}',
498
+ '.dshdl-input:focus,.dshdl-textarea:focus,.dshdl-select:focus{outline:none;border-color:var(--dsw-alias-brand-primary);}',
499
+ '.dshdl-row.vertical{display:block;}',
500
+ '.dshdl-row.vertical .dshdl-rowCtl{margin-top:6px;}',
501
+ '.dshdl-switch{position:relative;width:36px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);cursor:pointer;flex:0 0 auto;}',
502
+ '.dshdl-switch span{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-secondary);transition:transform .14s ease;}',
503
+ '.dshdl-switch.on{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);}',
504
+ '.dshdl-switch.on span{transform:translateX(16px);background:#fff;}',
505
+ '.dshdl-traffic{display:flex;gap:26px;flex-wrap:wrap;}',
506
+ '.dshdl-tItem{min-width:110px;}',
507
+ '.dshdl-tLabel{font-size:11px;color:var(--dsw-alias-label-secondary);}',
508
+ '.dshdl-tValue{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums;}',
509
+ '.dshdl-tSum{font-size:11px;color:var(--dsw-alias-label-secondary);}',
510
+ '.dshdl-inlineForm{display:grid;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1);}',
511
+ '.dshdl-inlineRow{display:flex;gap:8px;flex-wrap:wrap;}',
512
+ '.dshdl-inlineRow .dshdl-input{flex:1 1 200px;}',
513
+ '.dshdl-note{padding:10px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:12px;}',
514
+ '.dshdl-err{padding:8px 12px;border-radius:8px;border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:12px;}',
515
+ '.dshdl-empty{padding:22px 0;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px;}',
516
+ '.dshdl-trafFoot{display:flex;flex-direction:column;gap:1px;font-size:11px;line-height:1.25;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;}',
517
+ '.dshdl-trafFoot .row{display:flex;align-items:center;gap:5px;}',
518
+ '.dshdl-trafRail{display:flex;flex-direction:column;align-items:center;font-size:10px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;}'
519
+ ].join('\n')
520
+ document.head.appendChild(style)
232
521
  }
233
522
 
234
523
  // ------------------------------------------------------------------
235
- // 样式(沿用 DSH 语义化 CSS 变量,自动适配明暗主题)
524
+ // 基础组件
236
525
  // ------------------------------------------------------------------
237
- var CSS = [
238
- '.dshdl{display:flex;flex-direction:column;gap:16px}',
239
- '.dshdl-title{font-size:15px;font-weight:600;margin:0}',
240
- '.dshdl-desc{font-size:12.5px;line-height:19px;color:var(--dsw-alias-label-secondary,#5b616b);margin:0}',
241
- '.dshdl-card{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;background:var(--dsw-alias-bg-layer-2,transparent)}',
242
- '.dshdl-cardHead{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}',
243
- '.dshdl-cardTitle{font-size:13px;font-weight:600}',
244
- '.dshdl-badges{display:flex;gap:6px;flex-wrap:wrap}',
245
- '.dshdl-badge{font-size:11.5px;line-height:16px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.14));color:var(--dsw-alias-label-secondary,#5b616b)}',
246
- '.dshdl-badge.dshdl-ok{border-color:var(--dsw-alias-state-success-primary,#2fa95e);color:var(--dsw-alias-state-success-primary,#2fa95e)}',
247
- '.dshdl-badge.dshdl-warn{border-color:var(--dsw-alias-state-warn-primary,#d9822b);color:var(--dsw-alias-state-warn-primary,#d9822b)}',
248
- '.dshdl-badge.dshdl-err{border-color:var(--dsw-alias-state-error-primary,#d64545);color:var(--dsw-alias-state-error-primary,#d64545)}',
249
- '.dshdl-row{display:grid;grid-template-columns:minmax(140px,220px) 1fr;gap:10px;align-items:center}',
250
- '.dshdl-label{font-size:12.5px;color:var(--dsw-alias-label-secondary,#5b616b)}',
251
- '.dshdl-hint{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#8a8f98);grid-column:2}',
252
- '.dshdl-input,.dshdl-select,.dshdl-area{width:100%;box-sizing:border-box;font:inherit;font-size:12.5px;padding:5px 8px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.16));background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary,inherit)}',
253
- '.dshdl-area{min-height:62px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
254
- '.dshdl-check{width:15px;height:15px}',
255
- '.dshdl-actions{display:flex;gap:8px;flex-wrap:wrap}',
256
- '.dshdl-btn{font:inherit;font-size:12.5px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.16));background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary,inherit);cursor:pointer}',
257
- '.dshdl-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}',
258
- '.dshdl-btn:disabled{opacity:.5;cursor:default}',
259
- '.dshdl-btn.dshdl-small{font-size:11.5px;padding:3px 8px}',
260
- '.dshdl-nodes{max-height:280px;overflow:auto;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:8px}',
261
- '.dshdl-node{display:flex;align-items:center;gap:8px;padding:5px 9px;font-size:12px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.06))}',
262
- '.dshdl-node:last-child{border-bottom:none}',
263
- '.dshdl-node.dshdl-active{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}',
264
- '.dshdl-nodeName{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
265
- '.dshdl-nodeMeta{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11.5px;white-space:nowrap}',
266
- '.dshdl-error{font-size:12px;color:var(--dsw-alias-state-error-primary,#d64545);white-space:pre-wrap;word-break:break-word}',
267
- '.dshdl-warn{font-size:12px;color:var(--dsw-alias-state-warn-primary,#d9822b)}',
268
- '.dshdl-empty{font-size:12px;color:var(--dsw-alias-label-tertiary,#8a8f98);padding:8px 2px}',
269
- '.dshdl-traf{display:flex;flex-direction:column;gap:1px;padding:2px 4px;font-variant-numeric:tabular-nums;line-height:1.25}',
270
- '.dshdl-trafRow{display:flex;align-items:baseline;gap:4px;font-size:11px;white-space:nowrap}',
271
- '.dshdl-trafArrow{font-size:10px;width:9px;text-align:center;color:var(--dsw-alias-label-tertiary,#8a8f98)}',
272
- '.dshdl-trafArrow.up{color:var(--dsw-alias-state-warning-primary,#e0a33e)}',
273
- '.dshdl-trafArrow.down{color:var(--dsw-alias-state-business-primary,#4c7ef3)}',
274
- '.dshdl-trafRate{font-weight:600;color:var(--dsw-alias-label-primary,inherit)}',
275
- '.dshdl-trafSum{margin-left:auto;font-size:10px;color:var(--dsw-alias-label-tertiary,#8a8f98)}',
276
- '.dshdl-trafRail{display:flex;flex-direction:column;align-items:center;gap:0;font-variant-numeric:tabular-nums;line-height:1.1}',
277
- '.dshdl-trafRailArrow{font-size:9px;color:var(--dsw-alias-state-business-primary,#4c7ef3)}',
278
- '.dshdl-trafRailRate{font-size:9px;font-weight:600;color:var(--dsw-alias-label-secondary,#8a8f98)}',
279
- '.dshdl-importRow{display:flex;flex-direction:column;gap:6px}',
280
- '.dshdl-fileBtn{display:inline-flex;align-items:center}'
281
- ].join('')
526
+ function h() { return React.createElement.apply(null, arguments) }
282
527
 
283
- function ensureCss() {
284
- try {
285
- if (document.getElementById('dsh-downloader-style')) return
286
- var el = document.createElement('style')
287
- el.id = 'dsh-downloader-style'
288
- el.textContent = CSS
289
- document.head.appendChild(el)
290
- } catch (e) { /* ignore */ }
528
+ function Pill(props) {
529
+ var tone = props.tone === 'ok' ? ' ok' : props.tone === 'err' ? ' err' : props.tone === 'warn' ? ' warn' : ''
530
+ return h('span', { className: 'dshdl-pill' + tone, title: props.title || '' },
531
+ props.tone ? h('span', { className: 'dshdl-dot' }) : null,
532
+ props.children)
533
+ }
534
+
535
+ function Btn(props) {
536
+ return h('button', {
537
+ type: 'button',
538
+ className: cls('dshdl-btn', props.variant === 'primary' ? 'primary' : props.variant === 'ghost' ? 'ghost' : props.variant === 'danger' ? 'danger' : ''),
539
+ disabled: props.disabled === true,
540
+ onClick: props.onClick,
541
+ title: props.title || ''
542
+ }, props.children)
543
+ }
544
+
545
+ function Switch(props) {
546
+ return h('button', {
547
+ type: 'button',
548
+ className: cls('dshdl-switch', props.value ? 'on' : ''),
549
+ role: 'switch',
550
+ 'aria-checked': props.value ? 'true' : 'false',
551
+ 'aria-label': props.label || '',
552
+ disabled: props.disabled === true,
553
+ onClick: function () { if (props.onChange) props.onChange(!props.value) }
554
+ }, h('span', null))
555
+ }
556
+
557
+ function Card(props) {
558
+ return h('section', { className: 'dshdl-card' },
559
+ h('h3', { className: 'dshdl-cardTitle' },
560
+ props.title,
561
+ props.titleExtra || null),
562
+ props.hint ? h('p', { className: 'dshdl-cardHint' }, props.hint) : null,
563
+ props.children)
564
+ }
565
+
566
+ /** 一行设置项:左标签+说明,右控件。 */
567
+ function Row(props) {
568
+ return h('div', { className: cls('dshdl-row', props.vertical ? 'vertical' : '') },
569
+ h('div', { className: 'dshdl-rowText' },
570
+ h('div', { className: 'dshdl-rowLabel' }, props.label),
571
+ props.hint ? h('div', { className: 'dshdl-rowHint' }, props.hint) : null),
572
+ h('div', { className: 'dshdl-rowCtl' }, props.children))
573
+ }
574
+
575
+ /** 文本输入:本地草稿,失焦/回车才提交,避免每敲一个字就写配置。 */
576
+ function TextRow(props) {
577
+ var state = React.useState(props.value === undefined ? '' : String(props.value))
578
+ var draft = state[0]
579
+ var setDraft = state[1]
580
+ React.useEffect(function () { setDraft(props.value === undefined ? '' : String(props.value)) }, [props.value])
581
+ function commit() {
582
+ if (props.onCommit && String(props.value === undefined ? '' : props.value) !== draft) props.onCommit(draft)
583
+ }
584
+ return h(Row, { label: props.label, hint: props.hint },
585
+ h('input', {
586
+ className: 'dshdl-input',
587
+ type: props.type || 'text',
588
+ value: draft,
589
+ placeholder: props.placeholder || '',
590
+ onChange: function (e) { setDraft(e.target.value) },
591
+ onBlur: commit,
592
+ onKeyDown: function (e) { if (e.key === 'Enter') { commit(); e.currentTarget.blur() } }
593
+ }))
594
+ }
595
+
596
+ /** 多行列表(规则类)用逗号/换行编辑。 */
597
+ function ListRow(props) {
598
+ var value = Array.isArray(props.value) ? props.value.join('\n') : ''
599
+ var state = React.useState(value)
600
+ var draft = state[0]
601
+ var setDraft = state[1]
602
+ React.useEffect(function () { setDraft(Array.isArray(props.value) ? props.value.join('\n') : '') }, [props.value])
603
+ function commit() {
604
+ var next = draft.split(/[\n,]+/).map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 })
605
+ if (props.onCommit) props.onCommit(next)
606
+ }
607
+ return h(Row, { label: props.label, hint: props.hint, vertical: true },
608
+ h('textarea', {
609
+ className: 'dshdl-textarea',
610
+ value: draft,
611
+ placeholder: props.placeholder || '',
612
+ onChange: function (e) { setDraft(e.target.value) },
613
+ onBlur: commit
614
+ }))
615
+ }
616
+
617
+ function SelectRow(props) {
618
+ return h(Row, { label: props.label, hint: props.hint },
619
+ h('select', {
620
+ className: 'dshdl-select',
621
+ value: props.value,
622
+ onChange: function (e) { if (props.onCommit) props.onCommit(e.target.value) }
623
+ }, props.options.map(function (option) {
624
+ return h('option', { key: option.value, value: option.value }, option.label)
625
+ })))
291
626
  }
292
627
 
293
628
  // ------------------------------------------------------------------
294
- // 面板
629
+ // 语言:自动(跟随 DSH / 系统)+ 手动覆盖,只作用于本插件页面
295
630
  // ------------------------------------------------------------------
296
- function Field(props) {
297
- var label = props.label
298
- var hint = props.hint
299
- var value = props.value
300
- var onChange = props.onChange
301
- var onCommit = props.onCommit
302
- var type = props.type || 'text'
303
- var disabled = !!props.disabled
304
-
305
- var control
306
- if (type === 'bool') {
307
- control = React.createElement('input', {
308
- className: 'dshdl-check',
309
- type: 'checkbox',
310
- checked: !!value,
311
- disabled: disabled,
312
- onChange: function (e) { onChange(e.target.checked) }
313
- })
314
- } else if (type === 'select') {
315
- control = React.createElement('select', {
316
- className: 'dshdl-select',
317
- value: value,
318
- disabled: disabled,
319
- onChange: function (e) { onChange(e.target.value); if (onCommit) onCommit(e.target.value) }
320
- }, (props.options || []).map(function (option) {
321
- return React.createElement('option', { key: option.value, value: option.value }, option.label)
322
- }))
323
- } else if (type === 'textarea') {
324
- control = React.createElement('textarea', {
325
- className: 'dshdl-area',
326
- value: value,
327
- disabled: disabled,
328
- spellCheck: false,
329
- onChange: function (e) { onChange(e.target.value) },
330
- onBlur: function () { if (onCommit) onCommit(value) }
331
- })
332
- } else {
333
- control = React.createElement('input', {
334
- className: 'dshdl-input',
335
- type: type === 'number' ? 'number' : 'text',
336
- value: value,
337
- disabled: disabled,
338
- spellCheck: false,
339
- onChange: function (e) { onChange(e.target.value) },
340
- onBlur: function () { if (onCommit) onCommit(value) },
341
- onKeyDown: function (e) { if (e.key === 'Enter' && onCommit) onCommit(value) }
631
+ function useLocaleSelection(ctxRef) {
632
+ var mode = React.useState(function () { return readStore(LS_LANG, 'auto') })
633
+ var choice = mode[0]
634
+ var setChoice = mode[1]
635
+ // DSH 的语言快照变化时重渲染(auto 模式据此跟随系统)
636
+ var revision = React.useSyncExternalStore(
637
+ function (listener) {
638
+ var service = localeService(ctxRef.current)
639
+ try { return service.subscribe(listener) } catch (e) { return function () {} }
640
+ },
641
+ function () {
642
+ var service = localeService(ctxRef.current)
643
+ try { return service.getSnapshot().revision } catch (e) { return 0 }
342
644
  })
645
+ var active = 'en'
646
+ var service = localeService(ctxRef.current)
647
+ try {
648
+ var snap = service.getLocale()
649
+ active = snap && snap.active ? String(snap.active) : 'en'
650
+ } catch (e) { active = 'en' }
651
+
652
+ var resolved = choice === 'auto' ? (/^zh/i.test(active) ? 'zh' : 'en') : choice
653
+ var dict = resolved === 'zh' ? LOCALE_ZH : LOCALE_EN
654
+ var t = React.useCallback(function (key, arg) {
655
+ var text = dict[key]
656
+ if (text === undefined) text = LOCALE_EN[key]
657
+ if (text === undefined) text = key
658
+ return arg === undefined ? text : String(text).replace('%s', String(arg))
659
+ }, [resolved, revision])
660
+
661
+ function pick(next) {
662
+ setChoice(next)
663
+ writeStore(LS_LANG, next)
664
+ }
665
+
666
+ return { t: t, choice: choice, pick: pick, resolved: resolved }
667
+ }
668
+
669
+ function LocaleSwitch(props) {
670
+ var t = props.t
671
+ function item(value, label) {
672
+ return h('button', {
673
+ key: value,
674
+ type: 'button',
675
+ className: props.choice === value ? 'on' : '',
676
+ onClick: function () { props.pick(value) }
677
+ }, label)
678
+ }
679
+ return h('div', { className: 'dshdl-seg', title: props.title || '' },
680
+ item('auto', t('langAuto')),
681
+ item('zh', t('langZh')),
682
+ item('en', t('langEn')))
683
+ }
684
+
685
+ // ------------------------------------------------------------------
686
+ // 状态轮询
687
+ // ------------------------------------------------------------------
688
+ function useStatus(pollMs) {
689
+ var tuple = React.useState(null)
690
+ var status = tuple[0]
691
+ var setStatus = tuple[1]
692
+ var errTuple = React.useState(null)
693
+ var error = errTuple[0]
694
+ var setError = errTuple[1]
695
+ var alive = React.useRef(true)
696
+
697
+ var reload = React.useCallback(function () {
698
+ return apiFetch('status')
699
+ .then(function (payload) { if (alive.current) { setStatus(payload); setError(null) } return payload })
700
+ .catch(function (e) { if (alive.current) setError(String((e && e.message) || e)) })
701
+ }, [])
702
+
703
+ React.useEffect(function () {
704
+ alive.current = true
705
+ reload()
706
+ if (!pollMs) return function () { alive.current = false }
707
+ var timer = setInterval(function () { if (!document.hidden) reload() }, pollMs)
708
+ return function () { alive.current = false; clearInterval(timer) }
709
+ }, [reload, pollMs])
710
+
711
+ return { status: status, error: error, reload: reload }
712
+ }
713
+
714
+ function useTraffic(pollMs) {
715
+ var tuple = React.useState(null)
716
+ var traffic = tuple[0]
717
+ var setTraffic = tuple[1]
718
+ var alive = React.useRef(true)
719
+ React.useEffect(function () {
720
+ alive.current = true
721
+ function tick() {
722
+ apiFetch('traffic')
723
+ .then(function (payload) { if (alive.current) setTraffic(payload) })
724
+ .catch(function () { if (alive.current) setTraffic(null) })
725
+ }
726
+ tick()
727
+ var timer = setInterval(function () { if (!document.hidden) tick() }, pollMs)
728
+ return function () { alive.current = false; clearInterval(timer) }
729
+ }, [pollMs])
730
+ return traffic
731
+ }
732
+
733
+ // ------------------------------------------------------------------
734
+ // 节点配置卡片(对齐 FlClash 的「配置」页)
735
+ // ------------------------------------------------------------------
736
+ function ProfileTile(props) {
737
+ var profile = props.profile
738
+ var t = props.t
739
+ var openState = React.useState(false)
740
+ var open = openState[0]
741
+ var setOpen = openState[1]
742
+
743
+ var info = profile.subscriptionInfo || null
744
+ var used = null
745
+ var total = null
746
+ if (info) {
747
+ var upSum = Number(info.upload)
748
+ var downSum = Number(info.download)
749
+ var totalSum = Number(info.total)
750
+ if (isFinite(upSum) || isFinite(downSum)) used = (isFinite(upSum) ? upSum : 0) + (isFinite(downSum) ? downSum : 0)
751
+ if (isFinite(totalSum) && totalSum > 0) total = totalSum
752
+ }
753
+ var percent = null
754
+ if (used !== null && total !== null) {
755
+ percent = Math.max(0, Math.min(100, Math.round((used / total) * 100)))
756
+ }
757
+
758
+ function act(name) {
759
+ setOpen(false)
760
+ props.onAction(name, profile)
761
+ }
762
+
763
+ return h('div', {
764
+ className: cls('dshdl-tile', profile.current ? 'on' : ''),
765
+ onClick: function () { if (!open) act('select') },
766
+ title: profile.label
767
+ },
768
+ h('button', {
769
+ className: 'dshdl-more',
770
+ type: 'button',
771
+ 'aria-label': 'menu',
772
+ onClick: function (e) { e.stopPropagation(); setOpen(!open) }
773
+ }, '⋯'),
774
+ h('div', { className: 'dshdl-tileName' }, profile.label),
775
+ percent === null
776
+ ? h('div', { className: 'dshdl-bar' }, h('span', { style: { width: profile.current ? '100%' : '0%' } }))
777
+ : h('div', { className: 'dshdl-bar' }, h('span', { style: { width: percent + '%' } })),
778
+ h('div', { className: 'dshdl-tileMeta' },
779
+ percent === null
780
+ ? (profile.source === 'file' ? t('fileSource') : (profile.autoUpdate ? t('autoUpdateOn', profile.autoUpdateHours || 24) : t('upstream')))
781
+ : (formatBytes(used) + ' / ' + formatBytes(total))),
782
+ h('div', { className: 'dshdl-tileFoot' },
783
+ h('span', null, profile.current ? t('current') : (profile.source === 'file' ? t('fileSource') : '')),
784
+ h('span', null, profile.lastUpdateAt ? (t('updatedAt') + ' ' + formatTime(profile.lastUpdateAt)) : t('never'))),
785
+ open ? h('div', { className: 'dshdl-menu', onClick: function (e) { e.stopPropagation() } },
786
+ h('button', { type: 'button', onClick: function () { act('select') } }, t('use')),
787
+ profile.source === 'url' ? h('button', { type: 'button', onClick: function () { act('update') } }, t('update')) : null,
788
+ h('button', { type: 'button', onClick: function () { act('auto') } }, t('autoUpdate')),
789
+ h('button', { className: 'danger', type: 'button', onClick: function () { act('remove') } }, t('remove'))
790
+ ) : null)
791
+ }
792
+
793
+ // ------------------------------------------------------------------
794
+ // 节点卡片(对齐 FlClash 的「代理」页)
795
+ // ------------------------------------------------------------------
796
+ function NodeTile(props) {
797
+ var node = props.node
798
+ var t = props.t
799
+ var delay = node.delay
800
+ var latClass = 'dshdl-lat'
801
+ var latText = t('untested')
802
+ if (delay !== null && delay !== undefined) {
803
+ latClass = delay > 0 ? 'dshdl-lat ok' : 'dshdl-lat bad'
804
+ latText = delay > 0 ? t('ms', delay) : t('timeout')
343
805
  }
344
- return React.createElement('div', { className: 'dshdl-row' },
345
- React.createElement('span', { className: 'dshdl-label' }, label),
346
- control,
347
- hint ? React.createElement('span', { className: 'dshdl-hint' }, hint) : null)
806
+ return h('div', {
807
+ className: cls('dshdl-tile', props.active ? 'on' : '', delay === 0 ? 'dead' : ''),
808
+ onClick: props.onClick,
809
+ title: (node.server || node.name) + (node.needsNative ? ' · ' + t('needsNative') : '')
810
+ },
811
+ h('div', { className: 'dshdl-tileName' }, node.name),
812
+ h('div', { className: 'dshdl-tileMeta' }, node.type),
813
+ h('div', { className: 'dshdl-tileFoot' }, h('span', { className: latClass }, latText)))
814
+ }
815
+
816
+ function AutoTile(props) {
817
+ return h('div', {
818
+ className: cls('dshdl-tile', props.active ? 'on' : ''),
819
+ onClick: props.onClick
820
+ },
821
+ h('div', { className: 'dshdl-tileName' }, props.t('nodeAuto')),
822
+ h('div', { className: 'dshdl-tileMeta' }, props.t('upstream')),
823
+ h('div', { className: 'dshdl-tileFoot' }, h('span', { className: 'dshdl-lat' }, props.current || '')))
348
824
  }
349
825
 
350
- function DownloaderSection(props) {
826
+ // ------------------------------------------------------------------
827
+ // 主页面:两个入口共用
828
+ // ------------------------------------------------------------------
829
+ function DownloaderPage(props) {
830
+ var ctxRef = props.ctxRef
831
+ var variant = props.variant || 'panel'
832
+ var compact = variant === 'settings'
351
833
  var scope = props.scope
352
- var t = props.t || function (key) { return key }
353
834
 
354
- // scope.subscribe / getSnapshot 是类原型方法:必须包一层闭包保住 this。
835
+ var locale = useLocaleSelection(ctxRef)
836
+ var t = locale.t
837
+
838
+ var state = useStatus(compact ? 6000 : 5000)
839
+ var status = state.status
840
+ var error = state.error
841
+ var reload = state.reload
842
+ var traffic = useTraffic(2000)
843
+
844
+ var advancedState = React.useState(function () { return readStore(LS_ADVANCED, '0') === '1' })
845
+ var advanced = advancedState[0]
846
+ var setAdvanced = advancedState[1]
847
+
848
+ var nodesState = React.useState([])
849
+ var nodes = nodesState[0]
850
+ var setNodes = nodesState[1]
851
+ var groupsState = React.useState([])
852
+ var groups = groupsState[0]
853
+ var setGroups = groupsState[1]
854
+ var guardState = React.useState(null)
855
+ var guard = guardState[0]
856
+ var setGuard = guardState[1]
857
+
858
+ var busyState = React.useState(null)
859
+ var busy = busyState[0]
860
+ var setBusy = busyState[1]
861
+ var actionErrorState = React.useState(null)
862
+ var actionError = actionErrorState[0]
863
+ var setActionError = actionErrorState[1]
864
+ var noticeState = React.useState(null)
865
+ var notice = noticeState[0]
866
+ var setNotice = noticeState[1]
867
+
868
+ // 配置表单
355
869
  var snap = scope
356
870
  ? React.useSyncExternalStore(
357
871
  function (listener) { return scope.subscribe(listener) },
@@ -359,445 +873,590 @@ window.__ModuleLoader__.load({
359
873
  : { status: 'unavailable', value: undefined }
360
874
 
361
875
  var stored = Object.assign({}, DEFAULTS, (snap && snap.value) || {})
362
- var [draft, setDraft] = React.useState({})
363
- var [status, setStatus] = React.useState(null)
364
- var [nodes, setNodes] = React.useState([])
365
- var [groups, setGroups] = React.useState([])
366
- var [guard, setGuard] = React.useState(null)
367
- var [importUrl, setImportUrl] = React.useState('')
368
- var [importLabel, setImportLabel] = React.useState('')
369
- var [busy, setBusy] = React.useState(null)
370
- var [error, setError] = React.useState(null)
371
-
876
+ var draftState = React.useState({})
877
+ var draft = draftState[0]
878
+ var setDraft = draftState[1]
372
879
  var value = Object.assign({}, stored, draft)
880
+ var persistable = !!scope
373
881
 
374
- function set(field, next) {
882
+ function setField(field, next) {
375
883
  setDraft(function (prev) { var copy = Object.assign({}, prev); copy[field] = next; return copy })
376
- }
377
-
378
- function commit(field, next) {
379
884
  if (!scope) return
380
- setDraft(function (prev) { var copy = Object.assign({}, prev); delete copy[field]; return copy })
381
885
  try {
382
886
  var promise = scope.set(field, next)
383
887
  if (promise && typeof promise.catch === 'function') {
384
- promise.catch(function (e) { setError(String((e && e.message) || e)) })
888
+ promise.catch(function (e) {
889
+ setActionError(String((e && e.message) || e))
890
+ setDraft(function (prev) { var copy = Object.assign({}, prev); delete copy[field]; return copy })
891
+ })
385
892
  }
386
- } catch (e) {
387
- setError(String((e && e.message) || e))
388
- }
893
+ } catch (e) { setActionError(String((e && e.message) || e)) }
389
894
  }
390
895
 
391
- function commitBool(field, next) {
392
- set(field, next)
393
- commit(field, next)
896
+ var loadProxies = React.useCallback(function () {
897
+ return apiFetch('proxies').then(function (payload) {
898
+ setNodes(Array.isArray(payload.nodes) ? payload.nodes : [])
899
+ setGroups(Array.isArray(payload.groups) ? payload.groups : [])
900
+ return payload
901
+ }).catch(function (e) { setActionError(String((e && e.message) || e)) })
902
+ }, [])
903
+
904
+ var loadGuard = React.useCallback(function () {
905
+ return apiFetch('guard').then(function (payload) { setGuard(payload) })
906
+ .catch(function () { setGuard(null) })
907
+ }, [])
908
+
909
+ React.useEffect(function () { loadProxies() }, [loadProxies])
910
+ React.useEffect(function () { loadGuard() }, [loadGuard])
911
+
912
+ function run(key, fn) {
913
+ if (busy) return
914
+ setBusy(key)
915
+ setActionError(null)
916
+ setNotice(null)
917
+ Promise.resolve()
918
+ .then(fn)
919
+ .then(function (result) {
920
+ setBusy(null)
921
+ return reload().then(function () { return loadProxies() }).then(function () {
922
+ if (result && result.hint) setNotice(result.hint)
923
+ })
924
+ })
925
+ .catch(function (e) {
926
+ setBusy(null)
927
+ setActionError(String((e && e.message) || e))
928
+ })
929
+ }
930
+
931
+ function refreshAll() {
932
+ run('refresh', function () { return apiFetch('status?refresh=1') })
394
933
  }
395
934
 
396
- function commitNumber(field, raw) {
397
- var parsed = Number(raw)
398
- if (!isFinite(parsed)) { commit(field, stored[field]); return }
399
- commit(field, parsed)
935
+ function restartCore() {
936
+ run('restart', function () { return apiFetch('restart', {}) })
400
937
  }
401
938
 
402
- var api = React.useCallback(function (path, body) {
403
- return fetch(API_BASE + path, {
404
- method: body === undefined ? 'GET' : 'POST',
405
- headers: body === undefined ? undefined : { 'content-type': 'application/json' },
406
- body: body === undefined ? undefined : JSON.stringify(body)
407
- }).then(function (response) {
408
- return response.text().then(function (text) {
409
- var payload = text.length > 0 ? JSON.parse(text) : {}
410
- if (!response.ok) throw new Error(payload.error || ('HTTP ' + response.status))
411
- return payload
939
+ function updateSubscription() {
940
+ run('update', function () { return apiFetch('update-subscription', {}) })
941
+ }
942
+
943
+ function testAll() {
944
+ run('test', function () {
945
+ return apiFetch('delay-all', {}).then(function (result) {
946
+ var map = {}
947
+ var results = (result && result.results) || {}
948
+ Object.keys(results).forEach(function (key) { map[key] = results[key] })
949
+ setNodes(function (prev) {
950
+ return prev.map(function (node) {
951
+ if (map[node.name] === undefined) return node
952
+ var delay = map[node.name]
953
+ return Object.assign({}, node, { delay: delay, alive: delay !== null && delay > 0 })
954
+ })
955
+ })
956
+ return null
412
957
  })
413
958
  })
414
- }, [])
959
+ }
415
960
 
416
- var loadStatus = React.useCallback(function (refresh) {
417
- return api('status' + (refresh ? '?refresh=1' : ''))
418
- .then(function (payload) { setStatus(payload); setError(null) })
419
- .catch(function (e) { setError(String((e && e.message) || e)) })
420
- }, [api])
961
+ function selectNode(name) {
962
+ run('select:' + name, function () { return apiFetch('select', { name: name }) })
963
+ }
421
964
 
422
- var loadNodes = React.useCallback(function () {
423
- return api('proxies')
424
- .then(function (payload) { setNodes(payload.nodes || []); setGroups(payload.groups || []) })
425
- .catch(function () { /* status carries the error */ })
426
- }, [api])
965
+ function testNode(name) {
966
+ run('delay:' + name, function () {
967
+ return apiFetch('delay', { name: name }).then(function (result) {
968
+ setNodes(function (prev) {
969
+ return prev.map(function (node) {
970
+ if (node.name !== name) return node
971
+ return Object.assign({}, node, { delay: result.delay, alive: result.delay > 0 })
972
+ })
973
+ })
974
+ return null
975
+ })
976
+ })
977
+ }
427
978
 
428
- var loadGuard = React.useCallback(function () {
429
- return api('guard')
430
- .then(function (payload) { setGuard(payload) })
431
- .catch(function () { /* non-fatal */ })
432
- }, [api])
979
+ function profileAction(name, profile) {
980
+ if (name === 'select') return selectProfile(profile.id)
981
+ if (name === 'update') return run('p-update', function () { return apiFetch('profile-update', { id: profile.id }) })
982
+ if (name === 'remove') return run('p-remove', function () { return apiFetch('profile-remove', { id: profile.id }) })
983
+ if (name === 'auto') {
984
+ return run('p-auto', function () {
985
+ return apiFetch('profile-auto', { id: profile.id, enabled: !profile.autoUpdate, hours: profile.autoUpdateHours || 24 })
986
+ })
987
+ }
988
+ return null
989
+ }
433
990
 
434
- React.useEffect(function () {
435
- ensureCss()
436
- loadStatus(false)
437
- loadNodes()
438
- loadGuard()
439
- var statusTimer = setInterval(function () { loadStatus(false); loadGuard() }, 5000)
440
- var nodeTimer = setInterval(function () { loadNodes() }, 15000)
441
- return function () { clearInterval(statusTimer); clearInterval(nodeTimer) }
442
- }, [loadStatus, loadNodes, loadGuard])
443
-
444
- function run(label, task) {
445
- setBusy(label)
446
- setError(null)
447
- Promise.resolve()
448
- .then(task)
449
- .catch(function (e) { setError(String((e && e.message) || e)) })
450
- .then(function () { setBusy(null) })
991
+ function selectProfile(id) {
992
+ return run('p-select', function () { return apiFetch('profile-select', { id: id }) })
993
+ }
994
+
995
+ function addProfileFromUrl() {
996
+ var urlState = importUrlState[0]
997
+ var url = urlState
998
+ if (!url.trim()) return
999
+ run('p-add', function () {
1000
+ return apiFetch('profile-add-url', { value: url.trim(), label: importLabelState[0].trim() || undefined }).then(function (result) {
1001
+ setImportUrl('')
1002
+ setImportLabel('')
1003
+ setShowImport(false)
1004
+ return result
1005
+ })
1006
+ })
451
1007
  }
452
1008
 
453
- var stateBadgeClass = 'dshdl-badge'
454
- if (status) {
455
- if (status.state === 'running') stateBadgeClass += ' dshdl-ok'
456
- else if (status.state === 'failed') stateBadgeClass += ' dshdl-err'
457
- else stateBadgeClass += ' dshdl-warn'
1009
+ function addProfileFromFile(file) {
1010
+ if (!file) return
1011
+ var reader = new FileReader()
1012
+ reader.onload = function () {
1013
+ var content = String(reader.result || '')
1014
+ run('p-add-file', function () { return apiFetch('profile-add-file', { content: content, name: file.name }) })
1015
+ }
1016
+ reader.readAsText(file)
458
1017
  }
459
1018
 
460
- var profileList = status && Array.isArray(status.profiles) ? status.profiles : []
461
-
462
- var upstreamRows = [
463
- React.createElement(Field, { key: 'proxyUrl', label: 'proxyUrl', value: value.proxyUrl, type: 'text',
464
- hint: '显式上游,支持 http:// 与 socks5://。填了就不再用订阅。',
465
- onChange: function (v) { set('proxyUrl', v) }, onCommit: function (v) { commit('proxyUrl', v) } }),
466
- React.createElement(Field, { key: 'subscriptionUrl', label: 'subscriptionUrl', value: value.subscriptionUrl, type: 'text',
467
- hint: 'Clash 订阅地址。自包含内核从这里取节点,不需要本机装 Clash。',
468
- onChange: function (v) { set('subscriptionUrl', v) }, onCommit: function (v) { commit('subscriptionUrl', v) } }),
469
- React.createElement(Field, { key: 'fetchProxyUrl', label: 'fetchProxyUrl', value: value.fetchProxyUrl, type: 'text',
470
- hint: '仅用于抓订阅本身(订阅被墙时填),必须是 http(s):// 代理。',
471
- onChange: function (v) { set('fetchProxyUrl', v) }, onCommit: function (v) { commit('fetchProxyUrl', v) } }),
472
- React.createElement(Field, { key: 'groupType', label: 'groupType', value: value.groupType, type: 'select',
473
- options: [
474
- { value: 'url-test', label: 'url-test(自动最快)' },
475
- { value: 'select', label: 'select(手动)' },
476
- { value: 'fallback', label: 'fallback(失败切换)' }
477
- ],
478
- onChange: function (v) { set('groupType', v) }, onCommit: function (v) { commit('groupType', v) } }),
479
- React.createElement(Field, { key: 'preferredNode', label: 'preferredNode', value: value.preferredNode, type: 'text',
480
- hint: '按名字固定一个节点,优先级最高。',
481
- onChange: function (v) { set('preferredNode', v) }, onCommit: function (v) { commit('preferredNode', v) } }),
482
- React.createElement(Field, { key: 'autoUpdateHours', label: 'autoUpdateHours', value: value.autoUpdateHours, type: 'number',
483
- hint: '订阅自动更新间隔(小时,0 关闭)。',
484
- onChange: function (v) { set('autoUpdateHours', v) }, onCommit: function (v) { commitNumber('autoUpdateHours', v) } }),
485
- React.createElement(Field, { key: 'latencyTestUrl', label: 'latencyTestUrl', value: value.latencyTestUrl, type: 'text',
486
- onChange: function (v) { set('latencyTestUrl', v) }, onCommit: function (v) { commit('latencyTestUrl', v) } }),
487
- React.createElement(Field, { key: 'latencyTimeoutMs', label: 'latencyTimeoutMs', value: value.latencyTimeoutMs, type: 'number',
488
- onChange: function (v) { set('latencyTimeoutMs', v) }, onCommit: function (v) { commitNumber('latencyTimeoutMs', v) } })
489
- ]
490
-
491
- var rulesRows = [
492
- React.createElement(Field, { key: 'protectAiPlatforms', label: 'protectAiPlatforms', value: value.protectAiPlatforms, type: 'bool',
493
- hint: '国内 AI 平台域名(DeepSeek / 智谱 / Kimi / 通义 / 豆包 / 文心 / 混元 / 星火 / MiniMax …)强制直连,优先级高于一切规则,连"全部走代理"模式也不例外。',
494
- onChange: function (v) { commitBool('protectAiPlatforms', v) } }),
495
- React.createElement(Field, { key: 'domesticDirect', label: 'domesticDirect', value: value.domesticDirect, type: 'bool',
496
- hint: '开:境内直连、境外走代理。关:所有下载都走代理(AI 平台仍直连)。',
497
- onChange: function (v) { commitBool('domesticDirect', v) } }),
498
- React.createElement(Field, { key: 'extraDirectDomains', label: 'extraDirectDomains', value: arrayToLines(value.extraDirectDomains), type: 'textarea',
499
- hint: '每行一个域名或完整 URL,加进强制直连名单。',
500
- onChange: function (v) { set('extraDirectDomains', v) }, onCommit: function (v) { commit('extraDirectDomains', linesToArray(v)) } }),
501
- React.createElement(Field, { key: 'guardShellDownloads', label: 'guardShellDownloads', value: value.guardShellDownloads, type: 'bool',
502
- hint: '检测到 git / curl 等命令要从境外下载、又没带代理时拦下来,提示改用 dsh_run_proxied(命令里已带代理、或内核无可用出口时不拦)。',
503
- onChange: function (v) { commitBool('guardShellDownloads', v) } }),
504
- React.createElement(Field, { key: 'extraRules', label: 'extraRules', value: arrayToLines(value.extraRules), type: 'textarea',
505
- hint: '每行一条,如 DOMAIN-SUFFIX,example.com,DIRECT。',
506
- onChange: function (v) { set('extraRules', v) }, onCommit: function (v) { commit('extraRules', linesToArray(v)) } }),
507
- React.createElement(Field, { key: 'excludeRules', label: 'excludeRules', value: arrayToLines(value.excludeRules), type: 'textarea',
508
- hint: '每行一条,从订阅规则里剔除(子串匹配)。',
509
- onChange: function (v) { set('excludeRules', v) }, onCommit: function (v) { commit('excludeRules', linesToArray(v)) } })
510
- ]
511
-
512
- var downloadRows = [
513
- React.createElement(Field, { key: 'downloadDir', label: 'downloadDir', value: value.downloadDir, type: 'text',
514
- hint: '空 = $DSH_HOME/downloads。',
515
- onChange: function (v) { set('downloadDir', v) }, onCommit: function (v) { commit('downloadDir', v) } }),
516
- React.createElement(Field, { key: 'maxDownloadMb', label: 'maxDownloadMb', value: value.maxDownloadMb, type: 'number',
517
- onChange: function (v) { set('maxDownloadMb', v) }, onCommit: function (v) { commitNumber('maxDownloadMb', v) } }),
518
- React.createElement(Field, { key: 'downloadTimeoutS', label: 'downloadTimeoutS', value: value.downloadTimeoutS, type: 'number',
519
- onChange: function (v) { set('downloadTimeoutS', v) }, onCommit: function (v) { commitNumber('downloadTimeoutS', v) } }),
520
- React.createElement(Field, { key: 'stallTimeoutS', label: 'stallTimeoutS', value: value.stallTimeoutS, type: 'number',
521
- onChange: function (v) { set('stallTimeoutS', v) }, onCommit: function (v) { commitNumber('stallTimeoutS', v) } }),
522
- React.createElement(Field, { key: 'threads', label: 'threads', value: value.threads, type: 'number',
523
- hint: '多线程下载的分片连接数,1 = 单连接。服务端不支持 Range(或返回的 Range 不可信)时自动回退单连接;文件小于 1 MiB 也用单连接。',
524
- onChange: function (v) { set('threads', v) }, onCommit: function (v) { commitNumber('threads', v) } }),
525
- React.createElement(Field, { key: 'maxRedirects', label: 'maxRedirects', value: value.maxRedirects, type: 'number',
526
- onChange: function (v) { set('maxRedirects', v) }, onCommit: function (v) { commitNumber('maxRedirects', v) } }),
527
- React.createElement(Field, { key: 'insecureTls', label: 'insecureTls', value: value.insecureTls, type: 'bool',
528
- hint: '跳过 TLS 校验,仅用于企业 TLS 解密环境。',
529
- onChange: function (v) { commitBool('insecureTls', v) } }),
530
- React.createElement(Field, { key: 'allowOutsideWorkspace', label: 'allowOutsideWorkspace', value: value.allowOutsideWorkspace, type: 'bool',
531
- hint: '允许 save_path 写到工作区与下载目录之外。',
532
- onChange: function (v) { commitBool('allowOutsideWorkspace', v) } })
533
- ]
534
-
535
- var nodeRows = nodes.length === 0
536
- ? React.createElement('div', { className: 'dshdl-empty' }, '暂无节点。填好 subscriptionUrl 后点「更新订阅」。')
537
- : nodes.map(function (node) {
538
- var active = status && status.selected === node.name
539
- return React.createElement('div', { key: node.name, className: 'dshdl-node' + (active ? ' dshdl-active' : '') },
540
- React.createElement('span', { className: 'dshdl-nodeName', title: node.name }, node.name),
541
- React.createElement('span', { className: 'dshdl-nodeMeta' },
542
- (node.type || '?') + (node.needsNative ? ' · 需原生连接器' : '') + (node.delay === null ? '' : ' · ' + node.delay + 'ms')),
543
- React.createElement('button', {
544
- type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy,
545
- onClick: function () {
546
- run('delay:' + node.name, function () { return api('delay', { name: node.name }).then(loadNodes) })
547
- }
548
- }, t('test')),
549
- React.createElement('button', {
550
- type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy || active,
551
- onClick: function () {
552
- run('select:' + node.name, function () { return api('select', { name: node.name }).then(loadStatus).then(loadNodes) })
553
- }
554
- }, t('select')))
1019
+ function applyGuard() {
1020
+ run('guard', function () {
1021
+ return apiFetch('guard-apply', {}).then(function (result) {
1022
+ loadGuard()
1023
+ return { hint: t('guardRestart') }
555
1024
  })
1025
+ })
1026
+ }
556
1027
 
557
- return React.createElement('section', { className: 'dshdl' },
558
- React.createElement('h2', { className: 'dshdl-title' }, t('title')),
559
- React.createElement('p', { className: 'dshdl-desc' }, t('intro')),
560
-
561
- React.createElement('div', { className: 'dshdl-card' },
562
- React.createElement('div', { className: 'dshdl-cardHead' },
563
- React.createElement('span', { className: 'dshdl-cardTitle' }, t('upstream')),
564
- React.createElement('div', { className: 'dshdl-badges' },
565
- React.createElement('span', { className: stateBadgeClass }, status ? labelOf(STATE_KEYS, status.state, t) : '…'),
566
- React.createElement('span', { className: 'dshdl-badge' }, status ? labelOf(MODE_KEYS, status.mode, t) : t('mdNone')),
567
- React.createElement('span', { className: 'dshdl-badge' }, status && status.port > 0 ? ('127.0.0.1:' + status.port) : t('notRunning')),
568
- React.createElement('span', { className: 'dshdl-badge' }, (t('nodesBadge') || '节点') + ' ' + (status ? status.nodeCount : 0)))),
569
- status ? React.createElement('p', { className: 'dshdl-desc' }, status.reason) : null,
570
- status && status.scope ? React.createElement('p', { className: 'dshdl-desc' }, t('scopeIntro')) : null,
571
- React.createElement('div', { className: 'dshdl-actions' },
572
- React.createElement('button', { type: 'button', className: 'dshdl-btn', disabled: !!busy,
573
- onClick: function () { run('refresh', function () { return loadStatus(true).then(loadNodes) }) } }, t('refresh')),
574
- React.createElement('button', { type: 'button', className: 'dshdl-btn', disabled: !!busy,
575
- onClick: function () { run('restart', function () { return api('restart', {}).then(loadStatus).then(loadNodes) }) } }, t('restart')),
576
- React.createElement('button', { type: 'button', className: 'dshdl-btn', disabled: !!busy,
577
- onClick: function () { run('update-subscription', function () { return api('update-subscription', {}).then(loadStatus).then(loadNodes) }) } }, t('updateSubscription'))),
578
- React.createElement(Field, { key: 'enabled', label: 'enabled', value: value.enabled, type: 'bool',
579
- hint: '关闭后下载工具直接失败,不再解析上游。',
580
- onChange: function (v) { commitBool('enabled', v) } }),
581
- upstreamRows,
582
- status && status.lastSubscriptionUpdate
583
- ? React.createElement('p', { className: 'dshdl-desc' }, '订阅更新时间:' + formatTime(status.lastSubscriptionUpdate))
584
- : null,
585
- status && status.nativeNodes > 0 && !status.nativeConnector
586
- ? React.createElement('p', { className: 'dshdl-warn' }, '有 ' + status.nativeNodes + ' 个节点需要可选的 Go 原生连接器(hysteria2 / reality),当前构建未打包,这些节点会被跳过。')
587
- : null),
588
-
589
- React.createElement('div', { className: 'dshdl-card' },
590
- React.createElement('div', { className: 'dshdl-cardHead' },
591
- React.createElement('span', { className: 'dshdl-cardTitle' }, t('profiles') + ' (' + profileList.length + ')')),
592
- React.createElement('p', { className: 'dshdl-desc' }, t('profilesIntro')),
593
- profileList.length === 0
594
- ? React.createElement('div', { className: 'dshdl-empty' }, t('noProfiles'))
595
- : React.createElement('div', { className: 'dshdl-nodes' }, profileList.map(function (profile) {
596
- var info = profile.subscriptionInfo || null
597
- var traffic = ''
598
- if (info && (info.total || info.expire)) {
599
- var bits = []
600
- if (info.total) bits.push('已用 ' + formatBytes((info.upload || 0) + (info.download || 0)) + ' / ' + formatBytes(info.total))
601
- if (info.expire) bits.push('到期 ' + new Date(info.expire * 1000).toLocaleDateString())
602
- traffic = ' · ' + bits.join(' · ')
603
- }
604
- var meta = profile.source === 'file'
605
- ? t('profileFile')
606
- : (profile.autoUpdate === true ? t('profileAuto') + ' ' + profile.autoUpdateHours + 'h' : '自动更新关闭')
607
- return React.createElement('div', { key: profile.id, className: 'dshdl-node' + (profile.current ? ' dshdl-active' : '') },
608
- React.createElement('span', { className: 'dshdl-nodeName', title: profile.label }, profile.label),
609
- React.createElement('span', { className: 'dshdl-nodeMeta' }, meta + traffic),
610
- React.createElement('button', {
611
- type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy || profile.current === true,
612
- onClick: function () {
613
- run('profile-select', function () { return api('profile-select', { id: profile.id }).then(function () { return loadStatus(false) }).then(loadNodes) })
614
- }
615
- }, t('profileSelect')),
616
- profile.source === 'url'
617
- ? React.createElement('button', {
618
- type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy,
619
- onClick: function () {
620
- run('profile-update', function () { return api('profile-update', { id: profile.id }).then(function () { return loadStatus(false) }).then(loadNodes) })
621
- }
622
- }, t('profileUpdate'))
623
- : null,
624
- React.createElement('button', {
625
- type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy,
626
- onClick: function () {
627
- run('profile-remove', function () { return api('profile-remove', { id: profile.id }).then(function () { return loadStatus(false) }).then(loadNodes) })
628
- }
629
- }, t('profileRemove')))
630
- })),
631
- React.createElement('div', { className: 'dshdl-row' },
632
- React.createElement('span', { className: 'dshdl-label' }, t('importUrl')),
633
- React.createElement('div', { className: 'dshdl-importRow' },
634
- React.createElement('input', {
635
- className: 'dshdl-input', type: 'text', value: importUrl, spellCheck: false,
1028
+ function restoreGuard() {
1029
+ run('guard-restore', function () {
1030
+ return apiFetch('guard-restore', {}).then(function () { loadGuard(); return { hint: t('guardRestart') } })
1031
+ })
1032
+ }
1033
+
1034
+ // --- 派生状态 ---
1035
+ var coreState = status ? status.state : 'idle'
1036
+ var tone = 'idle'
1037
+ var stateText = t('stIdle')
1038
+ if (coreState === 'running') { tone = 'ok'; stateText = t('stRunning') }
1039
+ else if (coreState === 'disabled') { tone = 'idle'; stateText = t('stDisabled') }
1040
+ else if (coreState === 'failed') { tone = 'err'; stateText = t('stFailed') }
1041
+ else if (coreState === 'no-subscription') { tone = 'warn'; stateText = t('stNoSubscription') }
1042
+ else if (status && status.enabled === false) { tone = 'idle'; stateText = t('stDisabled') }
1043
+
1044
+ var mode = status ? status.mode : 'none'
1045
+ var modeText = mode === 'subscription' ? t('modeSubscription')
1046
+ : mode === 'explicit' ? t('modeExplicit')
1047
+ : mode === 'environment' ? t('modeEnvironment')
1048
+ : t('modeNone')
1049
+
1050
+ var profiles = (status && Array.isArray(status.profiles)) ? status.profiles : []
1051
+ var selectedNode = (status && status.selected) || null
1052
+ var currentNode = (status && status.currentNode) || null
1053
+ var running = status && status.state === 'running'
1054
+
1055
+ // --- 导入表单状态 ---
1056
+ var importUrlPair = React.useState('')
1057
+ var importUrlState = importUrlPair
1058
+ var setImportUrl = importUrlPair[1]
1059
+ var importLabelPair = React.useState('')
1060
+ var importLabelState = importLabelPair
1061
+ var setImportLabel = importLabelPair[1]
1062
+ var showImportPair = React.useState(false)
1063
+ var showImport = showImportPair[0]
1064
+ var setShowImport = showImportPair[1]
1065
+
1066
+ var header = h('header', { className: 'dshdl-head' },
1067
+ h('div', { className: 'dshdl-headText' },
1068
+ h('h2', { className: 'dshdl-h1' }, t('title')),
1069
+ h('p', { className: 'dshdl-sub' }, t('subtitle'))),
1070
+ h('div', { className: 'dshdl-actions' },
1071
+ h(Pill, { tone: tone }, stateText),
1072
+ h(LocaleSwitch, { t: t, choice: locale.choice, pick: locale.pick }),
1073
+ h(Btn, { onClick: refreshAll, disabled: busy === 'refresh' }, busy === 'refresh' ? '…' : t('refresh')),
1074
+ h(Btn, { onClick: restartCore, disabled: busy === 'restart' }, t('restartCore'))))
1075
+
1076
+ var errorNode = error ? h('div', { className: 'dshdl-err' }, error) : null
1077
+ var actionErrorNode = actionError ? h('div', { className: 'dshdl-err' }, actionError) : null
1078
+ var noticeNode = notice ? h('div', { className: 'dshdl-note' }, notice) : null
1079
+
1080
+ if (!persistable) {
1081
+ errorNode = h('div', { className: 'dshdl-err' }, t('unavailable'))
1082
+ }
1083
+
1084
+ // --- 状态卡 ---
1085
+ var statusCard = h(Card, { title: t('upstream') },
1086
+ h('dl', { className: 'dshdl-kv' },
1087
+ h('dt', null, t('upstream')),
1088
+ h('dd', null, modeText + (status && status.reason ? ' · ' + status.reason : '')),
1089
+ h('dt', null, t('corePort')),
1090
+ h('dd', null, status && status.port ? String(status.port) : t('coreNone')),
1091
+ h('dt', null, t('nodesCount')),
1092
+ h('dd', null, status ? String(status.nodeCount) + ' · ' + (status.unsupportedNodes ? String(status.unsupportedNodes) : '0') + ' ⚠' : '—'),
1093
+ h('dt', null, t('currentNode')),
1094
+ h('dd', null, currentNode || '—')),
1095
+ !running && status ? h('div', { className: 'dshdl-note', style: { marginTop: '12px' } }, t('notRunningHint')) : null)
1096
+
1097
+ // --- 节点配置卡 ---
1098
+ var profilesCard = h(Card, { title: t('profiles'), hint: t('profilesHint') },
1099
+ profiles.length === 0
1100
+ ? h('div', { className: 'dshdl-empty' }, t('emptyProfiles'))
1101
+ : h('div', { className: 'dshdl-grid' }, profiles.map(function (profile) {
1102
+ return h(ProfileTile, { key: profile.id, profile: profile, t: t, onAction: profileAction })
1103
+ })),
1104
+ showImport
1105
+ ? h('div', { className: 'dshdl-inlineForm' },
1106
+ h('div', { className: 'dshdl-inlineRow' },
1107
+ h('input', {
1108
+ className: 'dshdl-input',
636
1109
  placeholder: t('importUrlPlaceholder'),
1110
+ value: importUrlState[0],
637
1111
  onChange: function (e) { setImportUrl(e.target.value) }
638
1112
  }),
639
- React.createElement('input', {
640
- className: 'dshdl-input', type: 'text', value: importLabel, spellCheck: false,
641
- placeholder: t('importLabelPlaceholder'),
1113
+ h('input', {
1114
+ className: 'dshdl-input',
1115
+ placeholder: t('importLabel'),
1116
+ value: importLabelState[0],
642
1117
  onChange: function (e) { setImportLabel(e.target.value) }
643
- }),
644
- React.createElement('div', { className: 'dshdl-actions' },
645
- React.createElement('button', {
646
- type: 'button', className: 'dshdl-btn', disabled: !!busy || importUrl.trim().length === 0,
647
- onClick: function () {
648
- run('profile-add-url', function () {
649
- return api('profile-add-url', { value: importUrl, label: importLabel }).then(function () {
650
- setImportUrl('')
651
- setImportLabel('')
652
- return loadStatus(false)
653
- }).then(loadNodes)
654
- })
655
- }
656
- }, t('importBtn')),
657
- React.createElement('label', { className: 'dshdl-btn dshdl-fileBtn' }, t('importFile'),
658
- React.createElement('input', {
659
- type: 'file', accept: '.yaml,.yml,.txt,.conf', style: { display: 'none' },
660
- onChange: function (e) {
661
- var file = e.target.files && e.target.files[0]
662
- e.target.value = ''
663
- if (!file) return
664
- var reader = new FileReader()
665
- reader.onload = function () {
666
- run('profile-add-file', function () {
667
- return api('profile-add-file', { name: file.name, content: String(reader.result || '') }).then(function () {
668
- return loadStatus(false)
669
- }).then(loadNodes)
670
- })
671
- }
672
- reader.readAsText(file)
673
- }
674
- })))))),
675
-
676
- React.createElement('div', { className: 'dshdl-card' },
677
- React.createElement('div', { className: 'dshdl-cardHead' },
678
- React.createElement('span', { className: 'dshdl-cardTitle' }, t('sessionGuard')),
679
- React.createElement('div', { className: 'dshdl-badges' },
680
- React.createElement('span', {
681
- className: guard && guard.covered ? 'dshdl-badge dshdl-ok' : 'dshdl-badge dshdl-warn'
682
- }, guard
683
- ? (guard.covered ? t('guardCovered') : t('guardMissing') + ' ' + ((guard.missing && guard.missing.length) || 0))
684
- : '…'))),
685
- React.createElement('p', { className: 'dshdl-desc' }, t('guardIntro')),
686
- guard ? React.createElement('p', { className: 'dshdl-desc' }, guard.env_path) : null,
687
- guard && Array.isArray(guard.proxy_vars) && guard.proxy_vars.length === 0
688
- ? React.createElement('p', { className: 'dshdl-warn' }, t('guardNoProxyVars'))
689
- : null,
690
- React.createElement('div', { className: 'dshdl-actions' },
691
- React.createElement('button', {
692
- type: 'button', className: 'dshdl-btn', disabled: !!busy || !!(guard && guard.covered),
693
- onClick: function () {
694
- run('guard-apply', function () {
695
- return api('guard-apply', {}).then(function (payload) { setGuard(payload.state); return loadStatus(false) })
1118
+ })),
1119
+ h('div', { className: 'dshdl-inlineRow' },
1120
+ h(Btn, { variant: 'primary', onClick: addProfileFromUrl, disabled: busy === 'p-add' }, t('import')),
1121
+ h('label', { className: 'dshdl-btn' }, t('importFile'),
1122
+ h('input', {
1123
+ type: 'file',
1124
+ accept: '.yaml,.yml,.txt,.conf',
1125
+ style: { display: 'none' },
1126
+ onChange: function (e) { addProfileFromFile(e.target.files && e.target.files[0]) }
1127
+ })),
1128
+ h(Btn, { variant: 'ghost', onClick: function () { setShowImport(false) } }, t('cancel'))))
1129
+ : h('div', { style: { display: 'flex', justifyContent: 'flex-end', marginTop: '14px' } },
1130
+ h(Btn, { variant: 'primary', onClick: function () { setShowImport(true) } }, '+ ' + t('addProfile'))))
1131
+
1132
+ // --- 节点卡 ---
1133
+ var nodesCard = h(Card, { title: t('nodes'), hint: t('nodesHint') },
1134
+ h('div', { className: 'dshdl-grid' },
1135
+ h(AutoTile, {
1136
+ t: t,
1137
+ active: selectedNode === null || selectedNode === undefined,
1138
+ current: currentNode || '',
1139
+ onClick: function () {
1140
+ // 宿主可能是还没重启的旧版本,没有 select-auto 路由 —— 退回「选中 AUTO 组的当前节点」。
1141
+ run('select-auto', function () {
1142
+ return apiFetch('select-auto', {}).catch(function () {
1143
+ var auto = groups.filter(function (group) { return group.name === 'AUTO' })[0]
1144
+ var target = auto && auto.now ? auto.now : (nodes[0] && nodes[0].name)
1145
+ if (!target) return null
1146
+ return apiFetch('select', { name: target })
696
1147
  })
1148
+ })
1149
+ }
1150
+ }),
1151
+ nodes.map(function (node) {
1152
+ return h(NodeTile, {
1153
+ key: node.name,
1154
+ node: node,
1155
+ t: t,
1156
+ active: selectedNode === node.name,
1157
+ onClick: function () { selectNode(node.name) }
1158
+ })
1159
+ })),
1160
+ h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '14px' } },
1161
+ h(Btn, { onClick: testAll, disabled: busy === 'test' }, busy === 'test' ? t('testing') : t('testAll')),
1162
+ h(Btn, { onClick: updateSubscription, disabled: busy === 'update' }, t('update'))))
1163
+
1164
+ // --- 下载卡(基础) ---
1165
+ var downloadCard = h(Card, { title: t('download') },
1166
+ h(TextRow, {
1167
+ label: t('downloadDir'),
1168
+ hint: t('downloadDirHint'),
1169
+ placeholder: t('dirPlaceholder'),
1170
+ value: value.downloadDir,
1171
+ onCommit: function (next) { setField('downloadDir', next) }
1172
+ }),
1173
+ h(Row, { label: t('threads'), hint: t('threadsHint') },
1174
+ h('input', {
1175
+ className: 'dshdl-input',
1176
+ type: 'number',
1177
+ min: 1,
1178
+ max: 32,
1179
+ value: value.threads,
1180
+ style: { width: '90px', minWidth: '90px' },
1181
+ onChange: function (e) {
1182
+ var parsed = Number(e.target.value)
1183
+ if (isFinite(parsed) && parsed >= 1) setField('threads', parsed)
1184
+ }
1185
+ })),
1186
+ h(Row, { label: t('maxMb') },
1187
+ h('input', {
1188
+ className: 'dshdl-input',
1189
+ type: 'number',
1190
+ min: 1,
1191
+ value: value.maxDownloadMb,
1192
+ style: { width: '110px', minWidth: '110px' },
1193
+ onChange: function (e) {
1194
+ var parsed = Number(e.target.value)
1195
+ if (isFinite(parsed) && parsed >= 1) setField('maxDownloadMb', parsed)
1196
+ }
1197
+ })))
1198
+
1199
+ // --- 高级开关 ---
1200
+ var advancedToggle = h(Card, null,
1201
+ h(Row, { label: t('advanced'), hint: t('advancedHint') },
1202
+ h(Switch, {
1203
+ value: advanced,
1204
+ label: t('advanced'),
1205
+ onChange: function (next) {
1206
+ setAdvanced(next)
1207
+ writeStore(LS_ADVANCED, next ? '1' : '0')
1208
+ }
1209
+ })))
1210
+
1211
+ var advancedCards = []
1212
+ if (advanced) {
1213
+ advancedCards.push(h(Card, { key: 'adv-upstream', title: t('advUpstream') },
1214
+ h(TextRow, {
1215
+ label: t('proxyUrl'),
1216
+ hint: t('proxyUrlHint'),
1217
+ value: value.proxyUrl,
1218
+ placeholder: 'socks5://127.0.0.1:1080',
1219
+ onCommit: function (next) { setField('proxyUrl', next) }
1220
+ }),
1221
+ h(TextRow, {
1222
+ label: t('subscriptionUrl'),
1223
+ hint: t('subscriptionUrlHint'),
1224
+ value: value.subscriptionUrl,
1225
+ placeholder: 'https://…',
1226
+ onCommit: function (next) { setField('subscriptionUrl', next) }
1227
+ }),
1228
+ h(TextRow, {
1229
+ label: t('fetchProxyUrl'),
1230
+ hint: t('fetchProxyUrlHint'),
1231
+ value: value.fetchProxyUrl,
1232
+ placeholder: 'http://127.0.0.1:7890',
1233
+ onCommit: function (next) { setField('fetchProxyUrl', next) }
1234
+ }),
1235
+ h(SelectRow, {
1236
+ label: t('groupType'),
1237
+ value: value.groupType,
1238
+ options: [
1239
+ { value: 'url-test', label: 'url-test' },
1240
+ { value: 'select', label: 'select' },
1241
+ { value: 'fallback', label: 'fallback' },
1242
+ { value: 'load-balance', label: 'load-balance' }
1243
+ ],
1244
+ onCommit: function (next) { setField('groupType', next) }
1245
+ }),
1246
+ h(TextRow, {
1247
+ label: t('preferredNode'),
1248
+ hint: t('preferredNodeHint'),
1249
+ value: value.preferredNode,
1250
+ onCommit: function (next) { setField('preferredNode', next) }
1251
+ }),
1252
+ h(Row, { label: t('autoUpdateHours') },
1253
+ h('input', {
1254
+ className: 'dshdl-input',
1255
+ type: 'number',
1256
+ min: 0,
1257
+ value: value.autoUpdateHours,
1258
+ style: { width: '90px', minWidth: '90px' },
1259
+ onChange: function (e) {
1260
+ var parsed = Number(e.target.value)
1261
+ if (isFinite(parsed) && parsed >= 0) setField('autoUpdateHours', parsed)
697
1262
  }
698
- }, t('guardApply')),
699
- React.createElement('button', {
700
- type: 'button', className: 'dshdl-btn', disabled: !!busy,
701
- onClick: function () {
702
- run('guard-restore', function () {
703
- return api('guard-restore', {}).then(function (payload) { setGuard(payload.state) })
704
- })
1263
+ })),
1264
+ h(TextRow, { label: t('latencyTestUrl'), value: value.latencyTestUrl, onCommit: function (next) { setField('latencyTestUrl', next) } }),
1265
+ h(Row, { label: t('latencyTimeoutMs') },
1266
+ h('input', {
1267
+ className: 'dshdl-input',
1268
+ type: 'number',
1269
+ min: 200,
1270
+ value: value.latencyTimeoutMs,
1271
+ style: { width: '100px', minWidth: '100px' },
1272
+ onChange: function (e) {
1273
+ var parsed = Number(e.target.value)
1274
+ if (isFinite(parsed) && parsed >= 200) setField('latencyTimeoutMs', parsed)
1275
+ }
1276
+ }))))
1277
+
1278
+ advancedCards.push(h(Card, { key: 'adv-routing', title: t('advRouting') },
1279
+ h(Row, { label: t('domesticDirect'), hint: t('domesticDirectHint') },
1280
+ h(Switch, { value: value.domesticDirect !== false, label: t('domesticDirect'), onChange: function (next) { setField('domesticDirect', next) } })),
1281
+ h(Row, { label: t('protectAiPlatforms'), hint: t('protectAiPlatformsHint') },
1282
+ h(Switch, { value: value.protectAiPlatforms !== false, label: t('protectAiPlatforms'), onChange: function (next) { setField('protectAiPlatforms', next) } })),
1283
+ h(ListRow, {
1284
+ label: t('extraDirectDomains'),
1285
+ hint: t('extraDirectDomainsHint'),
1286
+ value: value.extraDirectDomains,
1287
+ onCommit: function (next) { setField('extraDirectDomains', next) }
1288
+ }),
1289
+ h(ListRow, { label: t('extraRules'), hint: t('extraRulesHint'), value: value.extraRules, onCommit: function (next) { setField('extraRules', next) } }),
1290
+ h(ListRow, { label: t('excludeRules'), hint: t('excludeRulesHint'), value: value.excludeRules, onCommit: function (next) { setField('excludeRules', next) } })))
1291
+
1292
+ advancedCards.push(h(Card, { key: 'adv-engine', title: t('advEngine') },
1293
+ h(Row, { label: t('maxRedirects') },
1294
+ h('input', {
1295
+ className: 'dshdl-input',
1296
+ type: 'number',
1297
+ min: 0,
1298
+ max: 30,
1299
+ value: value.maxRedirects,
1300
+ style: { width: '80px', minWidth: '80px' },
1301
+ onChange: function (e) {
1302
+ var parsed = Number(e.target.value)
1303
+ if (isFinite(parsed) && parsed >= 0) setField('maxRedirects', parsed)
1304
+ }
1305
+ })),
1306
+ h(Row, { label: t('stallTimeoutS') },
1307
+ h('input', {
1308
+ className: 'dshdl-input',
1309
+ type: 'number',
1310
+ min: 5,
1311
+ value: value.stallTimeoutS,
1312
+ style: { width: '80px', minWidth: '80px' },
1313
+ onChange: function (e) {
1314
+ var parsed = Number(e.target.value)
1315
+ if (isFinite(parsed) && parsed >= 5) setField('stallTimeoutS', parsed)
705
1316
  }
706
- }, t('guardRestore'))),
707
- React.createElement('p', { className: 'dshdl-desc' }, t('guardRestart'))),
708
-
709
- React.createElement('div', { className: 'dshdl-card' },
710
- React.createElement('div', { className: 'dshdl-cardHead' },
711
- React.createElement('span', { className: 'dshdl-cardTitle' }, t('nodes') + ' (' + nodes.length + ')'),
712
- React.createElement('div', { className: 'dshdl-actions' },
713
- React.createElement('button', { type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy,
714
- onClick: function () { run('delay-all', function () { return api('delay-all', {}).then(loadNodes) }) } }, t('testAll')),
715
- React.createElement('button', { type: 'button', className: 'dshdl-btn dshdl-small', disabled: !!busy,
716
- onClick: function () { run('clear-latencies', function () { return api('clear-latencies', {}).then(loadNodes) }) } }, t('clearLatency')))),
717
- React.createElement('div', { className: 'dshdl-nodes' }, nodeRows),
718
- status && status.traffic
719
- ? React.createElement('p', { className: 'dshdl-desc' },
720
- '流量 ↑' + formatBytes(status.traffic.up) + ' ↓' + formatBytes(status.traffic.down))
721
- : null),
722
-
723
- React.createElement('div', { className: 'dshdl-card' },
724
- React.createElement('span', { className: 'dshdl-cardTitle' }, t('rules')),
725
- rulesRows),
726
-
727
- React.createElement('div', { className: 'dshdl-card' },
728
- React.createElement('span', { className: 'dshdl-cardTitle' }, t('download')),
729
- downloadRows),
730
-
731
- error ? React.createElement('p', { className: 'dshdl-error' }, error) : null,
732
- snap && snap.status === 'unavailable' ? React.createElement('p', { className: 'dshdl-warn' }, t('unavailable')) : null,
733
-
734
- React.createElement('p', { className: 'dshdl-desc' },
735
- '注意:本插件只管它自己的下载。web_fetch 与 shell 里的 curl / git / npm 由 DSH 启动期的代理策略决定 —— 需要它们也走代理,请在 $DSH_HOME/.env 里设置 HTTP_PROXY / HTTPS_PROXY。'))
1317
+ })),
1318
+ h(Row, { label: t('downloadTimeoutS') },
1319
+ h('input', {
1320
+ className: 'dshdl-input',
1321
+ type: 'number',
1322
+ min: 1,
1323
+ value: value.downloadTimeoutS,
1324
+ style: { width: '90px', minWidth: '90px' },
1325
+ onChange: function (e) {
1326
+ var parsed = Number(e.target.value)
1327
+ if (isFinite(parsed) && parsed >= 1) setField('downloadTimeoutS', parsed)
1328
+ }
1329
+ })),
1330
+ h(Row, { label: t('insecureTls'), hint: t('insecureTlsHint') },
1331
+ h(Switch, { value: value.insecureTls === true, label: t('insecureTls'), onChange: function (next) { setField('insecureTls', next) } })),
1332
+ h(Row, { label: t('allowOutsideWorkspace'), hint: t('allowOutsideWorkspaceHint') },
1333
+ h(Switch, { value: value.allowOutsideWorkspace === true, label: t('allowOutsideWorkspace'), onChange: function (next) { setField('allowOutsideWorkspace', next) } })),
1334
+ h(Row, { label: t('guardShellDownloads'), hint: t('guardShellDownloadsHint') },
1335
+ h(Switch, { value: value.guardShellDownloads !== false, label: t('guardShellDownloads'), onChange: function (next) { setField('guardShellDownloads', next) } }))))
1336
+
1337
+ advancedCards.push(h(Card, { key: 'adv-harness', title: t('advHarness') },
1338
+ h(Row, { label: t('harnessProxy'), hint: t('harnessProxyHint') },
1339
+ h(Switch, { value: value.harnessProxy === true, label: t('harnessProxy'), onChange: function (next) { setField('harnessProxy', next) } })),
1340
+ h(Row, { label: t('harnessProxyPort'), hint: t('harnessProxyPortHint') },
1341
+ h('input', {
1342
+ className: 'dshdl-input',
1343
+ type: 'number',
1344
+ min: 1024,
1345
+ max: 65535,
1346
+ value: value.harnessProxyPort,
1347
+ style: { width: '100px', minWidth: '100px' },
1348
+ onChange: function (e) {
1349
+ var parsed = Number(e.target.value)
1350
+ if (isFinite(parsed) && parsed >= 1024 && parsed <= 65535) setField('harnessProxyPort', parsed)
1351
+ }
1352
+ }))))
1353
+ }
1354
+
1355
+ // --- 会话保护 ---
1356
+ var protectedTotal = (status && Array.isArray(status.protectedDomains)) ? status.protectedDomains.length : 0
1357
+ var guardMissingCount = (guard && Array.isArray(guard.missing)) ? guard.missing.length : 0
1358
+ var guardCard = h(Card, { title: t('guard'), hint: t('guardHint') },
1359
+ guard
1360
+ ? h(React.Fragment, null,
1361
+ h(Row, {
1362
+ label: t('guard'),
1363
+ hint: guardMissingCount === 0
1364
+ ? t('guardCovered', protectedTotal)
1365
+ : t('guardMissing', guardMissingCount)
1366
+ },
1367
+ h('div', { style: { display: 'flex', gap: '8px' } },
1368
+ h(Btn, { variant: 'primary', onClick: applyGuard, disabled: busy === 'guard' }, t('guardApply')),
1369
+ h(Btn, { onClick: restoreGuard, disabled: busy === 'guard-restore' }, t('guardRestore')))),
1370
+ (guard.proxy_vars && guard.proxy_vars.length === 0)
1371
+ ? h('div', { className: 'dshdl-note' }, t('guardNoVars'))
1372
+ : null)
1373
+ : h('div', { className: 'dshdl-note' }, '—'))
1374
+
1375
+ // --- 流量 ---
1376
+ var up = traffic ? Number(traffic.up) || 0 : 0
1377
+ var down = traffic ? Number(traffic.down) || 0 : 0
1378
+ var upRate = traffic ? Number(traffic.upRate) || 0 : 0
1379
+ var downRate = traffic ? Number(traffic.downRate) || 0 : 0
1380
+ var trafficCard = h(Card, { title: t('traffic') },
1381
+ traffic && traffic.active !== false
1382
+ ? h('div', { className: 'dshdl-traffic' },
1383
+ h('div', { className: 'dshdl-tItem' },
1384
+ h('div', { className: 'dshdl-tLabel' }, '↑ ' + t('trafficUp')),
1385
+ h('div', { className: 'dshdl-tValue' }, formatRate(upRate)),
1386
+ h('div', { className: 'dshdl-tSum' }, t('trafficTotal') + ' ' + formatBytes(up))),
1387
+ h('div', { className: 'dshdl-tItem' },
1388
+ h('div', { className: 'dshdl-tLabel' }, '↓ ' + t('trafficDown')),
1389
+ h('div', { className: 'dshdl-tValue' }, formatRate(downRate)),
1390
+ h('div', { className: 'dshdl-tSum' }, t('trafficTotal') + ' ' + formatBytes(down))))
1391
+ : h('div', { className: 'dshdl-note' }, t('trafficIdle')))
1392
+
1393
+ var aboutCard = status
1394
+ ? h(Card, { title: t('about') },
1395
+ h('dl', { className: 'dshdl-kv' },
1396
+ h('dt', null, t('dataDir')),
1397
+ h('dd', null, status.dataDir || '—')),
1398
+ h('p', { className: 'dshdl-cardHint', style: { marginTop: '10px', marginBottom: 0 } }, t('scopeNote')))
1399
+ : null
1400
+
1401
+ return h('div', { className: cls('dshdl-root', compact ? 'dshdl-compact' : '') },
1402
+ header,
1403
+ errorNode,
1404
+ actionErrorNode,
1405
+ noticeNode,
1406
+ statusCard,
1407
+ profilesCard,
1408
+ nodesCard,
1409
+ downloadCard,
1410
+ advancedToggle,
1411
+ advancedCards,
1412
+ guardCard,
1413
+ trafficCard,
1414
+ aboutCard)
736
1415
  }
737
1416
 
738
1417
  // ------------------------------------------------------------------
739
- // 侧边栏流量徽标
1418
+ // 侧栏导航图标(shell 只画这一格,行、标签、高亮由 shell 负责)
1419
+ // ------------------------------------------------------------------
1420
+ function PanelIcon(props) {
1421
+ var size = props.size || 16
1422
+ return h('svg', {
1423
+ 'data-dsh-panel-entry': PANEL_ID,
1424
+ viewBox: '0 0 16 16',
1425
+ width: size,
1426
+ height: size,
1427
+ fill: 'none',
1428
+ stroke: 'currentColor',
1429
+ strokeWidth: 1.3,
1430
+ strokeLinecap: 'round',
1431
+ strokeLinejoin: 'round',
1432
+ 'aria-hidden': 'true'
1433
+ },
1434
+ h('path', { d: 'M8 2v7.2' }),
1435
+ h('path', { d: 'M5.2 6.4 8 9.2l2.8-2.8' }),
1436
+ h('path', { d: 'M2.6 11.4v1.4c0 .6.5 1.1 1.1 1.1h8.6c.6 0 1.1-.5 1.1-1.1v-1.4' }))
1437
+ }
1438
+
1439
+ // ------------------------------------------------------------------
1440
+ // 页脚流量徽标
740
1441
  // ------------------------------------------------------------------
741
- /**
742
- * 实时显示经过本插件内核的上下行速率与累计流量。
743
- *
744
- * 数据来自 `/traffic`(内核的字节计数器)。这个插槽同时服务展开的侧边栏与
745
- * 56px 收起栏,`props.wide` 决定用哪种排版;收起态只放得下一个速率。
746
- * 页面隐藏时降到 5 秒一次,避免后台空转。
747
- */
748
1442
  function TrafficBadge(props) {
749
1443
  var wide = props.wide !== false
750
1444
  var t = props.t || function (key) { return key }
751
- var state = React.useState(null)
752
- var traffic = state[0]
753
- var setTraffic = state[1]
754
- var failedState = React.useState(false)
755
- var failed = failedState[0]
756
- var setFailed = failedState[1]
757
- var alive = React.useRef(true)
758
-
759
- React.useEffect(function () {
760
- alive.current = true
761
- var timer = null
762
- function tick() {
763
- apiFetch('traffic')
764
- .then(function (payload) { if (alive.current) { setTraffic(payload); setFailed(false) } })
765
- .catch(function () { if (alive.current) setFailed(true) })
766
- }
767
- function schedule() {
768
- timer = setTimeout(function () { tick(); schedule() }, document.hidden ? 5000 : 1500)
769
- }
770
- tick()
771
- schedule()
772
- return function () { alive.current = false; if (timer) clearTimeout(timer) }
773
- }, [])
774
-
775
- // 内核没跑或后端不可达时不占地方。
776
- if (failed || traffic === null || traffic.active === false) return null
777
-
778
- var up = Number(traffic.up) || 0
779
- var down = Number(traffic.down) || 0
1445
+ var traffic = useTraffic(1500)
1446
+ if (traffic === null || traffic.active === false) return null
780
1447
  var upRate = Number(traffic.upRate) || 0
781
1448
  var downRate = Number(traffic.downRate) || 0
782
- var tip = t('trafficTitle') + '\n' +
783
- t('trafficUp') + ':' + formatRate(upRate) + '(' + t('trafficTotal') + ' ' + formatBytes(up) + ')\n' +
784
- t('trafficDown') + ':' + formatRate(downRate) + '(' + t('trafficTotal') + ' ' + formatBytes(down) + ')'
785
-
1449
+ var tip = t('traffic') + '\n' +
1450
+ t('trafficUp') + ': ' + formatRate(upRate) + ' (' + t('trafficTotal') + ' ' + formatBytes(traffic.up) + ')\n' +
1451
+ t('trafficDown') + ': ' + formatRate(downRate) + ' (' + t('trafficTotal') + ' ' + formatBytes(traffic.down) + ')'
786
1452
  if (!wide) {
787
- return React.createElement('div', { className: 'dshdl-trafRail', title: tip },
788
- React.createElement('span', { className: 'dshdl-trafRailArrow' }, '↓'),
789
- React.createElement('span', { className: 'dshdl-trafRailRate' }, formatRateTiny(downRate)))
1453
+ return h('div', { className: 'dshdl-trafRail', title: tip },
1454
+ h('span', null, '↓'),
1455
+ h('span', null, formatRateTiny(downRate)))
790
1456
  }
791
-
792
- return React.createElement('div', { className: 'dshdl-traf', title: tip },
793
- React.createElement('div', { className: 'dshdl-trafRow' },
794
- React.createElement('span', { className: 'dshdl-trafArrow up' }, '↑'),
795
- React.createElement('span', { className: 'dshdl-trafRate' }, formatRate(upRate)),
796
- React.createElement('span', { className: 'dshdl-trafSum' }, formatBytes(up))),
797
- React.createElement('div', { className: 'dshdl-trafRow' },
798
- React.createElement('span', { className: 'dshdl-trafArrow down' }, '↓'),
799
- React.createElement('span', { className: 'dshdl-trafRate' }, formatRate(downRate)),
800
- React.createElement('span', { className: 'dshdl-trafSum' }, formatBytes(down))))
1457
+ return h('div', { className: 'dshdl-trafFoot', title: tip },
1458
+ h('div', { className: 'row' }, h('span', null, '↑'), h('span', null, formatRateTiny(upRate))),
1459
+ h('div', { className: 'row' }, h('span', null, '↓'), h('span', null, formatRateTiny(downRate))))
801
1460
  }
802
1461
 
803
1462
  // ------------------------------------------------------------------
@@ -810,11 +1469,13 @@ window.__ModuleLoader__.load({
810
1469
  function apply(ctx) {
811
1470
  ensureCss()
812
1471
 
813
- var translate = null
1472
+ var ctxRef = { current: ctx }
1473
+ var locale = localeService(ctx)
1474
+
1475
+ // 字典注册失败不影响面板(下面有自己的回退字典)。
814
1476
  try {
815
- ctx.locale.register(LOCALE_NS, { zh: LOCALE_ZH, en: LOCALE_EN })
816
- translate = ctx.locale.bind(LOCALE_NS)
817
- } catch (e) { /* 字典注册失败不影响面板 */ }
1477
+ if (locale) locale.register(LOCALE_NS, { zh: LOCALE_ZH, en: LOCALE_EN })
1478
+ } catch (e) { /* ignore */ }
818
1479
 
819
1480
  var cached = null
820
1481
  function getConfigForm() {
@@ -826,30 +1487,62 @@ window.__ModuleLoader__.load({
826
1487
  return cached
827
1488
  }
828
1489
 
1490
+ function label() {
1491
+ try {
1492
+ if (locale) return locale.bind(LOCALE_NS)('entryLabel')
1493
+ } catch (e) { /* ignore */ }
1494
+ return '下载代理'
1495
+ }
1496
+
1497
+ // 1) 左侧栏导航行(shell 拥有按钮/标签/高亮/收起态几何)
1498
+ ctx.slots.inject('sidebar.panellist', function () {
1499
+ return ctx.slots.register({
1500
+ name: 'sidebar.panellist',
1501
+ id: PANEL_ID,
1502
+ order: 30,
1503
+ label: label
1504
+ }, PanelIcon)
1505
+ })
1506
+
1507
+ // 2) 左侧栏对应的主面板页面
1508
+ ctx.slots.inject('main', function () {
1509
+ return ctx.slots.register({
1510
+ name: 'main',
1511
+ key: PANEL_ID,
1512
+ label: label,
1513
+ inject: function () {
1514
+ return { ctxRef: ctxRef, scope: getConfigForm(), variant: 'panel' }
1515
+ }
1516
+ }, DownloaderPage)
1517
+ })
1518
+
1519
+ // 3) 设置页入口 —— 同一套 Page 组件,只是排版更紧凑
829
1520
  ctx.slots.inject('settings.section', function () {
830
1521
  return ctx.slots.register({
831
1522
  name: 'settings.section',
832
1523
  id: ENTRY_ID,
833
1524
  order: 91,
834
- label: function () { return translate ? translate('nav') : '下载代理' },
1525
+ label: label,
835
1526
  locale: LOCALE_NS,
836
1527
  inject: function () {
837
- return {
838
- scope: getConfigForm(),
839
- t: function (key) { return translate ? translate(key) : key }
840
- }
1528
+ return { ctxRef: ctxRef, scope: getConfigForm(), variant: 'settings' }
841
1529
  }
842
- }, DownloaderSection)
1530
+ }, DownloaderPage)
843
1531
  })
844
1532
 
845
- // 侧边栏页脚:实时代理流量。与设置页各自独立,设置页没打开也能看。
1533
+ // 4) 侧栏页脚:实时流量。设置页/面板没打开也能看
846
1534
  ctx.slots.inject('sidebar.footer.action', function () {
847
1535
  return ctx.slots.register({
848
1536
  name: 'sidebar.footer.action',
849
1537
  id: ENTRY_ID + '-traffic',
850
1538
  order: 20,
851
1539
  inject: function () {
852
- return { t: function (key) { return translate ? translate(key) : key } }
1540
+ var translate = function (key) { return key }
1541
+ try {
1542
+ var service = localeService(ctx)
1543
+ if (service) translate = service.bind(LOCALE_NS)
1544
+ } catch (e) { /* ignore */ }
1545
+ return { t: translate }
853
1546
  }
854
1547
  }, TrafficBadge)
855
1548
  })