tt-help-cli-ycl 1.4.115 → 1.4.119

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.
@@ -668,7 +668,8 @@ function getTargetRefreshStats(hours = 24) {
668
668
  )
669
669
  .all(...targetLocations);
670
670
 
671
- if (!totalRows.length) return { countries: [], total: 0, refreshed: 0 };
671
+ if (!totalRows.length)
672
+ return { countries: [], total: 0, refreshed: 0, pendingRefresh: 0 };
672
673
 
673
674
  // 按国家统计最近 N 小时内刷新过的数量
674
675
  const threshold = Date.now() - hours * 3600 * 1000;
@@ -688,17 +689,47 @@ function getTargetRefreshStats(hours = 24) {
688
689
  const refreshedMap = new Map();
689
690
  for (const r of refreshedRows) refreshedMap.set(r.country, r.cnt);
690
691
 
692
+ // 按国家统计待 Refresh 数量(pending_refresh = 1,等待 refresh 客户端处理)
693
+ const pendingRows = getDb()
694
+ .prepare(
695
+ `
696
+ SELECT location_created as country, COUNT(*) as cnt
697
+ FROM jobs
698
+ WHERE tt_seller = 1
699
+ AND verified = 0
700
+ AND pending_refresh = 1
701
+ AND location_created IN (${placeholders})
702
+ GROUP BY location_created
703
+ `,
704
+ )
705
+ .all(...targetLocations);
706
+ const pendingMap = new Map();
707
+ for (const r of pendingRows) pendingMap.set(r.country, r.cnt);
708
+
691
709
  const results = [];
692
710
  let totalAll = 0;
693
711
  let refreshedAll = 0;
712
+ let pendingAll = 0;
694
713
  for (const r of totalRows) {
695
714
  const refreshed = refreshedMap.get(r.country) || 0;
715
+ const pendingRefresh = pendingMap.get(r.country) || 0;
696
716
  totalAll += r.cnt;
697
717
  refreshedAll += refreshed;
698
- results.push({ country: r.country, total: r.cnt, refreshed });
718
+ pendingAll += pendingRefresh;
719
+ results.push({
720
+ country: r.country,
721
+ total: r.cnt,
722
+ refreshed,
723
+ pendingRefresh,
724
+ });
699
725
  }
700
726
 
701
- return { countries: results, total: totalAll, refreshed: refreshedAll };
727
+ return {
728
+ countries: results,
729
+ total: totalAll,
730
+ refreshed: refreshedAll,
731
+ pendingRefresh: pendingAll,
732
+ };
702
733
  }
703
734
 
