tt-help-cli-ycl 1.4.160 → 1.4.162

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.160",
3
+ "version": "1.4.162",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,4 +48,4 @@
48
48
  "devDependencies": {
49
49
  "better-sqlite3": "^12.10.0"
50
50
  }
51
- }
51
+ }
@@ -443,6 +443,7 @@ export class ClashDaemon {
443
443
  if (r.status === "started") {
444
444
  started++;
445
445
  this._log(`✓ 实例${idx} (${spec.apiPort}/${spec.mixedPort}) 自愈拉起成功,下轮发现将接管`);
446
+ await this._rediscover("实例自愈拉起");
446
447
  } else if (r.status === "failed" || r.status === "no-vendor") {
447
448
  failed++;
448
449
  this._log(`⚠ 实例${idx} 自愈拉起失败: ${r.error || r.status}`);
@@ -689,33 +690,60 @@ export class ClashDaemon {
689
690
  }
690
691
 
691
692
  /**
692
- * API 连续失败时的控制端重发现
693
- * 连续 2 次巡检异常触发一次完整重发现,防止端口漂移后失联
693
+ * 重新发现控制端(订阅应用/自愈拉起新实例后调用,或 API 连续失败时)
694
+ * 背景: controllers 是 start() 时的快照,正常运行期新拉起的内置实例(9098+)
695
+ * 不会自动进入巡检列表 —— 必须主动重发现才能接管(否则新实例永远不被巡检/上报)
696
+ * 防抖: 30s 内多次触发只执行一次(多实例同时拉起时避免重复探测)
694
697
  */
695
- async _maybeRediscover(e) {
696
- this._apiFailCount = (this._apiFailCount || 0) + 1;
697
- if (this._apiFailCount < 2) return;
698
- this._apiFailCount = 0;
698
+ async _rediscover(reason) {
699
+ const now = Date.now();
700
+ if (now - (this._lastRediscoverAt || 0) < 30_000) return;
701
+ this._lastRediscoverAt = now;
699
702
  invalidateDiscoverCache();
700
- this._log(`API 连续失败,重新发现控制端... (${e?.message || e})`);
701
703
  try {
702
704
  const discs = await discoverAll(this._discoverOpts);
705
+ // 按端口迁移旧控制器运行状态(切换历史/冷却锚点/订阅快照等),
706
+ // 避免重发现后 failover 冷却锚点丢失导致反复切换
707
+ const prev = new Map(this._controllers.map((c) => [c.port, c]));
703
708
  const seen = new Set();
704
709
  this._controllers = discs.filter((d) => {
705
710
  if (seen.has(d.port)) return false;
706
711
  seen.add(d.port);
712
+ const old = prev.get(d.port);
713
+ if (old) {
714
+ d._meta = old._meta;
715
+ d._switchHistory = old._switchHistory;
716
+ d._lastSwitchAt = old._lastSwitchAt;
717
+ d._restartSeq = old._restartSeq;
718
+ d._trafficLast = old._trafficLast;
719
+ d._traffic = old._traffic;
720
+ d._subsSnapshot = old._subsSnapshot;
721
+ d._applyResult = old._applyResult;
722
+ d._lastProbeAt = old._lastProbeAt;
723
+ }
707
724
  return true;
708
725
  });
709
726
  this._primary = this._controllers[0] || null;
710
- this._lastDiscoverKey = "";
727
+ this._lastDiscoverKey = ""; // 强制下轮日志重新打印控制端列表
711
728
  this._log(
712
- `✓ 重新选定 ${this._controllers.length} 个控制端: ${this._controllers.map((c) => `${c.clientName}@${c.port}`).join(", ") || "无"}`,
729
+ `✓ ${reason}: 重新发现 ${this._controllers.length} 个控制端: ${this._controllers.map((c) => `${c.clientName}@${c.port}`).join(", ") || "无"}`,
713
730
  );
714
731
  } catch (err) {
715
- this._log(`重新发现控制端失败: ${err.message}`);
732
+ this._log(`${reason}: 重新发现控制端失败: ${err.message}`);
716
733
  }
717
734
  }
718
735
 
736
+ /**
737
+ * API 连续失败时的控制端重发现
738
+ * 连续 2 次巡检异常触发一次完整重发现,防止端口漂移后失联
739
+ */
740
+ async _maybeRediscover(e) {
741
+ this._apiFailCount = (this._apiFailCount || 0) + 1;
742
+ if (this._apiFailCount < 2) return;
743
+ this._apiFailCount = 0;
744
+ await this._rediscover(`API 连续失败 (${e?.message || e})`);
745
+ }
746
+
719
747
  /**
720
748
  * 白名单守卫:当前 Clash Verge 正在使用的节点必须 ∈ 候选集(咱们节点列表)
721
749
  *
@@ -1048,6 +1076,8 @@ export class ClashDaemon {
1048
1076
  c._applyResult = { ok: true, changed: true, cleared: true, at: Date.now() };
1049
1077
  this._log(`📋 订阅已清空: 回收内置 mihomo ${killed} 个进程,不再接管端口`);
1050
1078
  this._writeHealth(c, { node: "?", healthy: null });
1079
+ // 实例已回收 → 重发现让死实例退出巡检列表(否则每轮巡检持续报"未运行")
1080
+ await this._rediscover("订阅清空");
1051
1081
  return;
1052
1082
  }
1053
1083
  this._log(`📋 收到订阅配置: ${subs.length} 条,按条启动独立实例(9097+每条递增)...`);
@@ -1077,6 +1107,9 @@ export class ClashDaemon {
1077
1107
  } else {
1078
1108
  this._log(`⚠ ${failed.length}/${subs.length} 个实例应用失败(未持久化,待下轮心跳重试): ${failed.map((f) => `实例${f.index}`).join(",")}`);
1079
1109
  }
1110
+ // 实例集可能已变化(成功拉起新实例/回收多余实例,含部分失败场景)→ 重发现同步巡检列表
1111
+ // (controllers 是启动快照;不重发现则新实例永远不被巡检/上报,214 实采踩坑)
1112
+ await this._rediscover("订阅应用");
1080
1113
  c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
1081
1114
  c._applyResult = {
1082
1115
  ok: allOk,
@@ -5278,16 +5278,53 @@ async function wdSetErrorTrigger() {
5278
5278
 
5279
5279
  // ── 代理节点区域(看门狗面板中每个客户端卡片,支持多控制端通道) ──
5280
5280
 
5281
+ /** 内置实例端口段(与 discover.js RUNTIME_MAX_INSTANCES=8 对齐):代理 7897+n / API 9097+n */
5282
+ var _SUBS_BASE_PROXY_PORT = 7897;
5283
+ var _SUBS_BASE_API_PORT = 9097;
5284
+ var _SUBS_MAX_INSTANCES = 8;
5285
+
5286
+ /** 服务端配置的订阅列表(proxySettings[userId].subscriptions) */
5287
+ function wdSubsConfigured(userId) {
5288
+ if (_wdData && _wdData.proxySettings && _wdData.proxySettings[userId]) {
5289
+ return _wdData.proxySettings[userId].subscriptions || [];
5290
+ }
5291
+ return [];
5292
+ }
5293
+
5294
+ /**
5295
+ * 代理区域入口:
5296
+ * 内置实例通道(7897+n)→ 订阅总览表(一行 = 一条订阅 = 一个实例)
5297
+ * 外部通道(Verge 等) → 独立通道行(节点 + 自身订阅摘要)
5298
+ * 无通道 → 配置过订阅显示总览(实例离线态);否则单控制端旧视图
5299
+ */
5281
5300
  function renderProxySection(c, userId) {
5282
- var channels = c.clashHealthChannels || [];
5301
+ var channels = (c.clashHealthChannels || []).slice();
5302
+ channels.sort(function (a, b) {
5303
+ return (a.port || 0) - (b.port || 0);
5304
+ });
5283
5305
  if (channels.length > 0) {
5284
- var html = "";
5306
+ var builtin = [];
5307
+ var external = [];
5285
5308
  for (var i = 0; i < channels.length; i++) {
5286
- html += renderProxyChannel(channels[i], userId);
5309
+ var idx = (channels[i].port || 0) - _SUBS_BASE_PROXY_PORT;
5310
+ if (idx >= 0 && idx < _SUBS_MAX_INSTANCES) builtin.push(channels[i]);
5311
+ else external.push(channels[i]);
5312
+ }
5313
+ var html = renderSubsOverview(c, userId, builtin, channels);
5314
+ for (var j = 0; j < external.length; j++) {
5315
+ // 外部通道的订阅配置入口:仅当没有总览区时显示(配置入口全局收敛为一个)
5316
+ html += renderExternalChannel(external[j], userId, !html);
5287
5317
  }
5288
5318
  return html;
5289
5319
  }
5290
- // 兼容旧版:仅主控制端(legacy clashHealth)
5320
+ if (wdSubsConfigured(userId).length > 0) {
5321
+ return renderSubsOverview(c, userId, [], []);
5322
+ }
5323
+ return renderProxyLegacy(c, userId);
5324
+ }
5325
+
5326
+ /** 兼容旧版:仅主控制端(legacy clashHealth) */
5327
+ function renderProxyLegacy(c, userId) {
5291
5328
  var h = c.clashHealth;
5292
5329
  if (!h || !h.node) return "";
5293
5330
  var nodes = (h.meta && h.meta.healthyNodes) || [];
@@ -5300,8 +5337,8 @@ function renderProxySection(c, userId) {
5300
5337
  );
5301
5338
  }
5302
5339
 
5303
- /** 单个控制端通道的代理区域 */
5304
- function renderProxyChannel(ch, userId) {
5340
+ /** 外部代理通道(Verge 等非内置实例):节点行 + 节点列表 + 自身订阅摘要 */
5341
+ function renderExternalChannel(ch, userId, withConfig) {
5305
5342
  if (!ch || !ch.node) return "";
5306
5343
  var nodes = (ch.meta && ch.meta.healthyNodes) || [];
5307
5344
  var delays = (ch.meta && ch.meta.healthyNodeDelays) || {};
@@ -5309,10 +5346,284 @@ function renderProxyChannel(ch, userId) {
5309
5346
  return (
5310
5347
  renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
5311
5348
  renderNodeList(nodes, ch.node, userId, ch.port || 0, delays) +
5312
- renderSubscriptions(ch.meta && ch.meta.proxy, userId)
5349
+ renderSubscriptions(ch.meta && ch.meta.proxy, userId, withConfig !== false)
5350
+ );
5351
+ }
5352
+
5353
+ // ── 订阅总览区(每客户端一个) ──
5354
+
5355
+ /** 订阅应用结果提示(daemon 最近一次 setSubscription 执行反馈) */
5356
+ function renderApplyResult(ar) {
5357
+ if (!ar || !ar.at) return "";
5358
+ if (ar.ok === false) {
5359
+ return styled(
5360
+ "div",
5361
+ "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",
5362
+ "❌ 订阅应用失败: " + (ar.error || "未知错误") +
5363
+ styled("span", "color:#94a3b8;margin-left:6px;font-size:10px", new Date(ar.at).toLocaleTimeString()),
5364
+ );
5365
+ }
5366
+ if (ar.changed) {
5367
+ return styled(
5368
+ "div",
5369
+ "font-size:11px;color:#4ade80;width:100%",
5370
+ "✓ 订阅应用成功 (" + (ar.count || "?") + " 条," + new Date(ar.at).toLocaleTimeString() + ")",
5371
+ );
5372
+ }
5373
+ return "";
5374
+ }
5375
+
5376
+
5377
+ /**
5378
+ * 订阅总览表:
5379
+ * 订阅名 | 实例 | 代理端口(业务 --proxy 用) | 当前节点(可展开切换) | 用量/到期/速率
5380
+ * 一行 = 一条订阅 = 一个内置 mihomo 实例;通道离线时显示占位行(配置可见,实例自愈中)
5381
+ */
5382
+ function renderSubsOverview(c, userId, builtinChannels, allChannels) {
5383
+ var configured = wdSubsConfigured(userId);
5384
+ var byPort = {};
5385
+ for (var i = 0; i < builtinChannels.length; i++) {
5386
+ byPort[builtinChannels[i].port] = builtinChannels[i];
5387
+ }
5388
+ var count = configured.length;
5389
+ for (var p in byPort) {
5390
+ var n = parseInt(p, 10) - _SUBS_BASE_PROXY_PORT + 1;
5391
+ if (n > count) count = n;
5392
+ }
5393
+ if (!count) return "";
5394
+
5395
+ // 订阅应用结果(主控制端默认健康文件优先,回退任一通道快照)
5396
+ var ar = null;
5397
+ if (c.clashHealth && c.clashHealth.meta && c.clashHealth.meta.proxy) {
5398
+ ar = c.clashHealth.meta.proxy.applyResult;
5399
+ }
5400
+ if (!ar || !ar.at) {
5401
+ for (var k = 0; k < (allChannels || []).length; k++) {
5402
+ var pp = allChannels[k].meta && allChannels[k].meta.proxy;
5403
+ if (pp && pp.applyResult && pp.applyResult.at) {
5404
+ ar = pp.applyResult;
5405
+ break;
5406
+ }
5407
+ }
5408
+ }
5409
+
5410
+ // 标题行:入口收敛为全局唯一一个配置按钮
5411
+ var head = styled(
5412
+ "div",
5413
+ "padding:6px 8px;background:#111;display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
5414
+ styled("span", "font-size:14px", "🛜") +
5415
+ styled("span", "color:#e0e0e0;font-weight:bold", "代理订阅") +
5416
+ styled("span", "color:#94a3b8", count + " 条") +
5417
+ tag(
5418
+ "button",
5419
+ {
5420
+ onclick: "wdShowProxyModal('" + esc(userId) + "')",
5421
+ style:
5422
+ "padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
5423
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
5424
+ title: "配置该客户端的代理订阅(每行一个地址,保存后自动热应用,无需重启)",
5425
+ },
5426
+ "配置 ⚙️",
5427
+ ) +
5428
+ styled(
5429
+ "span",
5430
+ "color:#555;font-size:10px;margin-left:auto",
5431
+ "一条订阅 = 一个独立 mihomo 实例",
5432
+ ) +
5433
+ renderApplyResult(ar),
5434
+ );
5435
+
5436
+ // tab 栏(即概览: 状态点+订阅名+端口;临期/超量红标、离线灰)+ 详情区(仅激活显示)
5437
+ // 选中态跨刷新保留(_wdSubsActiveTab),点击纯 DOM 切换不重渲染
5438
+ var activePort = _wdSubsActiveTab[wdEscId(userId)] || _SUBS_BASE_PROXY_PORT;
5439
+ var tabsBar = styled(
5440
+ "div",
5441
+ "display:flex;gap:2px;padding:0 8px;background:#16161e;border-bottom:1px solid #2a2a3a;overflow-x:auto;flex-wrap:wrap",
5442
+ (function () {
5443
+ var out = "";
5444
+ for (var m = 1; m <= count; m++) {
5445
+ out += renderSubsTab(m, configured[m - 1], byPort[_SUBS_BASE_PROXY_PORT + m - 1], userId,
5446
+ _SUBS_BASE_PROXY_PORT + m - 1 === activePort);
5447
+ }
5448
+ return out;
5449
+ })(),
5450
+ );
5451
+ var bodies = "";
5452
+ for (var m2 = 1; m2 <= count; m2++) {
5453
+ bodies += renderSubsBody(m2, configured[m2 - 1], byPort[_SUBS_BASE_PROXY_PORT + m2 - 1], userId,
5454
+ _SUBS_BASE_PROXY_PORT + m2 - 1 === activePort);
5455
+ }
5456
+ return styled(
5457
+ "div",
5458
+ "margin-top:6px;border:1px solid #2a2a3a;border-radius:8px;overflow:hidden",
5459
+ head + tabsBar + bodies,
5460
+ );
5461
+ }
5462
+
5463
+ /** 订阅选中 tab(userId → 代理端口),跨刷新保留 */
5464
+ var _wdSubsActiveTab = {};
5465
+
5466
+ /** tab 点击切换(纯 DOM 操作,不重渲染;与节点展开/切换命令同用代理端口路由) */
5467
+ function wdSubsTab(userId, port) {
5468
+ _wdSubsActiveTab[wdEscId(userId)] = port;
5469
+ for (var i = 0; i < _SUBS_MAX_INSTANCES; i++) {
5470
+ var p = _SUBS_BASE_PROXY_PORT + i;
5471
+ var tab = document.getElementById("wdSubsTab-" + wdEscId(userId) + "-" + p);
5472
+ var body = document.getElementById("wdSubsBody-" + wdEscId(userId) + "-" + p);
5473
+ if (!tab || !body) continue;
5474
+ var active = p === port;
5475
+ tab.style.background = active ? "#2a2a3a" : "transparent";
5476
+ tab.style.color = active ? "#fff" : "#94a3b8";
5477
+ tab.style.borderBottom = active ? "2px solid #60a5fa" : "2px solid transparent";
5478
+ body.style.display = active ? "block" : "none";
5479
+ }
5480
+ }
5481
+
5482
+ /** tab 按钮(即概览条目): 状态点 + 订阅名 + 端口;临期/超量红标、离线灰 */
5483
+ function renderSubsTab(n, conf, ch, userId, active) {
5484
+ var port = _SUBS_BASE_PROXY_PORT + n - 1;
5485
+ var meta = (ch && ch.meta) || {};
5486
+ var sub = ((meta.proxy || {}).subscriptions || [])[0] || null;
5487
+ var online = !!(ch && ch.node);
5488
+ var name = (sub && sub.name) || (conf && conf.name) || "实例" + n;
5489
+ // 临期/超量判定(与详情区同口径)
5490
+ var danger = false;
5491
+ if (sub) {
5492
+ var used = (sub.upload || 0) + (sub.download || 0);
5493
+ var total = sub.total || 0;
5494
+ var pct = total > 0 ? Math.round(((total - used) / total) * 100) : null;
5495
+ var daysLeft = sub.expire ? Math.round((sub.expire * 1000 - Date.now()) / 86400000) : null;
5496
+ danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
5497
+ }
5498
+ var dot = !online ? "○" : ch.healthy ? "●" : "●";
5499
+ var dotColor = !online ? "#555" : ch.healthy ? "#4ade80" : "#f87171";
5500
+ return tag(
5501
+ "button",
5502
+ {
5503
+ id: "wdSubsTab-" + wdEscId(userId) + "-" + port,
5504
+ onclick: "wdSubsTab('" + esc(userId) + "'," + port + ")",
5505
+ style:
5506
+ "padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap;border:none;flex-shrink:0;" +
5507
+ "background:" + (active ? "#2a2a3a" : "transparent") + ";" +
5508
+ "color:" + (active ? "#fff" : "#94a3b8") + ";" +
5509
+ "border-bottom:2px solid " + (active ? "#60a5fa" : "transparent") + ";" +
5510
+ (online ? "" : "opacity:0.6;"),
5511
+ title: "实例" + n + " · 代理 " + port + " · API " + (_SUBS_BASE_API_PORT + n - 1) + (online ? (ch.healthy ? " · 健康" : " · 异常") : " · 未上线"),
5512
+ },
5513
+ styled("span", "color:" + dotColor, dot) +
5514
+ " " + name + styled("span", "color:#60a5fa;font-size:10px", " :" + port) +
5515
+ (danger ? styled("span", "color:#f87171;margin-left:2px", "⚠️") : ""),
5313
5516
  );
5314
5517
  }
5315
5518
 
5519
+ /** tab 详情区: 当前节点 + 端口/实例 + 用量/到期 + 速率 + 节点列表(可展开) */
5520
+ function renderSubsBody(n, conf, ch, userId, active) {
5521
+ var port = _SUBS_BASE_PROXY_PORT + n - 1;
5522
+ var apiPort = _SUBS_BASE_API_PORT + n - 1;
5523
+ var meta = (ch && ch.meta) || {};
5524
+ var proxy = meta.proxy || {};
5525
+ var sub = (proxy.subscriptions || [])[0] || null;
5526
+ var online = !!(ch && ch.node);
5527
+ var nodes = meta.healthyNodes || [];
5528
+ var delays = meta.healthyNodeDelays || {};
5529
+
5530
+ // 行1: 当前节点 + 健康状态 + 节点列表展开
5531
+ var line1;
5532
+ if (online) {
5533
+ var dotColor = ch.healthy ? "#4ade80" : "#f87171";
5534
+ var status = ch.healthy
5535
+ ? (ch.timeTotal || 0).toFixed(1) + "s " + ((ch.sizeDownload || 0) / 1024).toFixed(0) + "KB"
5536
+ : ch.error || "异常";
5537
+ line1 = styled(
5538
+ "div",
5539
+ "display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:13px;flex-wrap:wrap",
5540
+ styled("span", "color:" + dotColor, "●") +
5541
+ styled("span", "color:#e0e0e0;font-weight:bold;word-break:break-all", ch.node) +
5542
+ styled("span", "color:#666;font-size:11px", "| " + status) +
5543
+ (ch.stale ? styled("span", "color:#fbbf24;font-size:10px", "数据过期" + ch.ageSeconds + "s") : "") +
5544
+ (nodes.length
5545
+ ? tag(
5546
+ "button",
5547
+ {
5548
+ onclick: "wdToggleNodes('" + esc(userId) + "'," + port + ")",
5549
+ style:
5550
+ "margin-left:auto;padding:2px 8px;font-size:11px;background:#333;color:#60a5fa;" +
5551
+ "border:1px solid #444;border-radius:3px;cursor:pointer;flex-shrink:0",
5552
+ title: "查看/切换该实例的可用节点",
5553
+ },
5554
+ nodes.length + " 个节点 ▸",
5555
+ )
5556
+ : ""),
5557
+ );
5558
+ } else {
5559
+ line1 = styled(
5560
+ "div",
5561
+ "display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:12px",
5562
+ styled("span", "color:#555", "○") +
5563
+ styled("span", "color:#888", "实例未运行(等待自愈拉起,配置保留)"),
5564
+ );
5565
+ }
5566
+
5567
+ // 行2: 实例/端口(业务 --proxy 用代理端口)
5568
+ var line2 = styled(
5569
+ "div",
5570
+ "padding:2px 10px;font-size:11px;color:#888;display:flex;gap:10px;flex-wrap:wrap",
5571
+ styled("span", null, "实例 #" + n) +
5572
+ tag(
5573
+ "span",
5574
+ {
5575
+ style: "color:#60a5fa;font-family:monospace",
5576
+ title: "业务命令 --proxy 用此端口",
5577
+ },
5578
+ "代理 127.0.0.1:" + port,
5579
+ ) +
5580
+ styled("span", "color:#555;font-family:monospace", "API " + apiPort) +
5581
+ (proxy.runtime && proxy.runtime.status ? styled("span", "color:#555", "内核" + (proxy.runtime.status || "")) : ""),
5582
+ );
5583
+
5584
+ // 行3: 用量/到期 + 行4: 速率
5585
+ var line34 = "";
5586
+ if (sub) {
5587
+ var used = (sub.upload || 0) + (sub.download || 0);
5588
+ var total = sub.total || 0;
5589
+ var remain = total > 0 ? total - used : 0;
5590
+ var pct = total > 0 ? Math.round((remain / total) * 100) : null;
5591
+ var daysLeft = sub.expire ? Math.round((sub.expire * 1000 - Date.now()) / 86400000) : null;
5592
+ var danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
5593
+ var parts = [];
5594
+ if (sub.proxies) parts.push(sub.proxies + " 节点");
5595
+ parts.push(total > 0 ? "剩 " + fmtGB(remain) + (pct !== null ? " (" + pct + "%)" : "") : "用量未知");
5596
+ if (sub.expire) {
5597
+ parts.push("到 " + new Date(sub.expire * 1000).toLocaleDateString() + (daysLeft !== null ? "(剩" + daysLeft + "天)" : ""));
5598
+ }
5599
+ line34 = styled(
5600
+ "div",
5601
+ "padding:2px 10px 4px;font-size:11px" + (danger ? ";background:#3b1d1d" : ""),
5602
+ styled("span", "color:" + (danger ? "#f87171" : "#94a3b8"), "📡 " + parts.join(" · ") + (danger ? " ⚠️" : "")),
5603
+ );
5604
+ }
5605
+ var t = proxy.traffic;
5606
+ if (t && (t.downRate > 0 || t.upRate > 0)) {
5607
+ line34 += styled(
5608
+ "div",
5609
+ "padding:2px 10px 6px;font-size:11px;color:#666",
5610
+ "⚡ ↓" + fmtSpeed(t.downRate) + " / ↑" + fmtSpeed(t.upRate),
5611
+ );
5612
+ }
5613
+
5614
+ var bodyInner = line1 + line2 + line34 +
5615
+ (online && nodes.length ? renderNodeList(nodes, ch.node, userId, port, delays) : "");
5616
+ return tag(
5617
+ "div",
5618
+ {
5619
+ id: "wdSubsBody-" + wdEscId(userId) + "-" + port,
5620
+ style: "display:" + (active ? "block" : "none") + ";border-top:1px solid #22222f",
5621
+ },
5622
+ bodyInner,
5623
+ );
5624
+ }
5625
+
5626
+
5316
5627
  function fmtGB(bytes) {
5317
5628
  if (!bytes || bytes <= 0) return "0";
5318
5629
  var gb = bytes / 1073741824;
@@ -5328,31 +5639,14 @@ function fmtSpeed(bytesPerSec) {
5328
5639
  /**
5329
5640
  * 订阅用量/到期 + 实时速率 + 订阅配置入口(数据: clashHealth.meta.proxy)
5330
5641
  * 临期(<7天)或超量(剩余<10%)标红
5642
+ * @param {boolean} [withConfig] 是否渲染配置入口按钮(默认 true;多实例总览区已收敛唯一入口时传 false)
5331
5643
  */
5332
- function renderSubscriptions(proxy, userId) {
5644
+ function renderSubscriptions(proxy, userId, withConfig) {
5333
5645
  proxy = proxy || {};
5334
5646
  var subs = proxy.subscriptions || [];
5335
5647
  var rows = "";
5336
5648
  // 订阅应用结果(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
- }
5649
+ rows += renderApplyResult(proxy.applyResult);
5356
5650
  // 实时速率
5357
5651
  var t = proxy.traffic;
5358
5652
  if (t && (t.downRate > 0 || t.upRate > 0)) {
@@ -5389,7 +5683,8 @@ function renderSubscriptions(proxy, userId) {
5389
5683
  );
5390
5684
  }
5391
5685
  // 配置入口(摘要双来源: 后台配置优先,回退 daemon 实际生效快照;显示条数+订阅名)
5392
- var configured = (window._wdData && _wdData.proxySettings && _wdData.proxySettings[userId] && _wdData.proxySettings[userId].subscriptions) || proxy.subscriptions || [];
5686
+ if (withConfig !== false) {
5687
+ var configured = (window._wdData && _wdData.proxySettings && _wdData.proxySettings[userId] && _wdData.proxySettings[userId].subscriptions) || proxy.subscriptions || [];
5393
5688
  var cfgNames = configured
5394
5689
  .map(function (s) {
5395
5690
  return s.name || String(s.url || "").replace(/^https?:\/\//, "").split("/")[0];
@@ -5416,6 +5711,7 @@ function renderSubscriptions(proxy, userId) {
5416
5711
  "配置 ⚙️",
5417
5712
  ),
5418
5713
  );
5714
+ }
5419
5715
  return rows;
5420
5716
  }
5421
5717
 
@@ -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 实例</b>:按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”总览表可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(&lt;7天)或超量(剩余&lt;10%)在总览表中标红。</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">
@@ -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
  }
@@ -705,10 +705,22 @@ export async function runUpgradeMode(argv) {
705
705
  `[upgrade-proc] 开始下载新版: npm install -g ${PACKAGE}@latest ...`,
706
706
  );
707
707
  const beforeVersion = getVersion(); // 下载前版本,用于验证 npm install 真正生效
708
- const result = await performUpgrade();
708
+ let result = await performUpgrade();
709
+ if (!result.success && /EBUSY|resource busy/i.test(result.output)) {
710
+ // Windows 实测:运行中的 node/watchdog 或其 cwd 在包目录内时,npm 覆盖文件报 EBUSY。
711
+ // 此刻升级已势在必行(版本差已确认),终止旧 watchdog 释放文件锁后重试安装。
712
+ console.error(
713
+ `[upgrade-proc] 包目录被运行进程锁定(EBUSY),终止旧 watchdog 释放锁后重试安装...`,
714
+ );
715
+ const lockSnapshot = await snapshotAllProcesses();
716
+ const lockDescPids = listDescendantPids(lockSnapshot, oldPid, process.pid);
717
+ await terminateProcess(oldPid, 5000);
718
+ await killPidList(lockDescPids, "[upgrade-proc] 已停止旧 watchdog 释放包目录锁");
719
+ result = await performUpgrade();
720
+ }
709
721
  if (!result.success) {
710
722
  const err = result.output.substring(0, 300);
711
- console.error(`[upgrade-proc] 下载失败,保持当前版本运行: ${err}`);
723
+ console.error(`[upgrade-proc] 下载失败,保持当前状态: ${err}`);
712
724
  process.exit(1);
713
725
  }
714
726