tt-help-cli-ycl 1.4.114 → 1.4.117

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/src/cli/attach.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
2
2
  import { CDNBlockedError } from "../lib/parse-ssr.mjs";
3
+ import { writeCommand } from "../clash/ipc.js";
3
4
  import {
4
5
  proxy as configuredProxy,
5
6
  userId as configuredUserId,
@@ -11,6 +12,8 @@ import v8 from "node:v8";
11
12
 
12
13
  const HEAP_RESTART_RATIO = 0.72;
13
14
  const MAX_TASK_BATCHES_BEFORE_RESTART = 200;
15
+ const MAX_CDN_SWITCH_ATTEMPTS = 3; // 连续 CDN 限流最多切换几次节点,之后切换无效则改纯等待(账号/整体风控时切换无用)
16
+ const MAX_CDN_COOLDOWN_SEC = 300; // CDN 冷却上限(指数退避:60s→120s→240s→300s)
14
17
 
15
18
  function formatNow() {
16
19
  return new Date().toLocaleString("zh-CN", {
@@ -147,8 +150,27 @@ export async function handleAttach(options) {
147
150
  body: JSON.stringify({ uniqueId }),
148
151
  signal: AbortSignal.timeout(30000),
149
152
  });
150
- const data = await res.json();
151
- if (!res.ok || data.error) return null;
153
+ let data = null;
154
+ try {
155
+ data = await res.json();
156
+ } catch {}
157
+ if (!res.ok) {
158
+ // CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)
159
+ if (
160
+ data &&
161
+ typeof data.error === "string" &&
162
+ data.error.includes("CDN限流")
163
+ ) {
164
+ throw new CDNBlockedError(data.error);
165
+ }
166
+ // 其余失败(瞬时 503/5xx)返回 null,由服务端后续重新排队
167
+ return null;
168
+ }
169
+ // 业务结果:TikTok 明确 statusCode(209002 验证码等)→ 返回上层走"已记录"分支
170
+ if (data && data.error && data.tiktokStatusCode) {
171
+ return data;
172
+ }
173
+ if (data && data.error) return null;
152
174
  return data;
153
175
  };
154
176
  } else {
@@ -185,6 +207,7 @@ export async function handleAttach(options) {
185
207
  let loopCount = 0;
186
208
  let browserRestartCount = 0;
187
209
  let taskBatchCount = 0;
210
+ let consecutiveCdnBlocks = 0; // 连续 CDN 限流轮数(自适应:前几次切换节点,之后长等待)
188
211
 
189
212
  while (!shuttingDown) {
190
213
  loopCount++;
@@ -348,23 +371,47 @@ export async function handleAttach(options) {
348
371
 
349
372
  attachLog(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
350
373
 
351
- // CDN 限流比例超过 30% 时,冷却 + 重启浏览器(仅本地浏览器模式)
352
- if (scraper) {
353
- const cdnRatio = cdnBlockedCount / tasks.length;
354
- if (cdnRatio > 0.3) {
355
- const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
374
+ // CDN 限流比例超过 30% 时:自适应策略(前几次切节点,连续限流则长等待)
375
+ const cdnRatio = tasks.length > 0 ? cdnBlockedCount / tasks.length : 0;
376
+ if (cdnRatio > 0.3) {
377
+ consecutiveCdnBlocks++;
378
+ // 冷却指数退避:60s→120s→240s→…上限 300s(切换后短等,持续限流则长等窗口过去)
379
+ const coolDownSeconds = Math.min(
380
+ 60 * Math.pow(2, consecutiveCdnBlocks - 1),
381
+ MAX_CDN_COOLDOWN_SEC,
382
+ );
383
+ const shouldSwitch = consecutiveCdnBlocks <= MAX_CDN_SWITCH_ATTEMPTS;
384
+ if (shouldSwitch) {
385
+ // 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
386
+ try {
387
+ writeCommand({
388
+ action: "failover",
389
+ reason: `attach CDN限流 连续${consecutiveCdnBlocks}次 (${(cdnRatio * 100).toFixed(0)}%)`,
390
+ });
391
+ } catch {}
392
+ attachLog(
393
+ ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),第${consecutiveCdnBlocks}次连续限流,已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
394
+ );
395
+ } else {
396
+ // 连续多次切换仍限流 → 大概率账号/整体风控,切换无用,纯等待窗口过去
356
397
  attachLog(
357
- ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
398
+ ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),已连续 ${consecutiveCdnBlocks} 次切换仍限流(可能账号/整体风控),停止切换,长冷却 ${coolDownSeconds} 秒等待窗口过去...`,
358
399
  );
359
- await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
400
+ }
401
+ await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
402
+ if (scraper) {
360
403
  await recycleScraper(
361
404
  scraper,
362
405
  `CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
363
406
  );
364
407
  browserRestartCount++;
365
- taskBatchCount = 0;
366
408
  }
409
+ taskBatchCount = 0;
410
+ } else {
411
+ consecutiveCdnBlocks = 0; // 正常批次重置连续限流计数
412
+ }
367
413
 
414
+ if (scraper) {
368
415
  const heap = getHeapUsage();
369
416
  if (heap.ratio >= HEAP_RESTART_RATIO) {
370
417
  await recycleScraper(
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * clash-daemon CLI 命令入口
3
3
  *
4
- * 用法: tt-help clash-daemon [--interval 120] [--benchmark 10] [--country US] [--dry-run]
4
+ * 用法: tt-help clash-daemon [--interval 120] [--benchmark 10] [--country US]
5
+ * [--api-port <端口>] [--secret <secret>] [--selection <策略>] [--config-path <路径>] [--dry-run]
6
+ * 控制端(端口/secret)默认自动发现,显式参数优先于自动发现
5
7
  */
6
8
  import { ClashDaemon } from "../clash/daemon.js";
7
9
 
@@ -14,6 +16,13 @@ export async function handleClashDaemon(options) {
14
16
  options["dry-run"] === "" ||
15
17
  options["dry-run"] === true ||
16
18
  process.argv.includes("--dry-run"),
19
+ apiPort: parseInt(options["api-port"]) || undefined,
20
+ secret: options.secret || undefined,
21
+ selection: options.selection || undefined,
22
+ configPaths:
23
+ Array.isArray(options["config-paths"]) && options["config-paths"].length
24
+ ? options["config-paths"]
25
+ : undefined,
17
26
  });
18
27
  await d.start();
19
28
  }
@@ -1,5 +1,6 @@
1
1
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
2
2
  import { CDNBlockedError } from "../lib/parse-ssr.mjs";
3
+ import { writeCommand } from "../clash/ipc.js";
3
4
  import {
4
5
  proxy as configuredProxy,
5
6
  userId as configuredUserId,
@@ -163,8 +164,27 @@ export async function handlePrecheck(options) {
163
164
  body: JSON.stringify({ uniqueId }),
164
165
  signal: AbortSignal.timeout(30000),
165
166
  });
166
- const data = await res.json();
167
- if (!res.ok || data.error) return null;
167
+ let data = null;
168
+ try {
169
+ data = await res.json();
170
+ } catch {}
171
+ if (!res.ok) {
172
+ // CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)
173
+ if (
174
+ data &&
175
+ typeof data.error === "string" &&
176
+ data.error.includes("CDN限流")
177
+ ) {
178
+ throw new CDNBlockedError(data.error);
179
+ }
180
+ // 其余失败(瞬时 503/5xx)返回 null,由服务端后续重新排队
181
+ return null;
182
+ }
183
+ // 业务结果:TikTok 明确 statusCode(209002 验证码等)→ 返回上层走"已记录"分支
184
+ if (data && data.error && data.tiktokStatusCode) {
185
+ return data;
186
+ }
187
+ if (data && data.error) return null;
168
188
  return data;
169
189
  };
170
190
  } else {
@@ -397,24 +417,32 @@ export async function handlePrecheck(options) {
397
417
  ` 本轮结果: ${needRefreshCount} 需刷新, ${skipCount} 跳过, ${failCount} 失败`,
398
418
  );
399
419
 
400
- // CDN 限流/堆内存/批次重启(仅本地浏览器模式)
401
- if (scraper) {
402
- const cdnRatio =
403
- candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
404
- if (cdnRatio > 0.3) {
405
- const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
406
- precheckLog(
407
- ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
408
- );
409
- await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
420
+ // CDN 限流比例超过 30% 时:通知 clash daemon 故障切换(换出口 IP 绕过限流)+ 冷却
421
+ const cdnRatio =
422
+ candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
423
+ if (cdnRatio > 0.3) {
424
+ // 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
425
+ try {
426
+ writeCommand({
427
+ action: "failover",
428
+ reason: `precheck CDN限流 (${(cdnRatio * 100).toFixed(0)}%)`,
429
+ });
430
+ } catch {}
431
+ // 切换节点后新 IP 生效需短暂等待,冷却可缩短(替代纯硬等 120s)
432
+ const coolDownSeconds = cdnRatio > 0.8 ? 90 : 60;
433
+ precheckLog(
434
+ ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
435
+ );
436
+ await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
437
+ if (scraper) {
410
438
  await recycleScraper(
411
439
  scraper,
412
440
  `CDN限流比例过高 (${cdnBlockedCount}/${candidates.length})`,
413
441
  );
414
442
  browserRestartCount++;
415
- taskBatchCount = 0;
416
443
  }
417
-
444
+ taskBatchCount = 0;
445
+ } else if (scraper) {
418
446
  const heap = getHeapUsage();
419
447
  if (heap.ratio >= HEAP_RESTART_RATIO) {
420
448
  await recycleScraper(
@@ -37,7 +37,6 @@ import {
37
37
  killEdgeProcesses,
38
38
  } from "../lib/browser/cdp.js";
39
39
  import { createApiClient } from "../lib/api-client.js";
40
- import { requireClashOrExit } from "../clash/checker.js";
41
40
  import { readHealth, writeCommand } from "../clash/ipc.js";
42
41
  import { spawn } from "child_process";
43
42
  import { fileURLToPath } from "url";
@@ -83,6 +82,19 @@ async function withRetry(label, fn) {
83
82
  */
84
83
  let spawnedDaemonPid = null;
85
84
 
85
+ /** 从代理 URL 解析端口(http://127.0.0.1:7890 → 7890),无法解析返回 0(默认通道) */
86
+ function proxyPortOf(url) {
87
+ if (!url) return 0;
88
+ try {
89
+ const u = new URL(url);
90
+ const p = parseInt(u.port, 10);
91
+ return p > 0 ? p : 0;
92
+ } catch {
93
+ const m = String(url).match(/:(\d+)/);
94
+ return m ? parseInt(m[1], 10) : 0;
95
+ }
96
+ }
97
+
86
98
  function spawnClashDaemon() {
87
99
  try {
88
100
  const mainJs = path.join(__dirname, "..", "main.js");
@@ -115,10 +127,10 @@ function stopSpawnedDaemon() {
115
127
  }
116
128
 
117
129
  /**
118
- * 用 daemon 的 IPC 健康状态判断其是否存活(黑盒接口:clash-health.json)
130
+ * 用 daemon 的 IPC 健康状态判断其是否存活(黑盒接口:clash-health.json,按通道端口)
119
131
  */
120
- function isDaemonAlive() {
121
- const h = readHealth();
132
+ function isDaemonAlive(port = 0) {
133
+ const h = readHealth(port);
122
134
  return !!(h && !h.stale);
123
135
  }
124
136
 
@@ -140,20 +152,13 @@ export async function handleRefreshMini(options) {
140
152
  ensureDaemon,
141
153
  } = options;
142
154
 
155
+ // 配置了什么代理就用什么代理(--proxy > ~/.tt-help.json 的 proxy);
156
+ // 未配置则不注入 proxyServer,浏览器走系统默认代理。
157
+ // 节点切换仍由 clash-daemon 负责(触发通知模式),不在此处自动解析。
143
158
  const effectiveProxy = exploreProxy || configuredProxy;
144
- let resolvedProxy = effectiveProxy;
145
- if (
146
- !effectiveProxy ||
147
- effectiveProxy.includes("127.0.0.1") ||
148
- effectiveProxy.includes("localhost")
149
- ) {
150
- // refresh-mini 必须走本地 Clash:未配置代理时自动发现,节点轮换才有意义
151
- resolvedProxy = await requireClashOrExit("refresh-mini");
152
- } else {
153
- console.error(
154
- `[警告] 使用非本地代理 ${effectiveProxy},Clash 节点轮换将无效`,
155
- );
156
- }
159
+ const resolvedProxy = effectiveProxy || null;
160
+ // 切换通知通道:按本实例的代理端口路由到 daemon 对应控制端
161
+ const proxyPort = proxyPortOf(resolvedProxy);
157
162
 
158
163
  let userId = exploreUserId || configuredUserId;
159
164
  let browser = null;
@@ -196,7 +201,7 @@ export async function handleRefreshMini(options) {
196
201
  if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
197
202
  console.error(`空闲间隔: ${exploreInterval || 30} 秒`);
198
203
  console.error(
199
- `代理: ${resolvedProxy || "无"}${
204
+ `代理: ${resolvedProxy || "系统默认"}${
200
205
  rotateMin > 0
201
206
  ? `(每 ${rotateMin} 分钟轮换节点)`
202
207
  : "(定时轮换已关闭)"
@@ -310,8 +315,8 @@ export async function handleRefreshMini(options) {
310
315
 
311
316
  // --ensure-daemon:检查 clash-daemon 是否存活,未存活则拉起(默认不检测)
312
317
  if (ensureDaemon) {
313
- if (isDaemonAlive()) {
314
- const h = readHealth();
318
+ if (isDaemonAlive(proxyPort)) {
319
+ const h = readHealth(proxyPort);
315
320
  console.error(
316
321
  `[daemon] 已运行(上次巡检 ${h?.ageSeconds ?? "?"}s 前)`,
317
322
  );
@@ -339,16 +344,23 @@ export async function handleRefreshMini(options) {
339
344
  proxySwitchCount++;
340
345
  lastProxyTriggerTime = Date.now();
341
346
  consecutiveTaskErrors = 0;
342
- const before = readHealth()?.lastSwitch || "";
347
+ const before = readHealth(proxyPort)?.lastSwitch || "";
343
348
  console.error(
344
- `[代理] 触发切换通知(${reason},累计 ${proxySwitchCount} 次),等待 daemon 回执...`,
349
+ `[代理] 触发切换通知(${reason}${proxyPort ? `,通道 ${proxyPort}` : ""},累计 ${proxySwitchCount} 次),等待 daemon 回执...`,
350
+ );
351
+ writeCommand(
352
+ {
353
+ action: "failover",
354
+ reason: `refresh-mini: ${reason}`,
355
+ proxyPort: proxyPort || undefined,
356
+ },
357
+ proxyPort,
345
358
  );
346
- writeCommand({ action: "failover", reason: `refresh-mini: ${reason}` });
347
359
 
348
360
  const deadline = Date.now() + SWITCH_RECEIPT_TIMEOUT_MS;
349
361
  while (Date.now() < deadline) {
350
362
  await sleep(3000);
351
- const h = readHealth();
363
+ const h = readHealth(proxyPort);
352
364
  if (
353
365
  h &&
354
366
  h.lastSwitch &&
package/src/lib/args.js CHANGED
@@ -758,6 +758,10 @@ function parseClashDaemonArgs(args) {
758
758
  let benchmark = 10;
759
759
  let dryRun = false;
760
760
  let country = null;
761
+ let apiPort = null;
762
+ let secret = null;
763
+ let selection = null;
764
+ const configPaths = [];
761
765
 
762
766
  for (let i = 0; i < args.length; i++) {
763
767
  const arg = args[i];
@@ -769,6 +773,15 @@ function parseClashDaemonArgs(args) {
769
773
  country = args[++i] || null;
770
774
  } else if (arg === "--dry-run") {
771
775
  dryRun = true;
776
+ } else if (arg === "--api-port") {
777
+ apiPort = parseInt(args[++i]) || 0;
778
+ } else if (arg === "--secret") {
779
+ secret = args[++i] || "";
780
+ } else if (arg === "--selection") {
781
+ selection = args[++i] || null;
782
+ } else if (arg === "--config-path") {
783
+ const p = args[++i];
784
+ if (p) configPaths.push(p);
772
785
  }
773
786
  }
774
787
 
@@ -778,6 +791,10 @@ function parseClashDaemonArgs(args) {
778
791
  benchmark,
779
792
  country,
780
793
  "dry-run": dryRun,
794
+ "api-port": apiPort,
795
+ secret,
796
+ selection,
797
+ "config-paths": configPaths,
781
798
  urls: [],
782
799
  outputFormat: "json",
783
800
  exploreCount: 0,
@@ -420,7 +420,7 @@ const HELP_TEXT = [
420
420
  " tt-help watchdog -i 30 --client-id my-server-01",
421
421
  "",
422
422
  " tt-help clash-daemon [选项]",
423
- " Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
423
+ " Clash Verge / mihomo 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
424
424
  " 看门狗启动时会自动拉起此守护进程",
425
425
  " 选项:",
426
426
  " --interval <秒> 巡检间隔,默认 120 秒(2分钟)",
@@ -428,6 +428,11 @@ const HELP_TEXT = [
428
428
  " --country <国家代码> 只保留指定国家节点,默认不过滤",
429
429
  " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
430
430
  " --dry-run 只检测不切换(调试用)",
431
+ " --api-port <端口> 控制 API 端口(默认自动发现;显式指定验证失败时报错)",
432
+ " --secret <secret> 控制 API 密钥(默认自动获取)",
433
+ " --selection <策略> 多控制端选择策略: system-proxy / preference / first-alive",
434
+ " --config-path <路径> 追加自定义 config.yaml 探测路径(可重复)",
435
+ " 控制端自动发现: 依次探测 Clash Verge / 69cloud 配置 → 端口枚举,按策略选定",
431
436
  " 看门狗运行时可通过 /tmp/clash-cmd.json 发命令动态调整:",
432
437
  ' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
433
438
  ' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
@@ -4055,23 +4055,41 @@ async function wdSetErrorTrigger() {
4055
4055
  }
4056
4056
  }
4057
4057
 
4058
- // ── 代理节点区域(看门狗面板中每个客户端卡片) ──
4058
+ // ── 代理节点区域(看门狗面板中每个客户端卡片,支持多控制端通道) ──
4059
4059
 
4060
4060
  function renderProxySection(c, userId) {
4061
+ var channels = c.clashHealthChannels || [];
4062
+ if (channels.length > 0) {
4063
+ var html = "";
4064
+ for (var i = 0; i < channels.length; i++) {
4065
+ html += renderProxyChannel(channels[i], userId);
4066
+ }
4067
+ return html;
4068
+ }
4069
+ // 兼容旧版:仅主控制端(legacy clashHealth)
4061
4070
  var h = c.clashHealth;
4062
4071
  if (!h || !h.node) return "";
4063
-
4064
4072
  var nodes = (h.meta && h.meta.healthyNodes) || [];
4065
4073
  var hasNodes = nodes.length > 0;
4074
+ return (
4075
+ renderProxyLine(h, nodes, userId, 0, hasNodes) +
4076
+ renderNodeList(nodes, h.node, userId, 0)
4077
+ );
4078
+ }
4066
4079
 
4080
+ /** 单个控制端通道的代理区域 */
4081
+ function renderProxyChannel(ch, userId) {
4082
+ if (!ch || !ch.node) return "";
4083
+ var nodes = (ch.meta && ch.meta.healthyNodes) || [];
4084
+ var hasNodes = nodes.length > 0;
4067
4085
  return (
4068
- renderProxyLine(h, nodes, userId, hasNodes) +
4069
- renderNodeList(nodes, h.node, userId, hasNodes)
4086
+ renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
4087
+ renderNodeList(nodes, ch.node, userId, ch.port || 0)
4070
4088
  );
4071
4089
  }
4072
4090
 
4073
4091
  /** 代理状态行:当前节点 + 健康状态 + 展开按钮 */
4074
- function renderProxyLine(h, nodes, userId, hasNodes) {
4092
+ function renderProxyLine(h, nodes, userId, port, hasNodes) {
4075
4093
  var dot = h.healthy ? "#4ade80" : "#f87171";
4076
4094
  var status = h.healthy
4077
4095
  ? (h.timeTotal || 0).toFixed(1) +
@@ -4079,19 +4097,31 @@ function renderProxyLine(h, nodes, userId, hasNodes) {
4079
4097
  ((h.sizeDownload || 0) / 1024).toFixed(0) +
4080
4098
  "KB"
4081
4099
  : h.error || "异常";
4100
+ var clientTag = port
4101
+ ? h.meta && h.meta.clientName
4102
+ ? h.meta.clientName + " :" + port
4103
+ : "代理 :" + port
4104
+ : "";
4082
4105
 
4083
4106
  return styled(
4084
4107
  "div",
4085
4108
  "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
4086
4109
  "display:flex;align-items:center;gap:8px;font-size:12px",
4087
4110
  styled("span", "color:" + dot + ";font-size:14px", "●") +
4111
+ (clientTag
4112
+ ? styled(
4113
+ "span",
4114
+ "color:#888;padding:0 4px;border:1px solid #333;border-radius:3px;font-size:10px",
4115
+ clientTag,
4116
+ )
4117
+ : "") +
4088
4118
  styled("span", "color:#e0e0e0;font-weight:bold", h.node) +
4089
4119
  styled("span", "color:#666", "| " + status) +
4090
4120
  (hasNodes
4091
4121
  ? tag(
4092
4122
  "button",
4093
4123
  {
4094
- onclick: "wdToggleNodes('" + esc(userId) + "')",
4124
+ onclick: "wdToggleNodes('" + esc(userId) + "'," + port + ")",
4095
4125
  style:
4096
4126
  "margin-left:auto;padding:2px 8px;font-size:11px;background:#333;" +
4097
4127
  "color:#60a5fa;border:1px solid #444;border-radius:3px;cursor:pointer",
@@ -4104,15 +4134,15 @@ function renderProxyLine(h, nodes, userId, hasNodes) {
4104
4134
  }
4105
4135
 
4106
4136
  /** 节点列表面板(默认隐藏) */
4107
- function renderNodeList(nodes, currentNode, userId, hasNodes) {
4108
- if (!hasNodes) return "";
4137
+ function renderNodeList(nodes, currentNode, userId, port) {
4138
+ if (!nodes || !nodes.length) return "";
4109
4139
 
4110
4140
  var items = "";
4111
4141
  for (var i = 0; i < nodes.length; i++) {
4112
- items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId);
4142
+ items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId, port);
4113
4143
  }
4114
4144
 
4115
- var id = "wdNodes-" + wdEscId(userId);
4145
+ var id = "wdNodes-" + wdEscId(userId) + "-" + port;
4116
4146
  return (
4117
4147
  '<div id="' +
4118
4148
  id +
@@ -4124,7 +4154,7 @@ function renderNodeList(nodes, currentNode, userId, hasNodes) {
4124
4154
  }
4125
4155
 
4126
4156
  /** 单个节点行 */
4127
- function renderNodeItem(name, isActive, userId) {
4157
+ function renderNodeItem(name, isActive, userId, port) {
4128
4158
  var color = isActive ? "#4ade80" : "#888";
4129
4159
  var bg = isActive ? "background:#1a3a2a;" : "";
4130
4160
  var dot = isActive ? "●" : "○";
@@ -4135,7 +4165,9 @@ function renderNodeItem(name, isActive, userId) {
4135
4165
  esc(userId) +
4136
4166
  "','" +
4137
4167
  esc(name) +
4138
- "')\" " +
4168
+ "'," +
4169
+ port +
4170
+ ')" ' +
4139
4171
  'style="margin-left:auto;padding:1px 6px;font-size:10px;background:#2563eb;' +
4140
4172
  'color:#fff;border:none;border-radius:2px;cursor:pointer">切换</button>';
4141
4173
 
@@ -4164,18 +4196,23 @@ function wdEscId(s) {
4164
4196
  return (s || "").replace(/[^a-zA-Z0-9_-]/g, "_");
4165
4197
  }
4166
4198
 
4167
- function wdToggleNodes(userId) {
4168
- var el = document.getElementById("wdNodes-" + wdEscId(userId));
4199
+ function wdToggleNodes(userId, port) {
4200
+ var el = document.getElementById(
4201
+ "wdNodes-" + wdEscId(userId) + "-" + (port || 0),
4202
+ );
4169
4203
  if (!el) return;
4170
4204
  el.style.display = el.style.display === "none" ? "block" : "none";
4171
4205
  }
4172
4206
 
4173
- function wdSwitchNode(userId, node) {
4207
+ function wdSwitchNode(userId, node, port) {
4208
+ var portLabel = port ? "(代理通道 " + port + ")" : "";
4174
4209
  if (
4175
4210
  !confirm(
4176
4211
  "确定要将 " +
4177
4212
  userId +
4178
- " 的代理切换到: " +
4213
+ " 的代理" +
4214
+ portLabel +
4215
+ "切换到: " +
4179
4216
  node +
4180
4217
  " ?\n\n切换后自动巡检会继续运行。",
4181
4218
  )
@@ -4185,7 +4222,11 @@ function wdSwitchNode(userId, node) {
4185
4222
  fetch("/api/watchdog/switch-node", {
4186
4223
  method: "POST",
4187
4224
  headers: { "Content-Type": "application/json" },
4188
- body: JSON.stringify({ userId: userId, node: node }),
4225
+ body: JSON.stringify({
4226
+ userId: userId,
4227
+ node: node,
4228
+ proxyPort: port || undefined,
4229
+ }),
4189
4230
  })
4190
4231
  .then(function (r) {
4191
4232
  return r.json();
@@ -120,6 +120,7 @@ function getOnlineClients() {
120
120
  processes,
121
121
  llmAnalysis,
122
122
  clashHealth: info.clashHealth || null,
123
+ clashHealthChannels: info.clashHealthChannels || [],
123
124
  lastHeartbeat: info.lastHeartbeat,
124
125
  registeredAt: info.registeredAt,
125
126
  online,
@@ -324,6 +325,9 @@ async function handleHeartbeat(params, body, res) {
324
325
  config: body.config || {},
325
326
  processes: body.processes || [],
326
327
  clashHealth: body.clashHealth || null,
328
+ clashHealthChannels: Array.isArray(body.clashHealthChannels)
329
+ ? body.clashHealthChannels
330
+ : [],
327
331
  });
328
332
 
329
333
  // 按 userId 返回该客户端的专属命令列表
@@ -492,16 +496,22 @@ async function handleClearLlmAnalysis(params, body, res) {
492
496
  }
493
497
 
494
498
  async function handleSwitchNode(params, body, res) {
495
- const { userId, node } = body || {};
499
+ const { userId, node, proxyPort } = body || {};
496
500
  if (!userId || !node) {
497
501
  sendJSON(res, 400, { error: "缺少 userId 或 node" });
498
502
  return;
499
503
  }
500
- // 加入 Clash 命令队列,客户端下次心跳时取走
504
+ // 加入 Clash 命令队列,客户端下次心跳时取走(proxyPort 用于路由到对应控制端)
501
505
  const queue = clashCmdQueue.get(userId) || [];
502
- queue.push({ action: "switchNode", node });
506
+ queue.push({
507
+ action: "switchNode",
508
+ node,
509
+ proxyPort: proxyPort ? Number(proxyPort) : undefined,
510
+ });
503
511
  clashCmdQueue.set(userId, queue);
504
- console.error(`[watchdog] ${userId} 节点切换已入队: → ${node}`);
512
+ console.error(
513
+ `[watchdog] ${userId} 节点切换已入队: → ${node}${proxyPort ? ` (通道 ${proxyPort})` : ""}`,
514
+ );
505
515
  sendJSON(res, 200, { ok: true, message: `将在下次心跳时切换到 ${node}` });
506
516
  }
507
517
 
@@ -12,6 +12,7 @@ import {
12
12
  getClientConfig,
13
13
  getLocalIP,
14
14
  getClashHealth,
15
+ getClashHealthChannels,
15
16
  } from "./reporter.js";
16
17
  import { ProcessManager } from "./process-manager.js";
17
18
  import {
@@ -414,7 +415,8 @@ export class WatchdogAgent {
414
415
  config: getClientConfig(),
415
416
  processes: this.processManager.getProcessesStatus(),
416
417
  llmAnalysis: this._llmResults,
417
- clashHealth: getClashHealth(), // daemon 写入的代理健康状态
418
+ clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
419
+ clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
418
420
  };
419
421
  }
420
422
 
@@ -458,13 +460,21 @@ export class WatchdogAgent {
458
460
  }
459
461
  }
460
462
 
461
- // 处理 Clash daemon 命令(一次写入数组,daemon 轮询整批处理)
463
+ // 处理 Clash daemon 命令(按 proxyPort 分组写入对应通道,无端口走默认通道)
462
464
  if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
463
465
  const ipc = await import("../clash/ipc.js");
464
- ipc.writeCommand(data.clashCommands);
465
- console.error(
466
- `[watchdog] 下发 Clash 命令: ${JSON.stringify(data.clashCommands)}`,
467
- );
466
+ const byPort = new Map();
467
+ for (const cmd of data.clashCommands) {
468
+ const port = cmd && cmd.proxyPort ? Number(cmd.proxyPort) : 0;
469
+ if (!byPort.has(port)) byPort.set(port, []);
470
+ byPort.get(port).push(cmd);
471
+ }
472
+ for (const [port, cmds] of byPort) {
473
+ ipc.writeCommand(cmds, port);
474
+ console.error(
475
+ `[watchdog] 下发 Clash 命令${port ? ` (通道 ${port})` : ""}: ${JSON.stringify(cmds)}`,
476
+ );
477
+ }
468
478
  }
469
479
 
470
480
  // 服务端下发的客户端配置(远程修改 ~/.tt-help.json)