tt-help-cli-ycl 1.4.161 → 1.4.163

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.161",
3
+ "version": "1.4.163",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -175,20 +175,31 @@ export class ClashDaemon {
175
175
 
176
176
  /**
177
177
  * 逐个尝试候选节点并内容验证(巡检/failover/白名单守卫共用切换循环)
178
+ * @param {object} [opts]
179
+ * @param {Set<string>} [opts.occupied] 同订阅其它实例已占用的节点(互斥避让);
180
+ * 有空闲候选时跳过被占用节点;候选全被占/仅一个候选时放行(“只允许一个节点”例外)
178
181
  * @returns {{target: string|null, h2: object|null}} 第一个满足(健康或壳页)的候选;全部失败返回 {target:null}
179
182
  */
180
- async _tryRotateCandidates(c, ordered, currentNode, reason = "") {
183
+ async _tryRotateCandidates(c, ordered, currentNode, reason = "", opts = {}) {
181
184
  const tag = reason ? ` [原因: ${reason}]` : "";
185
+ // 同订阅互斥: 计算空闲候选(排除被占用与当前节点);全被占时不排除(放行)
186
+ const occ = opts.occupied || null;
187
+ const free = occ ? ordered.filter((x) => !occ.has(x) && x !== currentNode) : null;
182
188
  let target = null;
183
189
  let h2 = null;
184
190
  for (const cand of ordered) {
185
191
  if (cand === currentNode) continue;
192
+ if (free && free.length > 0 && !free.includes(cand)) {
193
+ this._log(` ⏭ [${c.proxyPort}] ${cand} 被同订阅其它实例占用,跳过`);
194
+ continue;
195
+ }
186
196
  this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand}${tag}`);
187
197
  if (!(await checker.switchToNode(cand))) {
188
198
  this._log(` ↺ ${cand} 切换失败,下一个`);
189
199
  continue;
190
200
  }
191
201
  this._markSwitch(c, cand);
202
+ c._currentNode = cand; // 同订阅互斥: 维护当前节点内存快照
192
203
  h2 = checker.checkNodeHealth();
193
204
  if (h2.healthy || this._isShellPage(h2)) {
194
205
  target = cand;
@@ -199,6 +210,120 @@ export class ClashDaemon {
199
210
  return { target, h2 };
200
211
  }
201
212
 
213
+ // ── 同订阅节点互斥(同 URL 多实例不允许同时使用同一节点,除非无空闲可用节点) ──
214
+
215
+ /** 内置实例序号(proxyPort 推导;0 = 非内置段如外部 Verge,不参与互斥) */
216
+ _instanceIndexOf(c) {
217
+ const idx = (c?.proxyPort || 0) - DISCOVER_DEFAULTS.proxyPort + 1;
218
+ return idx >= 1 && idx <= RUNTIME_MAX_INSTANCES ? idx : 0;
219
+ }
220
+
221
+ /** 同 URL 订阅的同组其它实例控制器(before=true 仅序号比自己小的) */
222
+ _mutexPeers(c, beforeOnly = false) {
223
+ const idx = this._instanceIndexOf(c);
224
+ if (!idx) return [];
225
+ let stored;
226
+ try {
227
+ stored = runtime.readStoredSubscriptions();
228
+ } catch {
229
+ return [];
230
+ }
231
+ const myUrl = stored[idx - 1]?.url;
232
+ if (!myUrl) return [];
233
+ const out = [];
234
+ for (let i = 1; i <= stored.length; i++) {
235
+ if (i === idx || stored[i - 1]?.url !== myUrl) continue;
236
+ if (beforeOnly && i > idx) continue;
237
+ const peer = this._controllers.find((x) => this._instanceIndexOf(x) === i);
238
+ if (peer && peer !== c) out.push(peer);
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /** 当前被同组其它实例占用的节点集合(无同组返回 null = 不启用避让) */
244
+ _occupiedNodes(c) {
245
+ const peers = this._mutexPeers(c);
246
+ if (!peers.length) return null;
247
+ const occ = new Set();
248
+ for (const p of peers) if (p._currentNode) occ.add(p._currentNode);
249
+ return occ.size ? occ : null;
250
+ }
251
+
252
+ /**
253
+ * 巡检常驻互斥对齐(与白名单守卫同口径,不受 failover 冷却约束):
254
+ * 与序号更小的同组实例撞节点 → 自己让位(序号小的保留);10 分钟防抖。
255
+ * 让位无空闲可用节点(候选全被占/全不可用)→ 保留现状,下轮再试。
256
+ * @returns {Promise<boolean>} true=已让位切换(调用方跳过自身 health 写入)
257
+ */
258
+ async _enforceMutex(c, node) {
259
+ if (!node || node === "?") return false;
260
+ const conflict = this._mutexPeers(c, true).find((p) => p._currentNode === node);
261
+ if (!conflict) return false;
262
+ if (Date.now() - (c._lastMutexAlignAt || 0) < 600_000) return false;
263
+ c._lastMutexAlignAt = Date.now();
264
+ this._log(
265
+ `🔒 [${c.proxyPort}] 同订阅互斥: 节点 ${node} 已被实例@${conflict.proxyPort} 占用,让位切换`,
266
+ );
267
+ const { nodes } = checker.loadHealthyList();
268
+ if (!nodes.length) return false;
269
+ const ordered = this._sortCandidates(nodes, c);
270
+ const { target, h2 } = await this._tryRotateCandidates(
271
+ c, ordered, node, "同订阅节点互斥", { occupied: this._occupiedNodes(c) },
272
+ );
273
+ if (!target) {
274
+ await checker.switchToNode(node).catch(() => {});
275
+ this._log(`🔒 [${c.proxyPort}] 互斥让位失败(无空闲可用节点),暂保留 ${node}`);
276
+ return false;
277
+ }
278
+ await this._disconnect(c);
279
+ this._markSwitchTime(c);
280
+ this._writeHealth(c, {
281
+ node: target,
282
+ healthy: h2.healthy,
283
+ httpCode: h2.httpCode,
284
+ timeTotal: h2.timeTotal,
285
+ sizeDownload: h2.sizeDownload,
286
+ title: h2.title,
287
+ lastSwitch: `同订阅互斥: ${node} → ${target}`,
288
+ });
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * 手动切换后让位: 同组其它实例若占用了新节点 → 挪它(用户显式意图优先,不挪切换者)
294
+ */
295
+ async _mutexYieldTo(c, node) {
296
+ for (const p of this._mutexPeers(c)) {
297
+ if (p._currentNode !== node || p._probing) continue;
298
+ this._useController(p);
299
+ this._log(
300
+ `🔒 [${p.proxyPort}] 同订阅互斥: 节点 ${node} 被手动切换给 [${c.proxyPort}],让位`,
301
+ );
302
+ const { nodes } = checker.loadHealthyList();
303
+ if (!nodes.length) continue;
304
+ const ordered = this._sortCandidates(nodes, p);
305
+ const { target, h2 } = await this._tryRotateCandidates(
306
+ p, ordered, node, "同订阅互斥(手动让位)", { occupied: this._occupiedNodes(p) },
307
+ );
308
+ if (!target) {
309
+ await checker.switchToNode(node).catch(() => {});
310
+ continue;
311
+ }
312
+ await this._disconnect(p);
313
+ this._markSwitchTime(p);
314
+ this._writeHealth(p, {
315
+ node: target,
316
+ healthy: h2.healthy,
317
+ httpCode: h2.httpCode,
318
+ timeTotal: h2.timeTotal,
319
+ sizeDownload: h2.sizeDownload,
320
+ title: h2.title,
321
+ lastSwitch: `同订阅互斥(手动): ${node} → ${target}`,
322
+ });
323
+ }
324
+ this._useController(c); // 恢复切换者上下文
325
+ }
326
+
202
327
  /** 将全局 api/checker 上下文指向指定控制器 */
203
328
  _useController(c) {
204
329
  api.configure({ port: c.port, secret: c.secret });
@@ -443,6 +568,7 @@ export class ClashDaemon {
443
568
  if (r.status === "started") {
444
569
  started++;
445
570
  this._log(`✓ 实例${idx} (${spec.apiPort}/${spec.mixedPort}) 自愈拉起成功,下轮发现将接管`);
571
+ await this._rediscover("实例自愈拉起");
446
572
  } else if (r.status === "failed" || r.status === "no-vendor") {
447
573
  failed++;
448
574
  this._log(`⚠ 实例${idx} 自愈拉起失败: ${r.error || r.status}`);
@@ -485,8 +611,7 @@ export class ClashDaemon {
485
611
  const node = await api
486
612
  .getCurrentNode(checker.CONFIG.groupName)
487
613
  .catch(() => "?");
488
-
489
- // 订阅信息 + 流量采样(随巡检周期,差分算速率)
614
+ if (node && node !== "?") c._currentNode = node; // 同订阅互斥: 维护当前节点内存快照
490
615
  c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
491
616
  const conn = await api.getConnections().catch(() => null);
492
617
  if (conn) {
@@ -554,25 +679,26 @@ export class ClashDaemon {
554
679
  }
555
680
 
556
681
  if (h.healthy) {
557
- c._apiFailCount = 0;
558
682
  if (Math.random() < 0.17)
559
683
  this._log(
560
684
  `✓ [${c.proxyPort}] ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
561
685
  );
562
- // 白名单守卫:节点健康但不在咱们节点列表内(如人工在 Clash Verge 界面手动切换)→ 强制切回
563
- await this._enforceWhitelist(c, node);
686
+ // 白名单守卫 + 同订阅互斥:节点健康但不在咱们节点列表内 → 强制切回;
687
+ // 与序号更小的同组实例撞节点 → 让位
688
+ if (await this._enforceWhitelist(c, node)) return;
689
+ if (await this._enforceMutex(c, node)) return;
564
690
  return;
565
691
  }
566
692
 
567
693
  // 空壳页(TikTok SSR 降级响应):链路可用、浏览器会自行渲染,
568
694
  // 切换无收益且会打断业务连接,不触发切换
569
695
  if (this._isShellPage(h)) {
570
- c._apiFailCount = 0;
571
696
  this._log(
572
697
  `~ [${c.proxyPort}] 空壳页(降级响应,链路正常),不切换 (${(h.sizeDownload / 1024).toFixed(0)}KB ${h.timeTotal.toFixed(1)}s)`,
573
698
  );
574
- // 白名单守卫与主分支同口径:壳页=链路正常,但列表外节点仍不允许
699
+ // 白名单守卫与互斥:壳页=链路正常,但列表外节点仍不允许;撞节点让位同口径
575
700
  if (await this._enforceWhitelist(c, node)) return;
701
+ if (await this._enforceMutex(c, node)) return;
576
702
  return this._writeHealth(c, {
577
703
  node,
578
704
  healthy: true,
@@ -593,8 +719,9 @@ export class ClashDaemon {
593
719
  this._log(
594
720
  `~ [${c.proxyPort}] 复测恢复(首次异常: ${firstReason}),不切换`,
595
721
  );
596
- // 白名单守卫:复测恢复同样守口径(列表外节点不允许)
722
+ // 白名单守卫与互斥:复测恢复同样守口径(列表外不允许;撞节点让位)
597
723
  if (await this._enforceWhitelist(c, node)) return;
724
+ if (await this._enforceMutex(c, node)) return;
598
725
  return this._writeHealth(c, {
599
726
  node,
600
727
  healthy: true,
@@ -637,13 +764,15 @@ export class ClashDaemon {
637
764
  `✗ [${c.proxyPort}] 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`,
638
765
  );
639
766
  // 逐个切换候选 + 内容验证,第一个满足即采用
640
- // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
767
+ // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点;
768
+ // 同订阅互斥: 避开同组其它实例占用的节点
641
769
  const ordered = this._sortCandidates(nodes, c);
642
770
  const { target, h2 } = await this._tryRotateCandidates(
643
771
  c,
644
772
  ordered,
645
773
  node,
646
774
  reason,
775
+ { occupied: this._occupiedNodes(c) },
647
776
  );
648
777
 
649
778
  if (!target) {
@@ -689,33 +818,62 @@ export class ClashDaemon {
689
818
  }
690
819
 
691
820
  /**
692
- * API 连续失败时的控制端重发现
693
- * 连续 2 次巡检异常触发一次完整重发现,防止端口漂移后失联
821
+ * 重新发现控制端(订阅应用/自愈拉起新实例后调用,或 API 连续失败时)
822
+ * 背景: controllers 是 start() 时的快照,正常运行期新拉起的内置实例(9098+)
823
+ * 不会自动进入巡检列表 —— 必须主动重发现才能接管(否则新实例永远不被巡检/上报)
824
+ * 防抖: 30s 内多次触发只执行一次(多实例同时拉起时避免重复探测)
694
825
  */
695
- async _maybeRediscover(e) {
696
- this._apiFailCount = (this._apiFailCount || 0) + 1;
697
- if (this._apiFailCount < 2) return;
698
- this._apiFailCount = 0;
826
+ async _rediscover(reason) {
827
+ const now = Date.now();
828
+ if (now - (this._lastRediscoverAt || 0) < 30_000) return;
829
+ this._lastRediscoverAt = now;
699
830
  invalidateDiscoverCache();
700
- this._log(`API 连续失败,重新发现控制端... (${e?.message || e})`);
701
831
  try {
702
832
  const discs = await discoverAll(this._discoverOpts);
833
+ // 按端口迁移旧控制器运行状态(切换历史/冷却锚点/订阅快照等),
834
+ // 避免重发现后 failover 冷却锚点丢失导致反复切换
835
+ const prev = new Map(this._controllers.map((c) => [c.port, c]));
703
836
  const seen = new Set();
704
837
  this._controllers = discs.filter((d) => {
705
838
  if (seen.has(d.port)) return false;
706
839
  seen.add(d.port);
840
+ const old = prev.get(d.port);
841
+ if (old) {
842
+ d._meta = old._meta;
843
+ d._switchHistory = old._switchHistory;
844
+ d._lastSwitchAt = old._lastSwitchAt;
845
+ d._restartSeq = old._restartSeq;
846
+ d._trafficLast = old._trafficLast;
847
+ d._traffic = old._traffic;
848
+ d._subsSnapshot = old._subsSnapshot;
849
+ d._applyResult = old._applyResult;
850
+ d._lastProbeAt = old._lastProbeAt;
851
+ d._currentNode = old._currentNode;
852
+ d._lastMutexAlignAt = old._lastMutexAlignAt;
853
+ }
707
854
  return true;
708
855
  });
709
856
  this._primary = this._controllers[0] || null;
710
- this._lastDiscoverKey = "";
857
+ this._lastDiscoverKey = ""; // 强制下轮日志重新打印控制端列表
711
858
  this._log(
712
- `✓ 重新选定 ${this._controllers.length} 个控制端: ${this._controllers.map((c) => `${c.clientName}@${c.port}`).join(", ") || "无"}`,
859
+ `✓ ${reason}: 重新发现 ${this._controllers.length} 个控制端: ${this._controllers.map((c) => `${c.clientName}@${c.port}`).join(", ") || "无"}`,
713
860
  );
714
861
  } catch (err) {
715
- this._log(`重新发现控制端失败: ${err.message}`);
862
+ this._log(`${reason}: 重新发现控制端失败: ${err.message}`);
716
863
  }
717
864
  }
718
865
 
866
+ /**
867
+ * API 连续失败时的控制端重发现
868
+ * 连续 2 次巡检异常触发一次完整重发现,防止端口漂移后失联
869
+ */
870
+ async _maybeRediscover(e) {
871
+ this._apiFailCount = (this._apiFailCount || 0) + 1;
872
+ if (this._apiFailCount < 2) return;
873
+ this._apiFailCount = 0;
874
+ await this._rediscover(`API 连续失败 (${e?.message || e})`);
875
+ }
876
+
719
877
  /**
720
878
  * 白名单守卫:当前 Clash Verge 正在使用的节点必须 ∈ 候选集(咱们节点列表)
721
879
  *
@@ -748,6 +906,7 @@ export class ClashDaemon {
748
906
  ordered,
749
907
  node,
750
908
  "白名单守卫",
909
+ { occupied: this._occupiedNodes(c) },
751
910
  );
752
911
  if (!target) {
753
912
  // 与 failover/巡检切换口径一致:候选全部不可用时恢复原节点
@@ -898,9 +1057,12 @@ export class ClashDaemon {
898
1057
  }
899
1058
  this._markSwitch(c, cmd.node);
900
1059
  this._markSwitchTime(c);
1060
+ c._currentNode = cmd.node; // 同订阅互斥: 维护当前节点内存快照
901
1061
  const h = checker.checkNodeHealth();
902
1062
  // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
903
1063
  await this._disconnect(c);
1064
+ // 同订阅互斥: 让同组占用新节点的其它实例让位(用户显式意图优先,不挪切换者)
1065
+ await this._mutexYieldTo(c, cmd.node);
904
1066
  this._log(
905
1067
  `🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
906
1068
  );
@@ -986,13 +1148,15 @@ export class ClashDaemon {
986
1148
  return;
987
1149
  }
988
1150
  // 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
989
- // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
1151
+ // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点;
1152
+ // 同订阅互斥: 避开同组其它实例占用的节点
990
1153
  const ordered = this._sortCandidates(nodes, c);
991
1154
  const { target, h2 } = await this._tryRotateCandidates(
992
1155
  c,
993
1156
  ordered,
994
1157
  node,
995
1158
  reason,
1159
+ { occupied: this._occupiedNodes(c) },
996
1160
  );
997
1161
  // 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
998
1162
  this._markSwitchTime(c);
@@ -1048,6 +1212,8 @@ export class ClashDaemon {
1048
1212
  c._applyResult = { ok: true, changed: true, cleared: true, at: Date.now() };
1049
1213
  this._log(`📋 订阅已清空: 回收内置 mihomo ${killed} 个进程,不再接管端口`);
1050
1214
  this._writeHealth(c, { node: "?", healthy: null });
1215
+ // 实例已回收 → 重发现让死实例退出巡检列表(否则每轮巡检持续报"未运行")
1216
+ await this._rediscover("订阅清空");
1051
1217
  return;
1052
1218
  }
1053
1219
  this._log(`📋 收到订阅配置: ${subs.length} 条,按条启动独立实例(9097+每条递增)...`);
@@ -1077,6 +1243,9 @@ export class ClashDaemon {
1077
1243
  } else {
1078
1244
  this._log(`⚠ ${failed.length}/${subs.length} 个实例应用失败(未持久化,待下轮心跳重试): ${failed.map((f) => `实例${f.index}`).join(",")}`);
1079
1245
  }
1246
+ // 实例集可能已变化(成功拉起新实例/回收多余实例,含部分失败场景)→ 重发现同步巡检列表
1247
+ // (controllers 是启动快照;不重发现则新实例永远不被巡检/上报,214 实采踩坑)
1248
+ await this._rediscover("订阅应用");
1080
1249
  c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
1081
1250
  c._applyResult = {
1082
1251
  ok: allOk,
@@ -78,12 +78,13 @@ const UI_MANIFEST = { file: "metacubexd-dist.tgz", sha256: "93eb334af7e37d213317
78
78
 
79
79
  /**
80
80
  * 内核/面板下载源(顺序 failover):
81
- * 1. ~/.tt-help.json → clash.vendorBase 或环境变量 TT_VENDOR_BASE(覆盖)
82
- * 2. 配置的服务端(~/.tt-help.json → server)→ {server}/scripts/vendor
83
- * (内网 watch 优先:213 等内网机器走内网下载,不绕公网)
84
- * 3. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
85
- * 4. 自建服务器内网(农场机器直连)
86
- * 5. GitHub Releases(仅海外/兜底;农场不可达属预期)
81
+ * 1. ~/.tt-help.json → clash.vendorBase / 环境变量 TT_VENDOR_BASE(显式覆盖,逃生舱)
82
+ * 2. HTTPS 域名镜像(最优默认源:域名+TLS+CDN,比公网 IP 直连更稳)
83
+ * 3. 配置的服务端(~/.tt-help.json → server)→ {server}/scripts/vendor
84
+ * (内网机器的 server 为内网地址,作为 CDN 不可达时的就近回退)
85
+ * 4. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
86
+ * 5. 自建服务器内网(农场机器直连)
87
+ * 6. GitHub Releases(仅海外/兜底;农场不可达属预期)
87
88
  */
88
89
  export function vendorBases() {
89
90
  const urls = [];
@@ -95,6 +96,14 @@ export function vendorBases() {
95
96
  if (existsSync(cfgPath)) {
96
97
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
97
98
  push(cfg?.clash?.vendorBase);
99
+ }
100
+ } catch {}
101
+ push(process.env.TT_VENDOR_BASE);
102
+ push("https://call.huochedejia.com/vendor");
103
+ try {
104
+ const cfgPath = join(homedir(), ".tt-help.json");
105
+ if (existsSync(cfgPath)) {
106
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
98
107
  const server = String(cfg?.server || "").replace(/\/+$/, "");
99
108
  // 显式配置的服务端地址 → 推导 vendor 下载源(未配置/非 http 则跳过,避免污染 127.0.0.1 默认值)
100
109
  if (/^https?:\/\//.test(server) && !/^https?:\/\/127\.0\.0\.1/.test(server)) {
@@ -102,7 +111,6 @@ export function vendorBases() {
102
111
  }
103
112
  }
104
113
  } catch {}
105
- push(process.env.TT_VENDOR_BASE);
106
114
  push("http://117.71.53.99:17301/scripts/vendor");
107
115
  push("http://172.18.154.201:3001/scripts/vendor");
108
116
  push("https://github.com/MetaCubeX/mihomo/releases/download");
@@ -446,7 +454,9 @@ export function buildConfig({ subscriptions, apiPort, secret, mixedPort }) {
446
454
  ` ${s.name}:`,
447
455
  " type: http",
448
456
  ` url: ${s.url}`,
449
- " interval: 86400",
457
+ // 订阅文件+userinfo(subscription-userinfo 响应头)刷新周期: 1 小时。
458
+ // 同一订阅可配多实例,各自快照时刻不同;24h 周期会导致页面剩余流量显示差异巨大(214 实测一天差 447GB)
459
+ " interval: 3600",
450
460
  ` path: ./providers/${s.name}.yaml`,
451
461
  " proxy: DIRECT",
452
462
  " health-check:",
@@ -4779,6 +4779,10 @@ async function wdSaveProxySettings() {
4779
4779
  return s.trim();
4780
4780
  })
4781
4781
  .filter(Boolean);
4782
+ if (lines.length > 8) {
4783
+ alert("最多 8 条订阅(每条一个独立 mihomo 实例,超出部分无法巡检);当前 " + lines.length + " 行");
4784
+ return;
4785
+ }
4782
4786
  try {
4783
4787
  var res = await fetch("/api/watchdog/set-proxy-settings", {
4784
4788
  method: "POST",
@@ -5278,16 +5282,53 @@ async function wdSetErrorTrigger() {
5278
5282
 
5279
5283
  // ── 代理节点区域(看门狗面板中每个客户端卡片,支持多控制端通道) ──
5280
5284
 
5285
+ /** 内置实例端口段(与 discover.js RUNTIME_MAX_INSTANCES=8 对齐):代理 7897+n / API 9097+n */
5286
+ var _SUBS_BASE_PROXY_PORT = 7897;
5287
+ var _SUBS_BASE_API_PORT = 9097;
5288
+ var _SUBS_MAX_INSTANCES = 8;
5289
+
5290
+ /** 服务端配置的订阅列表(proxySettings[userId].subscriptions) */
5291
+ function wdSubsConfigured(userId) {
5292
+ if (_wdData && _wdData.proxySettings && _wdData.proxySettings[userId]) {
5293
+ return _wdData.proxySettings[userId].subscriptions || [];
5294
+ }
5295
+ return [];
5296
+ }
5297
+
5298
+ /**
5299
+ * 代理区域入口:
5300
+ * 内置实例通道(7897+n)→ 订阅总览表(一行 = 一条订阅 = 一个实例)
5301
+ * 外部通道(Verge 等) → 独立通道行(节点 + 自身订阅摘要)
5302
+ * 无通道 → 配置过订阅显示总览(实例离线态);否则单控制端旧视图
5303
+ */
5281
5304
  function renderProxySection(c, userId) {
5282
- var channels = c.clashHealthChannels || [];
5305
+ var channels = (c.clashHealthChannels || []).slice();
5306
+ channels.sort(function (a, b) {
5307
+ return (a.port || 0) - (b.port || 0);
5308
+ });
5283
5309
  if (channels.length > 0) {
5284
- var html = "";
5310
+ var builtin = [];
5311
+ var external = [];
5285
5312
  for (var i = 0; i < channels.length; i++) {
5286
- html += renderProxyChannel(channels[i], userId);
5313
+ var idx = (channels[i].port || 0) - _SUBS_BASE_PROXY_PORT;
5314
+ if (idx >= 0 && idx < _SUBS_MAX_INSTANCES) builtin.push(channels[i]);
5315
+ else external.push(channels[i]);
5316
+ }
5317
+ var html = renderSubsOverview(c, userId, builtin, channels);
5318
+ for (var j = 0; j < external.length; j++) {
5319
+ // 外部通道的订阅配置入口:仅当没有总览区时显示(配置入口全局收敛为一个)
5320
+ html += renderExternalChannel(external[j], userId, !html);
5287
5321
  }
5288
5322
  return html;
5289
5323
  }
5290
- // 兼容旧版:仅主控制端(legacy clashHealth)
5324
+ if (wdSubsConfigured(userId).length > 0) {
5325
+ return renderSubsOverview(c, userId, [], []);
5326
+ }
5327
+ return renderProxyLegacy(c, userId);
5328
+ }
5329
+
5330
+ /** 兼容旧版:仅主控制端(legacy clashHealth) */
5331
+ function renderProxyLegacy(c, userId) {
5291
5332
  var h = c.clashHealth;
5292
5333
  if (!h || !h.node) return "";
5293
5334
  var nodes = (h.meta && h.meta.healthyNodes) || [];
@@ -5300,8 +5341,8 @@ function renderProxySection(c, userId) {
5300
5341
  );
5301
5342
  }
5302
5343
 
5303
- /** 单个控制端通道的代理区域 */
5304
- function renderProxyChannel(ch, userId) {
5344
+ /** 外部代理通道(Verge 等非内置实例):节点行 + 节点列表 + 自身订阅摘要 */
5345
+ function renderExternalChannel(ch, userId, withConfig) {
5305
5346
  if (!ch || !ch.node) return "";
5306
5347
  var nodes = (ch.meta && ch.meta.healthyNodes) || [];
5307
5348
  var delays = (ch.meta && ch.meta.healthyNodeDelays) || {};
@@ -5309,10 +5350,284 @@ function renderProxyChannel(ch, userId) {
5309
5350
  return (
5310
5351
  renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
5311
5352
  renderNodeList(nodes, ch.node, userId, ch.port || 0, delays) +
5312
- renderSubscriptions(ch.meta && ch.meta.proxy, userId)
5353
+ renderSubscriptions(ch.meta && ch.meta.proxy, userId, withConfig !== false)
5354
+ );
5355
+ }
5356
+
5357
+ // ── 订阅总览区(每客户端一个) ──
5358
+
5359
+ /** 订阅应用结果提示(daemon 最近一次 setSubscription 执行反馈) */
5360
+ function renderApplyResult(ar) {
5361
+ if (!ar || !ar.at) return "";
5362
+ if (ar.ok === false) {
5363
+ return styled(
5364
+ "div",
5365
+ "font-size:11px;margin:2px 0 4px;padding:4px 6px;background:#3b1d1d;border:1px solid #7f1d1d;border-radius:4px;color:#f87171;line-height:1.4;width:100%;box-sizing:border-box",
5366
+ "❌ 订阅应用失败: " + (ar.error || "未知错误") +
5367
+ styled("span", "color:#94a3b8;margin-left:6px;font-size:10px", new Date(ar.at).toLocaleTimeString()),
5368
+ );
5369
+ }
5370
+ if (ar.changed) {
5371
+ return styled(
5372
+ "div",
5373
+ "font-size:11px;color:#4ade80;width:100%",
5374
+ "✓ 订阅应用成功 (" + (ar.count || "?") + " 条," + new Date(ar.at).toLocaleTimeString() + ")",
5375
+ );
5376
+ }
5377
+ return "";
5378
+ }
5379
+
5380
+
5381
+ /**
5382
+ * 订阅总览表:
5383
+ * 订阅名 | 实例 | 代理端口(业务 --proxy 用) | 当前节点(可展开切换) | 用量/到期/速率
5384
+ * 一行 = 一条订阅 = 一个内置 mihomo 实例;通道离线时显示占位行(配置可见,实例自愈中)
5385
+ */
5386
+ function renderSubsOverview(c, userId, builtinChannels, allChannels) {
5387
+ var configured = wdSubsConfigured(userId);
5388
+ var byPort = {};
5389
+ for (var i = 0; i < builtinChannels.length; i++) {
5390
+ byPort[builtinChannels[i].port] = builtinChannels[i];
5391
+ }
5392
+ var count = configured.length;
5393
+ for (var p in byPort) {
5394
+ var n = parseInt(p, 10) - _SUBS_BASE_PROXY_PORT + 1;
5395
+ if (n > count) count = n;
5396
+ }
5397
+ if (!count) return "";
5398
+
5399
+ // 订阅应用结果(主控制端默认健康文件优先,回退任一通道快照)
5400
+ var ar = null;
5401
+ if (c.clashHealth && c.clashHealth.meta && c.clashHealth.meta.proxy) {
5402
+ ar = c.clashHealth.meta.proxy.applyResult;
5403
+ }
5404
+ if (!ar || !ar.at) {
5405
+ for (var k = 0; k < (allChannels || []).length; k++) {
5406
+ var pp = allChannels[k].meta && allChannels[k].meta.proxy;
5407
+ if (pp && pp.applyResult && pp.applyResult.at) {
5408
+ ar = pp.applyResult;
5409
+ break;
5410
+ }
5411
+ }
5412
+ }
5413
+
5414
+ // 标题行:入口收敛为全局唯一一个配置按钮
5415
+ var head = styled(
5416
+ "div",
5417
+ "padding:6px 8px;background:#111;display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
5418
+ styled("span", "font-size:14px", "🛜") +
5419
+ styled("span", "color:#e0e0e0;font-weight:bold", "代理订阅") +
5420
+ styled("span", "color:#94a3b8", count + " 条") +
5421
+ tag(
5422
+ "button",
5423
+ {
5424
+ onclick: "wdShowProxyModal('" + esc(userId) + "')",
5425
+ style:
5426
+ "padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
5427
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
5428
+ title: "配置该客户端的代理订阅(每行一个地址,保存后自动热应用,无需重启)",
5429
+ },
5430
+ "配置 ⚙️",
5431
+ ) +
5432
+ styled(
5433
+ "span",
5434
+ "color:#555;font-size:10px;margin-left:auto",
5435
+ "一条订阅 = 一个独立 mihomo 实例",
5436
+ ) +
5437
+ renderApplyResult(ar),
5438
+ );
5439
+
5440
+ // tab 栏(即概览: 状态点+订阅名+端口;临期/超量红标、离线灰)+ 详情区(仅激活显示)
5441
+ // 选中态跨刷新保留(_wdSubsActiveTab),点击纯 DOM 切换不重渲染
5442
+ var activePort = _wdSubsActiveTab[wdEscId(userId)] || _SUBS_BASE_PROXY_PORT;
5443
+ var tabsBar = styled(
5444
+ "div",
5445
+ "display:flex;gap:2px;padding:0 8px;background:#16161e;border-bottom:1px solid #2a2a3a;overflow-x:auto;flex-wrap:wrap",
5446
+ (function () {
5447
+ var out = "";
5448
+ for (var m = 1; m <= count; m++) {
5449
+ out += renderSubsTab(m, configured[m - 1], byPort[_SUBS_BASE_PROXY_PORT + m - 1], userId,
5450
+ _SUBS_BASE_PROXY_PORT + m - 1 === activePort);
5451
+ }
5452
+ return out;
5453
+ })(),
5454
+ );
5455
+ var bodies = "";
5456
+ for (var m2 = 1; m2 <= count; m2++) {
5457
+ bodies += renderSubsBody(m2, configured[m2 - 1], byPort[_SUBS_BASE_PROXY_PORT + m2 - 1], userId,
5458
+ _SUBS_BASE_PROXY_PORT + m2 - 1 === activePort);
5459
+ }
5460
+ return styled(
5461
+ "div",
5462
+ "margin-top:6px;border:1px solid #2a2a3a;border-radius:8px;overflow:hidden",
5463
+ head + tabsBar + bodies,
5464
+ );
5465
+ }
5466
+
5467
+ /** 订阅选中 tab(userId → 代理端口),跨刷新保留 */
5468
+ var _wdSubsActiveTab = {};
5469
+
5470
+ /** tab 点击切换(纯 DOM 操作,不重渲染;与节点展开/切换命令同用代理端口路由) */
5471
+ function wdSubsTab(userId, port) {
5472
+ _wdSubsActiveTab[wdEscId(userId)] = port;
5473
+ for (var i = 0; i < _SUBS_MAX_INSTANCES; i++) {
5474
+ var p = _SUBS_BASE_PROXY_PORT + i;
5475
+ var tab = document.getElementById("wdSubsTab-" + wdEscId(userId) + "-" + p);
5476
+ var body = document.getElementById("wdSubsBody-" + wdEscId(userId) + "-" + p);
5477
+ if (!tab || !body) continue;
5478
+ var active = p === port;
5479
+ tab.style.background = active ? "#2a2a3a" : "transparent";
5480
+ tab.style.color = active ? "#fff" : "#94a3b8";
5481
+ tab.style.borderBottom = active ? "2px solid #60a5fa" : "2px solid transparent";
5482
+ body.style.display = active ? "block" : "none";
5483
+ }
5484
+ }
5485
+
5486
+ /** tab 按钮(即概览条目): 状态点 + 订阅名 + 端口;临期/超量红标、离线灰 */
5487
+ function renderSubsTab(n, conf, ch, userId, active) {
5488
+ var port = _SUBS_BASE_PROXY_PORT + n - 1;
5489
+ var meta = (ch && ch.meta) || {};
5490
+ var sub = ((meta.proxy || {}).subscriptions || [])[0] || null;
5491
+ var online = !!(ch && ch.node);
5492
+ var name = (sub && sub.name) || (conf && conf.name) || "实例" + n;
5493
+ // 临期/超量判定(与详情区同口径)
5494
+ var danger = false;
5495
+ if (sub) {
5496
+ var used = (sub.upload || 0) + (sub.download || 0);
5497
+ var total = sub.total || 0;
5498
+ var pct = total > 0 ? Math.round(((total - used) / total) * 100) : null;
5499
+ var daysLeft = sub.expire ? Math.round((sub.expire * 1000 - Date.now()) / 86400000) : null;
5500
+ danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
5501
+ }
5502
+ var dot = !online ? "○" : ch.healthy ? "●" : "●";
5503
+ var dotColor = !online ? "#555" : ch.healthy ? "#4ade80" : "#f87171";
5504
+ return tag(
5505
+ "button",
5506
+ {
5507
+ id: "wdSubsTab-" + wdEscId(userId) + "-" + port,
5508
+ onclick: "wdSubsTab('" + esc(userId) + "'," + port + ")",
5509
+ style:
5510
+ "padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap;border:none;flex-shrink:0;" +
5511
+ "background:" + (active ? "#2a2a3a" : "transparent") + ";" +
5512
+ "color:" + (active ? "#fff" : "#94a3b8") + ";" +
5513
+ "border-bottom:2px solid " + (active ? "#60a5fa" : "transparent") + ";" +
5514
+ (online ? "" : "opacity:0.6;"),
5515
+ title: "实例" + n + " · 代理 " + port + " · API " + (_SUBS_BASE_API_PORT + n - 1) + (online ? (ch.healthy ? " · 健康" : " · 异常") : " · 未上线"),
5516
+ },
5517
+ styled("span", "color:" + dotColor, dot) +
5518
+ " " + name + styled("span", "color:#60a5fa;font-size:10px", " :" + port) +
5519
+ (danger ? styled("span", "color:#f87171;margin-left:2px", "⚠️") : ""),
5520
+ );
5521
+ }
5522
+
5523
+ /** tab 详情区: 当前节点 + 端口/实例 + 用量/到期 + 速率 + 节点列表(可展开) */
5524
+ function renderSubsBody(n, conf, ch, userId, active) {
5525
+ var port = _SUBS_BASE_PROXY_PORT + n - 1;
5526
+ var apiPort = _SUBS_BASE_API_PORT + n - 1;
5527
+ var meta = (ch && ch.meta) || {};
5528
+ var proxy = meta.proxy || {};
5529
+ var sub = (proxy.subscriptions || [])[0] || null;
5530
+ var online = !!(ch && ch.node);
5531
+ var nodes = meta.healthyNodes || [];
5532
+ var delays = meta.healthyNodeDelays || {};
5533
+
5534
+ // 行1: 当前节点 + 健康状态 + 节点列表展开
5535
+ var line1;
5536
+ if (online) {
5537
+ var dotColor = ch.healthy ? "#4ade80" : "#f87171";
5538
+ var status = ch.healthy
5539
+ ? (ch.timeTotal || 0).toFixed(1) + "s " + ((ch.sizeDownload || 0) / 1024).toFixed(0) + "KB"
5540
+ : ch.error || "异常";
5541
+ line1 = styled(
5542
+ "div",
5543
+ "display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:13px;flex-wrap:wrap",
5544
+ styled("span", "color:" + dotColor, "●") +
5545
+ styled("span", "color:#e0e0e0;font-weight:bold;word-break:break-all", ch.node) +
5546
+ styled("span", "color:#666;font-size:11px", "| " + status) +
5547
+ (ch.stale ? styled("span", "color:#fbbf24;font-size:10px", "数据过期" + ch.ageSeconds + "s") : "") +
5548
+ (nodes.length
5549
+ ? tag(
5550
+ "button",
5551
+ {
5552
+ onclick: "wdToggleNodes('" + esc(userId) + "'," + port + ")",
5553
+ style:
5554
+ "margin-left:auto;padding:2px 8px;font-size:11px;background:#333;color:#60a5fa;" +
5555
+ "border:1px solid #444;border-radius:3px;cursor:pointer;flex-shrink:0",
5556
+ title: "查看/切换该实例的可用节点",
5557
+ },
5558
+ nodes.length + " 个节点 ▸",
5559
+ )
5560
+ : ""),
5561
+ );
5562
+ } else {
5563
+ line1 = styled(
5564
+ "div",
5565
+ "display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:12px",
5566
+ styled("span", "color:#555", "○") +
5567
+ styled("span", "color:#888", "实例未运行(等待自愈拉起,配置保留)"),
5568
+ );
5569
+ }
5570
+
5571
+ // 行2: 实例/端口(业务 --proxy 用代理端口)
5572
+ var line2 = styled(
5573
+ "div",
5574
+ "padding:2px 10px;font-size:11px;color:#888;display:flex;gap:10px;flex-wrap:wrap",
5575
+ styled("span", null, "实例 #" + n) +
5576
+ tag(
5577
+ "span",
5578
+ {
5579
+ style: "color:#60a5fa;font-family:monospace",
5580
+ title: "业务命令 --proxy 用此端口",
5581
+ },
5582
+ "代理 127.0.0.1:" + port,
5583
+ ) +
5584
+ styled("span", "color:#555;font-family:monospace", "API " + apiPort) +
5585
+ (proxy.runtime && proxy.runtime.status ? styled("span", "color:#555", "内核" + (proxy.runtime.status || "")) : ""),
5586
+ );
5587
+
5588
+ // 行3: 用量/到期 + 行4: 速率
5589
+ var line34 = "";
5590
+ if (sub) {
5591
+ var used = (sub.upload || 0) + (sub.download || 0);
5592
+ var total = sub.total || 0;
5593
+ var remain = total > 0 ? total - used : 0;
5594
+ var pct = total > 0 ? Math.round((remain / total) * 100) : null;
5595
+ var daysLeft = sub.expire ? Math.round((sub.expire * 1000 - Date.now()) / 86400000) : null;
5596
+ var danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
5597
+ var parts = [];
5598
+ if (sub.proxies) parts.push(sub.proxies + " 节点");
5599
+ parts.push(total > 0 ? "剩 " + fmtGB(remain) + (pct !== null ? " (" + pct + "%)" : "") : "用量未知");
5600
+ if (sub.expire) {
5601
+ parts.push("到 " + new Date(sub.expire * 1000).toLocaleDateString() + (daysLeft !== null ? "(剩" + daysLeft + "天)" : ""));
5602
+ }
5603
+ line34 = styled(
5604
+ "div",
5605
+ "padding:2px 10px 4px;font-size:11px" + (danger ? ";background:#3b1d1d" : ""),
5606
+ styled("span", "color:" + (danger ? "#f87171" : "#94a3b8"), "📡 " + parts.join(" · ") + (danger ? " ⚠️" : "")),
5607
+ );
5608
+ }
5609
+ var t = proxy.traffic;
5610
+ if (t && (t.downRate > 0 || t.upRate > 0)) {
5611
+ line34 += styled(
5612
+ "div",
5613
+ "padding:2px 10px 6px;font-size:11px;color:#666",
5614
+ "⚡ ↓" + fmtSpeed(t.downRate) + " / ↑" + fmtSpeed(t.upRate),
5615
+ );
5616
+ }
5617
+
5618
+ var bodyInner = line1 + line2 + line34 +
5619
+ (online && nodes.length ? renderNodeList(nodes, ch.node, userId, port, delays) : "");
5620
+ return tag(
5621
+ "div",
5622
+ {
5623
+ id: "wdSubsBody-" + wdEscId(userId) + "-" + port,
5624
+ style: "display:" + (active ? "block" : "none") + ";border-top:1px solid #22222f",
5625
+ },
5626
+ bodyInner,
5313
5627
  );
5314
5628
  }
5315
5629
 
5630
+
5316
5631
  function fmtGB(bytes) {
5317
5632
  if (!bytes || bytes <= 0) return "0";
5318
5633
  var gb = bytes / 1073741824;
@@ -5328,31 +5643,14 @@ function fmtSpeed(bytesPerSec) {
5328
5643
  /**
5329
5644
  * 订阅用量/到期 + 实时速率 + 订阅配置入口(数据: clashHealth.meta.proxy)
5330
5645
  * 临期(<7天)或超量(剩余<10%)标红
5646
+ * @param {boolean} [withConfig] 是否渲染配置入口按钮(默认 true;多实例总览区已收敛唯一入口时传 false)
5331
5647
  */
5332
- function renderSubscriptions(proxy, userId) {
5648
+ function renderSubscriptions(proxy, userId, withConfig) {
5333
5649
  proxy = proxy || {};
5334
5650
  var subs = proxy.subscriptions || [];
5335
5651
  var rows = "";
5336
5652
  // 订阅应用结果(daemon 最近一次 setSubscription 执行反馈;随下次应用/daemon 重启刷新)
5337
- var ar = proxy.applyResult;
5338
- if (ar && ar.at) {
5339
- if (ar.ok === false) {
5340
- rows +=
5341
- styled(
5342
- "div",
5343
- "font-size:11px;margin:2px 0 4px;padding:4px 6px;background:#3b1d1d;border:1px solid #7f1d1d;border-radius:4px;color:#f87171;line-height:1.4",
5344
- "❌ 订阅应用失败: " + esc(ar.error || "未知错误") +
5345
- styled("span", "color:#94a3b8;margin-left:6px;font-size:10px", new Date(ar.at).toLocaleTimeString()),
5346
- );
5347
- } else if (ar.changed) {
5348
- rows +=
5349
- styled(
5350
- "div",
5351
- "font-size:11px;margin:2px 0 4px;color:#4ade80",
5352
- "✓ 订阅应用成功 (" + (ar.count || "?") + " 条," + new Date(ar.at).toLocaleTimeString() + ")",
5353
- );
5354
- }
5355
- }
5653
+ rows += renderApplyResult(proxy.applyResult);
5356
5654
  // 实时速率
5357
5655
  var t = proxy.traffic;
5358
5656
  if (t && (t.downRate > 0 || t.upRate > 0)) {
@@ -5389,7 +5687,8 @@ function renderSubscriptions(proxy, userId) {
5389
5687
  );
5390
5688
  }
5391
5689
  // 配置入口(摘要双来源: 后台配置优先,回退 daemon 实际生效快照;显示条数+订阅名)
5392
- var configured = (window._wdData && _wdData.proxySettings && _wdData.proxySettings[userId] && _wdData.proxySettings[userId].subscriptions) || proxy.subscriptions || [];
5690
+ if (withConfig !== false) {
5691
+ var configured = (window._wdData && _wdData.proxySettings && _wdData.proxySettings[userId] && _wdData.proxySettings[userId].subscriptions) || proxy.subscriptions || [];
5393
5692
  var cfgNames = configured
5394
5693
  .map(function (s) {
5395
5694
  return s.name || String(s.url || "").replace(/^https?:\/\//, "").split("/")[0];
@@ -5416,6 +5715,7 @@ function renderSubscriptions(proxy, userId) {
5416
5715
  "配置 ⚙️",
5417
5716
  ),
5418
5717
  );
5718
+ }
5419
5719
  return rows;
5420
5720
  }
5421
5721
 
@@ -786,7 +786,7 @@
786
786
  style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
787
787
  <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
788
788
  <div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span></div>
789
- <div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一条订阅 = 一个独立 mihomo 实例</b>:代理端口依次 127.0.0.1:7897、7898、7899…(API 端口 9097 起,同步 +1),各命令用 --proxy 参数指定使用哪个。<br>保存后客户端下次心跳时自动热应用(无需重启),应用成功后此处可看到剩余流量与到期时间;临期(&lt;7天)或超量(剩余&lt;10%)会标红。</div>
789
+ <div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一行 = 一个独立 mihomo 实例,最多 8 行</b>;<b style="color:#fbbf24">同一地址可配置多行</b>(各自独立使用不同节点):按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”tabs 可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(&lt;7天)或超量(剩余&lt;10%)在 tab 上标红。</div>
790
790
  <textarea id="wdProxySubsText" rows="6" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx\nhttps://another-airport.com/sub?token=yyyy"
791
791
  style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
792
792
  <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
@@ -831,7 +831,12 @@ async function handleSetErrorTrigger(params, body, res) {
831
831
  sendJSON(res, 200, { ok: true, config: cleaned });
832
832
  }
833
833
 
834
- /** 配置某 agent 的代理订阅(心跳下发 → daemon 热应用;空列表表示清空) */
834
+ /**
835
+ * 配置某 agent 的代理订阅(心跳下发 → daemon 热应用;空列表表示清空)
836
+ * 允许同一订阅地址配置多行(每行一个独立实例,可各自使用不同节点)
837
+ * 上限 8 条: 与 discover.js RUNTIME_MAX_INSTANCES=8 对齐(第 9 条实例端口会超出
838
+ * 巡检枚举段 9097-9104,永远进不了 daemon 巡检列表)
839
+ */
835
840
  async function handleSetProxySettings(params, body, res) {
836
841
  const { userId, subscriptions } = body || {};
837
842
  if (!userId) {
@@ -842,16 +847,19 @@ async function handleSetProxySettings(params, body, res) {
842
847
  sendJSON(res, 400, { error: "subscriptions 须为数组" });
843
848
  return;
844
849
  }
845
- // 归一化: 字符串或 {name,url},去重去空
846
- const seen = new Set();
850
+ // 归一化: 字符串或 {name,url},去空去非法;不去重(同 URL 多行 = 多实例)
851
+ const MAX_SUBS = 8;
847
852
  const cleaned = [];
848
853
  for (const it of subscriptions) {
849
854
  const url = typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
850
- if (!url || !/^https?:\/\//.test(url) || seen.has(url)) continue;
851
- seen.add(url);
855
+ if (!url || !/^https?:\/\//.test(url)) continue;
852
856
  const name = typeof it === "object" && it.name ? String(it.name).trim() : "";
853
857
  cleaned.push({ name, url });
854
858
  }
859
+ if (cleaned.length > MAX_SUBS) {
860
+ sendJSON(res, 400, { error: `最多 ${MAX_SUBS} 条(每条一个独立 mihomo 实例,超出部分无法巡检)` });
861
+ return;
862
+ }
855
863
  setProxySettingsStored(userId, { subscriptions: cleaned });
856
864
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
857
865
  console.error(`[watchdog ${ts}] ${userId} 代理订阅配置已更新: ${cleaned.length} 条`);
@@ -113,7 +113,7 @@ const NETWORK_ERROR_PATTERNS = [
113
113
  ];
114
114
  const NETWORK_ERROR_RE = new RegExp(NETWORK_ERROR_PATTERNS.join("|"), "i");
115
115
  const ERROR_SCAN_INTERVAL_MS = 30_000; // 日志扫描间隔
116
- const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 → 重启全部命令)
116
+ const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 → 重启对应通道命令)
117
117
  const ERROR_TRIGGER_COUNT = 10; // 连续错误阈值
118
118
  const ERROR_MIN_MS = 5 * 60 * 1000; // 最小持续时间(时间只做最小值判断)
119
119
  const ERROR_BREAK_MS = 120_000; // 两次错误间隔超过则视为连续中断,重置计数
@@ -145,6 +145,27 @@ function isWebserverMode(cmdDef) {
145
145
  return /--webserver(?=\s|=|$)/.test(cmdDef?.command || "");
146
146
  }
147
147
 
148
+ /**
149
+ * 解析命令使用的代理端口(多实例通道路由):
150
+ * --proxy http://127.0.0.1:7898 / --proxy 127.0.0.1:7898 / --proxy=:7898 → 7898
151
+ * 无 --proxy(走系统代理 = 主控制端)→ null
152
+ * 用于: 错误触发 failover / 代理切换重启 时路由到出错命令实际使用的实例,
153
+ * 避免多实例下切错对象(用 7898 的命令报错却切了 7897 的节点)。
154
+ */
155
+ function parseCmdProxyPort(cmdDef) {
156
+ const s = cmdDef?.command || "";
157
+ const m = s.match(/--proxy(?:=|\s+)(\S+)/);
158
+ if (!m) return null;
159
+ const pm = m[1].match(/:(\d{2,5})(?:\/|$)/);
160
+ return pm ? parseInt(pm[1], 10) : null;
161
+ }
162
+
163
+ /** 从 daemon 健康数据提取主控制端代理端口(meta.proxyUrl = http://127.0.0.1:7897) */
164
+ function mainProxyPortOf(health) {
165
+ const m = String(health?.meta?.proxyUrl || "").match(/:(\d{2,5})\/?$/);
166
+ return m ? parseInt(m[1], 10) : null;
167
+ }
168
+
148
169
  /** 自动发现 daemon.js 路径 */
149
170
  function findDaemonPath() {
150
171
  // 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
@@ -459,35 +480,54 @@ export class WatchdogAgent {
459
480
  try {
460
481
  const health = getClashHealth();
461
482
  if (!health) return; // daemon 未就绪,暂不初始化基线
483
+ // 通道 → seq 映射: 主控制端(默认文件)与其代理端口通道是同一控制器双写,
484
+ // 用代理端口作 key 自动合并,避免同一次切换被当成两个通道
485
+ const mainPort = mainProxyPortOf(health);
462
486
  const seqMap = new Map();
463
- seqMap.set("main", Number(health.restartSeq) || 0);
487
+ seqMap.set(mainPort || "main", Number(health.restartSeq) || 0);
464
488
  for (const ch of getClashHealthChannels()) {
465
- seqMap.set(`ch${ch.port}`, Number(ch.restartSeq) || 0);
489
+ if (mainPort && ch.port === mainPort) continue; // 已并入主控制端 key
490
+ seqMap.set(ch.port, Number(ch.restartSeq) || 0);
466
491
  }
467
492
  if (this._restartSeqBaseline === null) {
468
493
  this._restartSeqBaseline = seqMap; // 首次读取建基线(watchdog 重启不误触发)
469
494
  return;
470
495
  }
471
- let switched = false;
496
+ // 收集发生切换的通道(多实例各自独立切换,互不影响)
497
+ const switchedPorts = []; // 代理端口(数字);主控制端无端口信息时为 null
498
+ let mainSwitched = false;
472
499
  for (const [key, seq] of seqMap) {
473
500
  if (seq > (this._restartSeqBaseline.get(key) || 0)) {
474
- switched = true;
475
- break;
501
+ if (key === "main") mainSwitched = true;
502
+ else {
503
+ switchedPorts.push(key);
504
+ if (key === mainPort) mainSwitched = true; // 主控制端端口通道切换
505
+ }
476
506
  }
477
507
  }
478
508
  this._restartSeqBaseline = seqMap;
479
- if (!switched) return;
509
+ if (!switchedPorts.length && !mainSwitched) return;
510
+ const switchedDesc = switchedPorts.length
511
+ ? switchedPorts.join(",") + (mainSwitched ? ",主控制端" : "")
512
+ : "主控制端";
480
513
  if (!this._switchRestartEnabled) {
481
514
  console.error(
482
- `[watchdog] 检测到代理切换但重启功能未启用(后台可开启),跳过重启`,
515
+ `[watchdog] 检测到代理切换(通道 ${switchedDesc})但重启功能未启用(后台可开启),跳过重启`,
483
516
  );
484
517
  return;
485
518
  }
486
519
  const reason = health.lastSwitch
487
520
  ? `代理切换(${health.lastSwitch})`
488
521
  : "代理切换";
489
- console.error(`[watchdog] 检测到${reason},强制重启全部命令...`);
490
- await this.processManager.restartAll(reason);
522
+ // 只重启使用切换通道的命令(按 --proxy 匹配;无 --proxy 的命令走系统代理=主控制端)
523
+ // 避免多实例下误伤其它通道的命令
524
+ await this.processManager.restartMatching(
525
+ `代理切换(通道 ${switchedDesc}, ${reason})`,
526
+ (cmdDef) => {
527
+ const p = parseCmdProxyPort(cmdDef);
528
+ return p === null ? mainSwitched : switchedPorts.includes(p);
529
+ },
530
+ );
491
531
  } catch (e) {
492
532
  console.error(`[watchdog] 代理切换感知检查失败: ${e.message}`);
493
533
  }
@@ -548,18 +588,21 @@ export class WatchdogAgent {
548
588
  if (!this.running || !this._errorTriggerEnabled) return;
549
589
  const now = Date.now();
550
590
  const scanned = this.processManager.scanErrorLogs(NETWORK_ERROR_RE);
551
- let failoverTriggered = false; // 本轮是否已触发全局切换
591
+ // 本轮已触发切换的通道(同通道只触发一次;不同通道互不阻塞——多实例各自出口独立)
592
+ const triggeredChannels = new Set();
552
593
  for (const { cmdId, cmdDef, newErrors } of scanned) {
553
594
  if (!isCollectorCommand(cmdDef)) continue;
554
595
  // webserver 模式:错误发生在远端出口,本地切节点无法解决,不计入错误触发统计
555
596
  if (isWebserverMode(cmdDef)) continue;
597
+ // 通道键: 命令实际使用的代理端口(多实例各切各的);无 --proxy = 主控制端
598
+ const channelKey = parseCmdProxyPort(cmdDef) || "main";
556
599
  let st = this._errorStates.get(cmdId) || {
557
600
  consecutive: 0,
558
601
  firstErrorAt: 0,
559
602
  lastErrorAt: 0,
560
603
  };
561
- if (failoverTriggered) {
562
- // 本轮已有进程触发全局切换 → 该进程也从新计数开始(防同轮接力触发)
604
+ if (triggeredChannels.has(channelKey)) {
605
+ // 本轮该通道已触发切换 → 该进程也从新计数开始(防同轮接力触发)
563
606
  st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
564
607
  }
565
608
  const hadNew = newErrors.length > 0;
@@ -581,14 +624,13 @@ export class WatchdogAgent {
581
624
  now - st.firstErrorAt >= this._errorTriggerMinMs
582
625
  ) {
583
626
  console.error(
584
- `[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换`,
627
+ `[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换 (通道 ${channelKey})`,
585
628
  );
586
- this._triggerFailover(cmdId);
587
- // 切换是全局的(所有子进程共用节点):清空全部错误计数,
588
- // 让新节点有干净的观察窗口,旧节点错误不再驱动后续切换;
589
- // 触发者自身也重新计数(含本轮后续错误行,从 0 累计)。
629
+ this._triggerFailover(cmdId, parseCmdProxyPort(cmdDef));
630
+ // 切换作用于该命令实际使用的代理通道;同通道命令共用出口,
631
+ // 全部重新计数给新节点干净观察窗口(含触发者自身,从本轮后续错误重新累计)。
590
632
  this._errorStates.clear();
591
- failoverTriggered = true;
633
+ triggeredChannels.add(channelKey);
592
634
  st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
593
635
  }
594
636
  }
@@ -616,31 +658,59 @@ export class WatchdogAgent {
616
658
  }
617
659
  }
618
660
 
619
- /** 下发 failover 命令给 daemon(候选集切下一个可用节点) */
620
- async _triggerFailover(cmdId) {
661
+ /**
662
+ * 下发 failover 命令给 daemon(候选集切下一个可用节点)
663
+ * @param {string} cmdId 触发源命令 id(日志用)
664
+ * @param {number|null} proxyPort 命令实际使用的代理端口(多实例路由;null=主控制端)
665
+ */
666
+ async _triggerFailover(cmdId, proxyPort = null) {
621
667
  try {
622
668
  const ipc = await import("../clash/ipc.js");
623
- ipc.writeCommand({
624
- action: "failover",
625
- reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
626
- });
627
- console.error(`[watchdog] 已下发 failover 命令 (触发源: ${cmdId})`);
669
+ ipc.writeCommand(
670
+ {
671
+ action: "failover",
672
+ reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
673
+ ...(proxyPort ? { proxyPort } : {}),
674
+ },
675
+ proxyPort || 0,
676
+ );
677
+ console.error(
678
+ `[watchdog] 已下发 failover 命令 (触发源: ${cmdId}, 通道: ${proxyPort || "主控制端"})`,
679
+ );
628
680
  } catch (e) {
629
681
  console.error(`[watchdog] 下发 failover 失败: ${e.message}`);
630
682
  }
631
683
  }
632
684
 
633
- /** 下发强切 failover(force: 无视 daemon 冷却期,直接轮换候选) */
685
+ /**
686
+ * 下发强切 failover(force: 无视 daemon 冷却期,直接轮换候选)
687
+ * 客户端异常告警无法归因到具体通道 → 对全部代理通道各发一条强切
688
+ * (默认通道=主控制端 + 其余实例通道;主控制端的代理端口通道与默认通道
689
+ * 是同一控制器双写,跳过避免同一次切换被切两次)
690
+ */
634
691
  async _forceFailover(reason) {
635
692
  try {
636
693
  const ipc = await import("../clash/ipc.js");
637
- ipc.writeCommand({
638
- action: "failover",
639
- force: true,
640
- reason: reason || "客户端异常强切",
641
- });
694
+ const mainPort = mainProxyPortOf(getClashHealth());
695
+ const ports = [
696
+ 0,
697
+ ...getClashHealthChannels()
698
+ .map((ch) => ch.port)
699
+ .filter((p) => p !== mainPort),
700
+ ];
701
+ for (const p of ports) {
702
+ ipc.writeCommand(
703
+ {
704
+ action: "failover",
705
+ force: true,
706
+ reason: reason || "客户端异常强切",
707
+ ...(p ? { proxyPort: p } : {}),
708
+ },
709
+ p,
710
+ );
711
+ }
642
712
  console.error(
643
- `[watchdog] 已下发强切命令 (原因: ${reason || "客户端异常"})`,
713
+ `[watchdog] 已下发强切命令 (${ports.length} 个通道, 原因: ${reason || "客户端异常"})`,
644
714
  );
645
715
  } catch (e) {
646
716
  console.error(`[watchdog] 下发强切失败: ${e.message}`);
@@ -146,11 +146,24 @@ async function killTree(pid) {
146
146
  */
147
147
  export async function takeoverEnvironment({ spawnDaemon, selfPid, log = () => {} } = {}) {
148
148
  const procs = listProcesses();
149
+ // 自身祖先链一律跳过: 启动包装层(schtasks 的 cmd /c node cli.js watchdog…、
150
+ // Start-Process 的 powershell 等)命令行同样命中 agent 签名,若误判为"旧 agent"
151
+ // 会 taskkill /T 树杀连坐树内的自己(214 实采踩坑:watchdog 启动后即自杀)。
152
+ // 祖先链只可能是本次启动的包装层,绝不可能是需要接管的"旧实例"。
153
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
154
+ const ancestors = new Set();
155
+ let cur = byPid.get(selfPid)?.ppid;
156
+ while (cur && cur > 1 && !ancestors.has(cur) && ancestors.size < 32) {
157
+ ancestors.add(cur);
158
+ cur = byPid.get(cur)?.ppid;
159
+ }
160
+
149
161
  const agents = [];
150
162
  const daemons = [];
151
163
 
152
164
  for (const p of procs) {
153
165
  if (p.pid === selfPid) continue;
166
+ if (ancestors.has(p.pid)) continue; // 祖先链跳过(防树杀自杀)
154
167
  if (isAgentCmd(p.cmd)) agents.push(p);
155
168
  else if (spawnDaemon && isDaemonCmd(p.cmd)) daemons.push(p);
156
169
  }
@@ -740,18 +740,29 @@ export class ProcessManager extends EventEmitter {
740
740
  * @returns {Promise<{restarted:number}>}
741
741
  */
742
742
  async restartAll(reason) {
743
+ return this.restartMatching(reason, () => true);
744
+ }
745
+
746
+ /**
747
+ * 按条件选择性强制重启(错峰停止 + 错峰启动,同 restartAll 语义)
748
+ * 多实例场景: 代理切换/故障切换只重启使用对应通道(--proxy 端口)的命令,
749
+ * 避免误伤其它通道的命令。
750
+ * @param {string} reason 重启原因(日志)
751
+ * @param {(cmdDef: object) => boolean} matchFn 命令匹配函数
752
+ */
753
+ async restartMatching(reason, matchFn) {
743
754
  const toRestart = [];
744
755
  for (const [, info] of this.processes) {
745
- if (!info.stopped && info.child && info.cmdDef) {
756
+ if (!info.stopped && info.child && info.cmdDef && matchFn(info.cmdDef)) {
746
757
  toRestart.push(info.cmdDef);
747
758
  }
748
759
  }
749
760
  if (!toRestart.length) {
750
- console.error(`[watchdog] ${reason},无运行中的命令,跳过重启`);
761
+ console.error(`[watchdog] ${reason},无匹配的运行中命令,跳过重启`);
751
762
  return { restarted: 0 };
752
763
  }
753
764
  console.error(
754
- `[watchdog] ${reason},强制重启全部命令 (${toRestart.length}个)...`,
765
+ `[watchdog] ${reason},强制重启匹配命令 (${toRestart.length}个)...`,
755
766
  );
756
767
  // 错峰停止:第一个立即停,之后每个间隔 RESTART_STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
757
768
  for (let i = 0; i < toRestart.length; i++) {