704
735
  export function createStore(filePath, options = {}) {
@@ -1706,7 +1706,7 @@ let currentTagsSeq = 0;
1706
1706
  const TAGS_PAGE_SIZE = 200;
1707
1707
  let cachedTagCountries = [];
1708
1708
  let currentRefreshHours = 24;
1709
- let currentTargetRefreshHours = 24;
1709
+ let currentTargetRefreshHours = 48;
1710
1710
 
1711
1711
  function showTagsPage() {
1712
1712
  document.getElementById("mainPage").classList.add("hidden");
@@ -2087,6 +2087,7 @@ function renderTargetRefreshStats(data) {
2087
2087
  <thead><tr style="color:#888;text-align:left">
2088
2088
  <th style="padding:4px 8px">国家</th>
2089
2089
  <th style="padding:4px 8px;text-align:right">商家数</th>
2090
+ <th style="padding:4px 8px;text-align:right">待Refresh</th>
2090
2091
  <th style="padding:4px 8px;text-align:right">${currentTargetRefreshHours}h已刷</th>
2091
2092
  <th style="padding:4px 8px;width:120px">进度</th>
2092
2093
  </tr></thead>
@@ -2098,6 +2099,7 @@ function renderTargetRefreshStats(data) {
2098
2099
  html += `<tr style="color:#ccc;border-top:1px solid #222">
2099
2100
  <td style="padding:4px 8px">${c.country}</td>
2100
2101
  <td style="padding:4px 8px;text-align:right">${c.total}</td>
2102
+ <td style="padding:4px 8px;text-align:right;color:${c.pendingRefresh > 0 ? "#fbbf24" : "#9ca3af"}">${c.pendingRefresh || 0}</td>
2101
2103
  <td style="padding:4px 8px;text-align:right;color:${cpct >= 50 ? "#22c55e" : "#facc15"}">${c.refreshed}</td>
2102
2104
  <td style="padding:4px 8px">
2103
2105
  <div style="display:flex;align-items:center;gap:6px">
@@ -3436,6 +3438,7 @@ async function fetchWatchdogData() {
3436
3438
  _wdData.clientCommands || {},
3437
3439
  _wdData.clashCountries || {},
3438
3440
  _wdData.errorTriggers || {},
3441
+ _wdData.loginPorts || {},
3439
3442
  );
3440
3443
  } catch (e) {
3441
3444
  document.getElementById("wdClientsList").textContent =
@@ -3488,6 +3491,7 @@ function renderWatchdogDashboard(
3488
3491
  clientCommands,
3489
3492
  clashCountries,
3490
3493
  errorTriggers,
3494
+ loginPorts,
3491
3495
  ) {
3492
3496
  // 顶栏统计始终更新(textContent 不闪烁)
3493
3497
  var onlineCount = clients.filter(function (c) {
@@ -3546,7 +3550,13 @@ function renderWatchdogDashboard(
3546
3550
  var allCards = [];
3547
3551
  sortedClients.forEach(function (c) {
3548
3552
  allCards.push(
3549
- renderClientCard(c, clientCommands, clashCountries, errorTriggers),
3553
+ renderClientCard(
3554
+ c,
3555
+ clientCommands,
3556
+ clashCountries,
3557
+ errorTriggers,
3558
+ loginPorts,
3559
+ ),
3550
3560
  );
3551
3561
  });
3552
3562
  // 离线卡也按名称排序
@@ -3570,6 +3580,7 @@ function renderWatchdogDashboard(
3570
3580
  clientCommands[uid],
3571
3581
  clashCountries,
3572
3582
  errorTriggers,
3583
+ loginPorts,
3573
3584
  ),
3574
3585
  );
3575
3586
  });
@@ -3615,7 +3626,13 @@ function renderWatchdogDashboard(
3615
3626
  }
3616
3627
  }
3617
3628
 
3618
- function renderOfflineCard(userId, cmds, clashCountries, errorTriggers) {
3629
+ function renderOfflineCard(
3630
+ userId,
3631
+ cmds,
3632
+ clashCountries,
3633
+ errorTriggers,
3634
+ loginPorts,
3635
+ ) {
3619
3636
  var cmdCount = cmds ? cmds.length : 0;
3620
3637
  return styled(
3621
3638
  "div",
@@ -3686,6 +3703,7 @@ function renderOfflineCard(userId, cmds, clashCountries, errorTriggers) {
3686
3703
  ) +
3687
3704
  renderCountrySection({}, userId, clashCountries) +
3688
3705
  renderErrorTriggerSection({}, userId, errorTriggers) +
3706
+ renderLoginSection({}, userId, loginPorts) +
3689
3707
  renderCmdRows(cmds || [], userId, [], false),
3690
3708
  );
3691
3709
  }
@@ -3720,7 +3738,13 @@ function findAnalysis(llmAnalysis, cmdId) {
3720
3738
  return null;
3721
3739
  }
3722
3740
 
3723
- function renderClientCard(c, clientCommands, clashCountries, errorTriggers) {
3741
+ function renderClientCard(
3742
+ c,
3743
+ clientCommands,
3744
+ clashCountries,
3745
+ errorTriggers,
3746
+ loginPorts,
3747
+ ) {
3724
3748
  var os = c.osInfo || {},
3725
3749
  cfg = c.config || {},
3726
3750
  procs = c.processes || [];
@@ -3833,6 +3857,7 @@ function renderClientCard(c, clientCommands, clashCountries, errorTriggers) {
3833
3857
  styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
3834
3858
  renderCountrySection(c, userId, clashCountries) +
3835
3859
  renderErrorTriggerSection(c, userId, errorTriggers) +
3860
+ renderLoginSection(c, userId, loginPorts) +
3836
3861
  renderProxySection(c, userId) +
3837
3862
  renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis),
3838
3863
  );
@@ -3962,6 +3987,137 @@ async function wdSetCountries() {
3962
3987
  }
3963
3988
  }
3964
3989
 
3990
+ // ── 登录检测端口配置区域(看门狗面板中每个客户端卡片) ──
3991
+
3992
+ function renderLoginSection(c, userId, loginPorts) {
3993
+ if (!userId) return "";
3994
+ var cfgEntry = (loginPorts && loginPorts[userId]) || {};
3995
+ var configured =
3996
+ cfgEntry.ports && cfgEntry.ports.length ? cfgEntry.ports : [];
3997
+ var report = c.loginReport || null;
3998
+ var reportHtml;
3999
+ if (!report) {
4000
+ reportHtml = styled(
4001
+ "span",
4002
+ "color:#555;margin-left:auto",
4003
+ configured.length ? "等待客户端上报..." : "",
4004
+ );
4005
+ } else if (report.error) {
4006
+ var et = report.checkedAt
4007
+ ? new Date(report.checkedAt).toLocaleTimeString("zh-CN")
4008
+ : "?";
4009
+ reportHtml =
4010
+ styled("span", "color:#888;margin-left:auto", "最近检测(" + et + "): ") +
4011
+ styled("span", "color:#f87171", "失败");
4012
+ } else if (report.ports && report.ports.length) {
4013
+ var et2 = report.checkedAt
4014
+ ? new Date(report.checkedAt).toLocaleTimeString("zh-CN")
4015
+ : "?";
4016
+ var badges = report.ports
4017
+ .map(function (r) {
4018
+ var color =
4019
+ r.status === "logged_in"
4020
+ ? "#4ade80"
4021
+ : r.status === "expired"
4022
+ ? "#fbbf24"
4023
+ : r.status === "error"
4024
+ ? "#f87171"
4025
+ : "#888";
4026
+ var label =
4027
+ r.status === "logged_in"
4028
+ ? "已登录"
4029
+ : r.status === "expired"
4030
+ ? "已过期"
4031
+ : r.status === "error"
4032
+ ? "失败"
4033
+ : "未登录";
4034
+ var txt =
4035
+ r.port +
4036
+ " " +
4037
+ label +
4038
+ (r.status === "logged_in" && r.account ? " @" + r.account : "");
4039
+ return styled(
4040
+ "span",
4041
+ "color:" +
4042
+ color +
4043
+ ";background:#0d1117;border:1px solid #333;padding:1px 6px;border-radius:4px",
4044
+ txt,
4045
+ );
4046
+ })
4047
+ .join(" ");
4048
+ reportHtml =
4049
+ styled("span", "color:#888;margin-left:auto", "最近检测(" + et2 + "): ") +
4050
+ badges;
4051
+ } else {
4052
+ reportHtml = styled("span", "color:#555;margin-left:auto", "无结果");
4053
+ }
4054
+ return styled(
4055
+ "div",
4056
+ "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
4057
+ "display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
4058
+ styled("span", "font-size:14px", "🔑") +
4059
+ styled("span", "color:#888", "登录检测:") +
4060
+ tag(
4061
+ "button",
4062
+ {
4063
+ onclick: "wdShowLoginPortsModal('" + esc(userId) + "')",
4064
+ style:
4065
+ "padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
4066
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
4067
+ title: "配置要定期检测登录状态的 CDP 端口(逗号分隔,留空清空)",
4068
+ },
4069
+ (configured.length ? configured.join(",") : "未配置") + " ⚙️",
4070
+ ) +
4071
+ reportHtml,
4072
+ );
4073
+ }
4074
+
4075
+ // ── 登录检测端口配置弹窗 ──
4076
+
4077
+ var _wdLoginPortsUserId = "";
4078
+
4079
+ function wdShowLoginPortsModal(userId) {
4080
+ _wdLoginPortsUserId = userId;
4081
+ document.getElementById("wdLoginPortsUserId").textContent = userId;
4082
+ var configured = [];
4083
+ if (_wdData && _wdData.loginPorts && _wdData.loginPorts[userId]) {
4084
+ configured = _wdData.loginPorts[userId].ports || [];
4085
+ }
4086
+ document.getElementById("wdLoginPortsInput").value = configured.join(",");
4087
+ document.getElementById("wdLoginPortsModal").style.display = "flex";
4088
+ }
4089
+
4090
+ async function wdSetLoginPorts() {
4091
+ var raw = document.getElementById("wdLoginPortsInput").value;
4092
+ var ports = raw
4093
+ .split(",")
4094
+ .map(function (s) {
4095
+ return s.trim();
4096
+ })
4097
+ .filter(Boolean);
4098
+ try {
4099
+ var res = await fetch("/api/watchdog/login-ports", {
4100
+ method: "POST",
4101
+ headers: { "Content-Type": "application/json" },
4102
+ body: JSON.stringify({ userId: _wdLoginPortsUserId, ports: ports }),
4103
+ });
4104
+ var d = await res.json();
4105
+ document.getElementById("wdLoginPortsModal").style.display = "none";
4106
+ if (d.ok) {
4107
+ alert(
4108
+ "已配置检测端口: " +
4109
+ (d.ports.length ? d.ports.join(",") : "无") +
4110
+ "\n\n客户端将在下次心跳时生效,并立即触发一次检测。",
4111
+ );
4112
+ } else {
4113
+ alert("失败: " + (d.error || "未知错误"));
4114
+ }
4115
+ fetchWatchdogData();
4116
+ } catch (e) {
4117
+ alert("保存失败: " + e.message);
4118
+ }
4119
+ }
4120
+
3965
4121
  // ── 错误自动切节点配置区域(看门狗面板中每个客户端卡片) ──
3966
4122
 
3967
4123
  function renderErrorTriggerSection(c, userId, errorTriggers) {
@@ -4055,23 +4211,41 @@ async function wdSetErrorTrigger() {
4055
4211
  }
4056
4212
  }
4057
4213
 
4058
- // ── 代理节点区域(看门狗面板中每个客户端卡片) ──
4214
+ // ── 代理节点区域(看门狗面板中每个客户端卡片,支持多控制端通道) ──
4059
4215
 
4060
4216
  function renderProxySection(c, userId) {
4217
+ var channels = c.clashHealthChannels || [];
4218
+ if (channels.length > 0) {
4219
+ var html = "";
4220
+ for (var i = 0; i < channels.length; i++) {
4221
+ html += renderProxyChannel(channels[i], userId);
4222
+ }
4223
+ return html;
4224
+ }
4225
+ // 兼容旧版:仅主控制端(legacy clashHealth)
4061
4226
  var h = c.clashHealth;
4062
4227
  if (!h || !h.node) return "";
4063
-
4064
4228
  var nodes = (h.meta && h.meta.healthyNodes) || [];
4065
4229
  var hasNodes = nodes.length > 0;
4230
+ return (
4231
+ renderProxyLine(h, nodes, userId, 0, hasNodes) +
4232
+ renderNodeList(nodes, h.node, userId, 0)
4233
+ );
4234
+ }
4066
4235
 
4236
+ /** 单个控制端通道的代理区域 */
4237
+ function renderProxyChannel(ch, userId) {
4238
+ if (!ch || !ch.node) return "";
4239
+ var nodes = (ch.meta && ch.meta.healthyNodes) || [];
4240
+ var hasNodes = nodes.length > 0;
4067
4241
  return (
4068
- renderProxyLine(h, nodes, userId, hasNodes) +
4069
- renderNodeList(nodes, h.node, userId, hasNodes)
4242
+ renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
4243
+ renderNodeList(nodes, ch.node, userId, ch.port || 0)
4070
4244
  );
4071
4245
  }
4072
4246
 
4073
4247
  /** 代理状态行:当前节点 + 健康状态 + 展开按钮 */
4074
- function renderProxyLine(h, nodes, userId, hasNodes) {
4248
+ function renderProxyLine(h, nodes, userId, port, hasNodes) {
4075
4249
  var dot = h.healthy ? "#4ade80" : "#f87171";
4076
4250
  var status = h.healthy
4077
4251
  ? (h.timeTotal || 0).toFixed(1) +
@@ -4079,19 +4253,31 @@ function renderProxyLine(h, nodes, userId, hasNodes) {
4079
4253
  ((h.sizeDownload || 0) / 1024).toFixed(0) +
4080
4254
  "KB"
4081
4255
  : h.error || "异常";
4256
+ var clientTag = port
4257
+ ? h.meta && h.meta.clientName
4258
+ ? h.meta.clientName + " :" + port
4259
+ : "代理 :" + port
4260
+ : "";
4082
4261
 
4083
4262
  return styled(
4084
4263
  "div",
4085
4264
  "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
4086
4265
  "display:flex;align-items:center;gap:8px;font-size:12px",
4087
4266
  styled("span", "color:" + dot + ";font-size:14px", "●") +
4267
+ (clientTag
4268
+ ? styled(
4269
+ "span",
4270
+ "color:#888;padding:0 4px;border:1px solid #333;border-radius:3px;font-size:10px",
4271
+ clientTag,
4272
+ )
4273
+ : "") +
4088
4274
  styled("span", "color:#e0e0e0;font-weight:bold", h.node) +
4089
4275
  styled("span", "color:#666", "| " + status) +
4090
4276
  (hasNodes
4091
4277
  ? tag(
4092
4278
  "button",
4093
4279
  {
4094
- onclick: "wdToggleNodes('" + esc(userId) + "')",
4280
+ onclick: "wdToggleNodes('" + esc(userId) + "'," + port + ")",
4095
4281
  style:
4096
4282
  "margin-left:auto;padding:2px 8px;font-size:11px;background:#333;" +
4097
4283
  "color:#60a5fa;border:1px solid #444;border-radius:3px;cursor:pointer",
@@ -4104,15 +4290,15 @@ function renderProxyLine(h, nodes, userId, hasNodes) {
4104
4290
  }
4105
4291
 
4106
4292
  /** 节点列表面板(默认隐藏) */
4107
- function renderNodeList(nodes, currentNode, userId, hasNodes) {
4108
- if (!hasNodes) return "";
4293
+ function renderNodeList(nodes, currentNode, userId, port) {
4294
+ if (!nodes || !nodes.length) return "";
4109
4295
 
4110
4296
  var items = "";
4111
4297
  for (var i = 0; i < nodes.length; i++) {
4112
- items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId);
4298
+ items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId, port);
4113
4299
  }
4114
4300
 
4115
- var id = "wdNodes-" + wdEscId(userId);
4301
+ var id = "wdNodes-" + wdEscId(userId) + "-" + port;
4116
4302
  return (
4117
4303
  '<div id="' +
4118
4304
  id +
@@ -4124,7 +4310,7 @@ function renderNodeList(nodes, currentNode, userId, hasNodes) {
4124
4310
  }
4125
4311
 
4126
4312
  /** 单个节点行 */
4127
- function renderNodeItem(name, isActive, userId) {
4313
+ function renderNodeItem(name, isActive, userId, port) {
4128
4314
  var color = isActive ? "#4ade80" : "#888";
4129
4315
  var bg = isActive ? "background:#1a3a2a;" : "";
4130
4316
  var dot = isActive ? "●" : "○";
@@ -4135,7 +4321,9 @@ function renderNodeItem(name, isActive, userId) {
4135
4321
  esc(userId) +
4136
4322
  "','" +
4137
4323
  esc(name) +
4138
- "')\" " +
4324
+ "'," +
4325
+ port +
4326
+ ')" ' +
4139
4327
  'style="margin-left:auto;padding:1px 6px;font-size:10px;background:#2563eb;' +
4140
4328
  'color:#fff;border:none;border-radius:2px;cursor:pointer">切换</button>';
4141
4329
 
@@ -4164,18 +4352,23 @@ function wdEscId(s) {
4164
4352
  return (s || "").replace(/[^a-zA-Z0-9_-]/g, "_");
4165
4353
  }
4166
4354
 
4167
- function wdToggleNodes(userId) {
4168
- var el = document.getElementById("wdNodes-" + wdEscId(userId));
4355
+ function wdToggleNodes(userId, port) {
4356
+ var el = document.getElementById(
4357
+ "wdNodes-" + wdEscId(userId) + "-" + (port || 0),
4358
+ );
4169
4359
  if (!el) return;
4170
4360
  el.style.display = el.style.display === "none" ? "block" : "none";
4171
4361
  }
4172
4362
 
4173
- function wdSwitchNode(userId, node) {
4363
+ function wdSwitchNode(userId, node, port) {
4364
+ var portLabel = port ? "(代理通道 " + port + ")" : "";
4174
4365
  if (
4175
4366
  !confirm(
4176
4367
  "确定要将 " +
4177
4368
  userId +
4178
- " 的代理切换到: " +
4369
+ " 的代理" +
4370
+ portLabel +
4371
+ "切换到: " +
4179
4372
  node +
4180
4373
  " ?\n\n切换后自动巡检会继续运行。",
4181
4374
  )
@@ -4185,7 +4378,11 @@ function wdSwitchNode(userId, node) {
4185
4378
  fetch("/api/watchdog/switch-node", {
4186
4379
  method: "POST",
4187
4380
  headers: { "Content-Type": "application/json" },
4188
- body: JSON.stringify({ userId: userId, node: node }),
4381
+ body: JSON.stringify({
4382
+ userId: userId,
4383
+ node: node,
4384
+ proxyPort: port || undefined,
4385
+ }),
4189
4386
  })
4190
4387
  .then(function (r) {
4191
4388
  return r.json();
@@ -667,7 +667,26 @@
667
667
  </div>
668
668
  </div>
669
669
 
670
- <script src="app.js?v=20260811b"></script>
670
+ <!-- 登录检测端口配置弹窗 -->
671
+ <div id="wdLoginPortsModal"
672
+ 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">
673
+ <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:480px">
674
+ <div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑 登录检测端口 — <span id="wdLoginPortsUserId"></span>
675
+ </div>
676
+ <div style="color:#888;font-size:12px;margin-bottom:12px">配置该客户端要定期检测登录状态的 CDP 端口(逗号分隔,如 <b
677
+ style="color:#4ade80">9222,9223,9225</b>)。<br>客户端每隔一段时间自动检测并上报结果,留空保存可清空配置。</div>
678
+ <input id="wdLoginPortsInput" placeholder="9222,9223,9225"
679
+ style="width:100%;padding:8px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:6px;font-size:13px;box-sizing:border-box">
680
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
681
+ <button onclick="document.getElementById('wdLoginPortsModal').style.display='none'"
682
+ style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
683
+ <button onclick="wdSetLoginPorts()"
684
+ style="padding:8px 16px;background:#4ade80;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存并下发</button>
685
+ </div>
686
+ </div>
687
+ </div>
688
+
689
+ <script src="app.js?v=20260816b"></script>
671
690
  </body>
672
691
 
673
692
  </html>
@@ -120,6 +120,8 @@ function getOnlineClients() {
120
120
  processes,
121
121
  llmAnalysis,
122
122
  clashHealth: info.clashHealth || null,
123
+ clashHealthChannels: info.clashHealthChannels || [],
124
+ loginReport: info.loginReport || null,
123
125
  lastHeartbeat: info.lastHeartbeat,
124
126
  registeredAt: info.registeredAt,
125
127
  online,
@@ -242,6 +244,43 @@ function getAllErrorTriggers() {
242
244
  return cfg.errorTriggers || {};
243
245
  }
244
246
 
247
+ // ── 登录端口检测配置管理(per agent,后端配置要检查的 CDP 端口列表) ──
248
+
249
+ function getLoginPortsStored(userId) {
250
+ if (!userId) return [];
251
+ const cfg = loadConfig();
252
+ const entry = cfg.loginPorts && cfg.loginPorts[userId];
253
+ if (entry && Array.isArray(entry.ports)) return entry.ports;
254
+ return [];
255
+ }
256
+
257
+ function setLoginPortsStored(userId, ports) {
258
+ const cleaned = [
259
+ ...new Set(
260
+ (Array.isArray(ports) ? ports : [])
261
+ .map((p) => parseInt(p, 10))
262
+ .filter((p) => Number.isInteger(p) && p > 0 && p < 65536),
263
+ ),
264
+ ];
265
+ const cfg = loadConfig();
266
+ if (!cfg.loginPorts) cfg.loginPorts = {};
267
+ if (cleaned.length === 0) {
268
+ delete cfg.loginPorts[userId];
269
+ } else {
270
+ cfg.loginPorts[userId] = {
271
+ ports: cleaned,
272
+ updatedAt: new Date().toISOString(),
273
+ };
274
+ }
275
+ saveConfig(cfg);
276
+ return cleaned;
277
+ }
278
+
279
+ function getAllLoginPorts() {
280
+ const cfg = loadConfig();
281
+ return cfg.loginPorts || {};
282
+ }
283
+
245
284
  // ── 节点国家配置管理(per agent,默认 US,幂等下发) ──
246
285
 
247
286
  /** 读取某 agent 的国家配置,未配置时默认 ["US"] */
@@ -324,6 +363,10 @@ async function handleHeartbeat(params, body, res) {
324
363
  config: body.config || {},
325
364
  processes: body.processes || [],
326
365
  clashHealth: body.clashHealth || null,
366
+ clashHealthChannels: Array.isArray(body.clashHealthChannels)
367
+ ? body.clashHealthChannels
368
+ : [],
369
+ loginReport: body.loginReport || null,
327
370
  });
328
371
 
329
372
  // 按 userId 返回该客户端的专属命令列表
@@ -362,6 +405,7 @@ async function handleHeartbeat(params, body, res) {
362
405
  clashCommands: clashCommands.length > 0 ? clashCommands : undefined,
363
406
  clientConfig: getClientConfigStored(userId),
364
407
  errorTrigger: getErrorTriggerStored(userId),
408
+ loginPorts: getLoginPortsStored(userId),
365
409
  serverTime: Date.now(),
366
410
  });
367
411
  }
@@ -375,6 +419,7 @@ async function handleGetClients(params, body, res) {
375
419
  clashCountries: getAllClientCountries(),
376
420
  supportedCountries: SUPPORTED_COUNTRIES,
377
421
  errorTriggers: getAllErrorTriggers(),
422
+ loginPorts: getAllLoginPorts(),
378
423
  configPath: WATCHDOG_CONFIG_PATH,
379
424
  });
380
425
  }
@@ -445,6 +490,7 @@ async function handleDeleteClient(params, body, res) {
445
490
  if (cfg.clientConfigs) delete cfg.clientConfigs[userId];
446
491
  if (cfg.clashCountries) delete cfg.clashCountries[userId];
447
492
  if (cfg.errorTriggers) delete cfg.errorTriggers[userId];
493
+ if (cfg.loginPorts) delete cfg.loginPorts[userId];
448
494
  saveConfig(cfg);
449
495
  lastSentCountries.delete(userId);
450
496
  clashCmdQueue.delete(userId);
@@ -492,16 +538,22 @@ async function handleClearLlmAnalysis(params, body, res) {
492
538
  }
493
539
 
494
540
  async function handleSwitchNode(params, body, res) {
495
- const { userId, node } = body || {};
541
+ const { userId, node, proxyPort } = body || {};
496
542
  if (!userId || !node) {
497
543
  sendJSON(res, 400, { error: "缺少 userId 或 node" });
498
544
  return;
499
545
  }
500
- // 加入 Clash 命令队列,客户端下次心跳时取走
546
+ // 加入 Clash 命令队列,客户端下次心跳时取走(proxyPort 用于路由到对应控制端)
501
547
  const queue = clashCmdQueue.get(userId) || [];
502
- queue.push({ action: "switchNode", node });
548
+ queue.push({
549
+ action: "switchNode",
550
+ node,
551
+ proxyPort: proxyPort ? Number(proxyPort) : undefined,
552
+ });
503
553
  clashCmdQueue.set(userId, queue);
504
- console.error(`[watchdog] ${userId} 节点切换已入队: → ${node}`);
554
+ console.error(
555
+ `[watchdog] ${userId} 节点切换已入队: → ${node}${proxyPort ? ` (通道 ${proxyPort})` : ""}`,
556
+ );
505
557
  sendJSON(res, 200, { ok: true, message: `将在下次心跳时切换到 ${node}` });
506
558
  }
507
559
 
@@ -558,6 +610,36 @@ async function handleSetErrorTrigger(params, body, res) {
558
610
  sendJSON(res, 200, { ok: true, config: cleaned });
559
611
  }
560
612
 
613
+ /** 配置某 agent 要检测登录状态的 CDP 端口列表(逗号分隔或数组) */
614
+ async function handleSetLoginPorts(params, body, res) {
615
+ const { userId, ports } = body || {};
616
+ if (!userId) {
617
+ sendJSON(res, 400, { error: "缺少 userId" });
618
+ return;
619
+ }
620
+ // 前端可能传字符串 "9222,9223",统一转数组
621
+ let portArr = ports;
622
+ if (typeof ports === "string") {
623
+ portArr = ports
624
+ .split(",")
625
+ .map((s) => s.trim())
626
+ .filter(Boolean)
627
+ .map((s) => parseInt(s, 10));
628
+ }
629
+ const cleaned = setLoginPortsStored(userId, portArr);
630
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
631
+ console.error(
632
+ `[watchdog ${ts}] ${userId} 登录检测端口已更新: ${cleaned.join(",") || "已清空"}`,
633
+ );
634
+ sendJSON(res, 200, {
635
+ ok: true,
636
+ ports: cleaned,
637
+ message: cleaned.length
638
+ ? `已配置检测端口 ${cleaned.join(",")},客户端将在下次心跳时生效`
639
+ : "已清空检测端口",
640
+ });
641
+ }
642
+
561
643
  // ── 公开 ──
562
644
 
563
645
  export function registerWatchdogRoutes(httpServer) {
@@ -635,5 +717,9 @@ export async function tryHandleWatchdogRoute(
635
717
  await handleSetErrorTrigger(params, body, res);
636
718
  return true;
637
719
  }
720
+ if (method === "POST" && routePath === "/api/watchdog/login-ports") {
721
+ await handleSetLoginPorts(params, body, res);
722
+ return true;
723
+ }
638
724
  return false;
639
725
  }