tt-help-cli-ycl 1.4.171 → 1.4.175

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.
@@ -4192,6 +4192,49 @@ function esc(s) {
4192
4192
 
4193
4193
  var _wdLastRendered = "";
4194
4194
 
4195
+ // ── 看门狗客户端卡片折叠(默认只显示头部,点击展开全部;内存态,刷新重置) ──
4196
+ var _wdCardExpandAll = false; // 全局默认态(顶栏「全部展开/收起」切换)
4197
+ var _wdCardExpanded = {}; // 单卡覆盖:userId → true/false(点过单卡按钮才有)
4198
+
4199
+ function wdCardIsExpanded(userId) {
4200
+ return _wdCardExpanded[userId] !== undefined
4201
+ ? _wdCardExpanded[userId]
4202
+ : _wdCardExpandAll;
4203
+ }
4204
+
4205
+ // 单卡展开/收起:直接切 DOM 不重渲染(数据变化重建时按此状态生成)
4206
+ function wdToggleCardBody(userId) {
4207
+ var body = document.getElementById("wdCardBody-" + wdEscId(userId));
4208
+ if (!body) return;
4209
+ var expand = body.style.display === "none";
4210
+ body.style.display = expand ? "" : "none";
4211
+ _wdCardExpanded[userId] = expand;
4212
+ var btn = document.getElementById("wdCardToggle-" + wdEscId(userId));
4213
+ if (btn) {
4214
+ btn.textContent = expand ? "▲ 收起" : "▼ 展开";
4215
+ btn.title = expand ? "收起卡片" : "展开卡片";
4216
+ }
4217
+ }
4218
+
4219
+ // 顶栏总开关:重置单卡覆盖,全部统一切换
4220
+ function wdToggleAllCards() {
4221
+ _wdCardExpandAll = !_wdCardExpandAll;
4222
+ _wdCardExpanded = {};
4223
+ var bodies = document.querySelectorAll("[id^='wdCardBody-']");
4224
+ for (var i = 0; i < bodies.length; i++) {
4225
+ bodies[i].style.display = _wdCardExpandAll ? "" : "none";
4226
+ var btn = document.getElementById(
4227
+ bodies[i].id.replace("wdCardBody-", "wdCardToggle-"),
4228
+ );
4229
+ if (btn) {
4230
+ btn.textContent = _wdCardExpandAll ? "▲ 收起" : "▼ 展开";
4231
+ btn.title = _wdCardExpandAll ? "收起卡片" : "展开卡片";
4232
+ }
4233
+ }
4234
+ var label = document.getElementById("wdToggleAllLabel");
4235
+ if (label) label.textContent = _wdCardExpandAll ? "⊟ 全部收起" : "⊞ 全部展开";
4236
+ }
4237
+
4195
4238
  function renderWatchdogDashboard(
4196
4239
  clients,
4197
4240
  clientCommands,
@@ -4242,9 +4285,9 @@ function renderWatchdogDashboard(
4242
4285
  b.hostname ||
4243
4286
  ""
4244
4287
  ).toLowerCase();
4245
- // 在线优先
4288
+ // 在线优先;名称用自然数字序(F-2 排在 F-10 前)
4246
4289
  if (a.online !== b.online) return a.online ? -1 : 1;
4247
- return nameA.localeCompare(nameB);
4290
+ return nameA.localeCompare(nameB, undefined, { numeric: true });
4248
4291
  });
4249
4292
 
4250
4293
  // 构建 userId → 在线客户端 映射
@@ -4281,7 +4324,9 @@ function renderWatchdogDashboard(
4281
4324
  }
4282
4325
  }
4283
4326
  offlineUids.sort(function (a, b) {
4284
- return a.toLowerCase().localeCompare(b.toLowerCase());
4327
+ return a
4328
+ .toLowerCase()
4329
+ .localeCompare(b.toLowerCase(), undefined, { numeric: true });
4285
4330
  });
4286
4331
  offlineUids.forEach(function (uid) {
4287
4332
  allCards.push(
@@ -4348,6 +4393,29 @@ function renderOfflineCard(
4348
4393
  autoUpgrades,
4349
4394
  ) {
4350
4395
  var cmdCount = cmds ? cmds.length : 0;
4396
+ var expanded = wdCardIsExpanded(userId);
4397
+ // 第二行摘要:离线卡最关心的就是配置了多少命令,整行可点展开/收起
4398
+ var summaryLine = tag(
4399
+ "div",
4400
+ {
4401
+ style:
4402
+ "display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px;margin-top:4px;cursor:pointer",
4403
+ onclick: "wdToggleCardBody('" + esc(userId) + "')",
4404
+ title: "点击展开/收起卡片",
4405
+ },
4406
+ styled("span", "color:#f59e0b", "配置 " + cmdCount + " 条命令"),
4407
+ );
4408
+ var toggleBtn = tag(
4409
+ "button",
4410
+ {
4411
+ id: "wdCardToggle-" + wdEscId(userId),
4412
+ onclick: "wdToggleCardBody('" + esc(userId) + "')",
4413
+ style:
4414
+ "padding:2px 8px;font-size:11px;background:#333;color:#888;border:1px solid #444;border-radius:3px;cursor:pointer",
4415
+ title: expanded ? "收起卡片" : "展开卡片",
4416
+ },
4417
+ expanded ? "▲ 收起" : "▼ 展开",
4418
+ );
4351
4419
  return tag(
4352
4420
  "div",
4353
4421
  {
@@ -4411,20 +4479,24 @@ function renderOfflineCard(
4411
4479
  title: "删除此客户端配置",
4412
4480
  },
4413
4481
  "删除",
4414
- ),
4482
+ ) +
4483
+ toggleBtn,
4415
4484
  ),
4416
4485
  ) +
4417
- styled(
4486
+ summaryLine +
4487
+ tag(
4418
4488
  "div",
4419
- "color:#555;font-size:12px",
4420
- "该机器尚未连接看门狗,配置了 " + cmdCount + " 条命令",
4421
- ) +
4422
- renderCountrySection({}, userId, clashCountries) +
4423
- renderErrorTriggerSection({}, userId, errorTriggers) +
4424
- renderSwitchRestartSection({}, userId, switchRestarts) +
4425
- renderAutoUpgradeSection({}, userId, autoUpgrades) +
4426
- renderLoginSection({}, userId, loginPorts) +
4427
- renderCmdRows(cmds || [], userId, [], false),
4489
+ {
4490
+ id: "wdCardBody-" + wdEscId(userId),
4491
+ style: expanded ? "" : "display:none",
4492
+ },
4493
+ renderCountrySection({}, userId, clashCountries) +
4494
+ renderErrorTriggerSection({}, userId, errorTriggers) +
4495
+ renderSwitchRestartSection({}, userId, switchRestarts) +
4496
+ renderAutoUpgradeSection({}, userId, autoUpgrades) +
4497
+ renderLoginSection({}, userId, loginPorts) +
4498
+ renderCmdRows(cmds || [], userId, [], false),
4499
+ ),
4428
4500
  );
4429
4501
  }
4430
4502
 
@@ -4474,6 +4546,78 @@ function renderClientCard(
4474
4546
  var myCmds = userId && clientCommands[userId] ? clientCommands[userId] : [];
4475
4547
  var online = c.online;
4476
4548
 
4549
+ var uptimeStr = os.uptime != null ? Math.round(os.uptime / 3600) + "h" : "?";
4550
+ // 第二行巡检摘要:版本/进程/登录/国家(异常项高亮),整行可点展开/收起
4551
+ var procRun = procs.filter(function (p) {
4552
+ return p.status === "running";
4553
+ }).length;
4554
+ var procColor = procRun < procs.length ? "#f87171" : "#888";
4555
+ var procTxt =
4556
+ "进程 " + procRun + "/" + procs.length + (procRun < procs.length ? " ⚠" : "");
4557
+
4558
+ var loginTxt = "登录 —";
4559
+ var loginColor = "#555";
4560
+ var rep = c.loginReport;
4561
+ if (rep && rep.error) {
4562
+ loginTxt = "登录 检测失败";
4563
+ loginColor = "#f87171";
4564
+ } else if (rep && Array.isArray(rep.ports) && rep.ports.length) {
4565
+ var loginOk = rep.ports.filter(function (r) {
4566
+ return r.status === "logged_in";
4567
+ }).length;
4568
+ loginTxt =
4569
+ "登录 " +
4570
+ loginOk +
4571
+ "/" +
4572
+ rep.ports.length +
4573
+ (loginOk === rep.ports.length ? " ✓" : " ⚠");
4574
+ loginColor = loginOk === rep.ports.length ? "#4ade80" : "#fbbf24";
4575
+ }
4576
+
4577
+ var ccEntry = (clashCountries && clashCountries[userId]) || {};
4578
+ var ccCfg =
4579
+ ccEntry.countries && ccEntry.countries.length ? ccEntry.countries : ["US"];
4580
+ var ccActive = c.clashHealth && c.clashHealth.meta
4581
+ ? c.clashHealth.meta.countryFilter
4582
+ : null;
4583
+ var ccActiveStr = Array.isArray(ccActive) ? ccActive.join(",") : ccActive;
4584
+ var ccCfgStr = ccCfg.join(",");
4585
+ var ccColor = "#888";
4586
+ var ccTxt = "🌍 " + ccCfgStr;
4587
+ if (ccActiveStr && ccActiveStr !== ccCfgStr) {
4588
+ ccTxt = "🌍 " + ccCfgStr + "≠" + ccActiveStr + " ⚠";
4589
+ ccColor = "#fbbf24";
4590
+ }
4591
+
4592
+ var summaryLine = tag(
4593
+ "div",
4594
+ {
4595
+ style:
4596
+ "display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px;margin-top:4px;cursor:pointer",
4597
+ onclick: "wdToggleCardBody('" + esc(userId) + "')",
4598
+ title: "点击展开/收起卡片",
4599
+ },
4600
+ styled("span", "color:#60a5fa", "v" + (c.ttHelpVersion || "?")) +
4601
+ styled("span", "color:" + procColor, procTxt) +
4602
+ styled("span", "color:" + loginColor, loginTxt) +
4603
+ styled("span", "color:" + ccColor, ccTxt) +
4604
+ styled("span", "color:#888", "运行 " + uptimeStr),
4605
+ );
4606
+
4607
+ // 折叠状态:默认只显示头部,点展开按钮看全部(需在 header 组装前定义)
4608
+ var expanded = wdCardIsExpanded(userId);
4609
+ var toggleBtn = tag(
4610
+ "button",
4611
+ {
4612
+ id: "wdCardToggle-" + wdEscId(userId),
4613
+ onclick: "wdToggleCardBody('" + esc(userId) + "')",
4614
+ style:
4615
+ "padding:2px 8px;font-size:11px;background:#333;color:#888;border:1px solid #444;border-radius:3px;cursor:pointer",
4616
+ title: expanded ? "收起卡片" : "展开卡片",
4617
+ },
4618
+ expanded ? "▲ 收起" : "▼ 展开",
4619
+ );
4620
+
4477
4621
  var osStr = [
4478
4622
  os.platform,
4479
4623
  os.release,
@@ -4578,6 +4722,11 @@ function renderClientCard(
4578
4722
  : "color:#fca5a5;font-size:12px",
4579
4723
  "最后心跳 " + lastSeen,
4580
4724
  ) +
4725
+ styled(
4726
+ "span",
4727
+ "color:#888;font-size:13px",
4728
+ "进程 " + procs.length,
4729
+ ) +
4581
4730
  (!online && userId
4582
4731
  ? tag(
4583
4732
  "button",
@@ -4589,7 +4738,8 @@ function renderClientCard(
4589
4738
  },
4590
4739
  "删除",
4591
4740
  )
4592
- : ""),
4741
+ : "") +
4742
+ toggleBtn,
4593
4743
  ),
4594
4744
  );
4595
4745
 
@@ -4603,15 +4753,34 @@ function renderClientCard(
4603
4753
  ";border-radius:12px;padding:16px",
4604
4754
  },
4605
4755
  header +
4606
- styled("div", "color:#888;font-size:12px", osStr) +
4607
- styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
4608
- renderCountrySection(c, userId, clashCountries) +
4609
- renderErrorTriggerSection(c, userId, errorTriggers) +
4610
- renderSwitchRestartSection(c, userId, switchRestarts) +
4611
- renderAutoUpgradeSection(c, userId, autoUpgrades) +
4612
- renderLoginSection(c, userId, loginPorts) +
4613
- renderProxySection(c, userId) +
4614
- renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis, c.clientId),
4756
+ summaryLine +
4757
+ tag(
4758
+ "div",
4759
+ {
4760
+ id: "wdCardBody-" + wdEscId(userId),
4761
+ style: expanded ? "" : "display:none",
4762
+ },
4763
+ styled("div", "color:#888;font-size:12px", osStr) +
4764
+ styled(
4765
+ "div",
4766
+ "color:#888;font-size:12px;margin-bottom:6px",
4767
+ infoLine,
4768
+ ) +
4769
+ renderCountrySection(c, userId, clashCountries) +
4770
+ renderErrorTriggerSection(c, userId, errorTriggers) +
4771
+ renderSwitchRestartSection(c, userId, switchRestarts) +
4772
+ renderAutoUpgradeSection(c, userId, autoUpgrades) +
4773
+ renderLoginSection(c, userId, loginPorts) +
4774
+ renderProxySection(c, userId) +
4775
+ renderCmdRows(
4776
+ myCmds,
4777
+ userId,
4778
+ procs,
4779
+ online,
4780
+ c.llmAnalysis,
4781
+ c.clientId,
4782
+ ),
4783
+ ),
4615
4784
  );
4616
4785
  }
4617
4786
 
@@ -5064,6 +5233,14 @@ function wdSubRefsCell(s) {
5064
5233
  .replace("<span", '<span title="' + escHtml(names) + '"');
5065
5234
  }
5066
5235
 
5236
+ function wdSubWeightCell(s) {
5237
+ var w = parseInt(s.weight, 10);
5238
+ if (!Number.isFinite(w) || w <= 1)
5239
+ return styled("span", "color:#555;font-size:11px", "×1");
5240
+ return styled("span", "color:#4ade80;font-weight:700;font-size:11px;cursor:help", "×" + w)
5241
+ .replace("<span", '<span title="自动分配权重:占比≈权重比(修改后下次心跳生效)"');
5242
+ }
5243
+
5067
5244
  function wdSubSourceCell(s) {
5068
5245
  if (s.source === "probe") {
5069
5246
  var p = s.lastProbe || {};
@@ -5179,6 +5356,7 @@ function renderSubscriptionsCenter(subs) {
5179
5356
  styled("div", "flex:0 0 160px", wdSubTrafficCell(s)) +
5180
5357
  styled("div", "flex:0 0 130px", wdSubExpireCell(s)) +
5181
5358
  styled("div", "flex:0 0 80px;text-align:center", wdSubRefsCell(s)) +
5359
+ styled("div", "flex:0 0 48px;text-align:center;", wdSubWeightCell(s)) +
5182
5360
  styled("div", "flex:1;overflow:hidden", wdSubSourceCell(s)) +
5183
5361
  styled("div", "display:flex;gap:6px;flex:0 0 auto", actions));
5184
5362
  });
@@ -5202,6 +5380,7 @@ function wdSubShowAddModal() {
5202
5380
  document.getElementById("wdSubName").value = "";
5203
5381
  document.getElementById("wdSubUrl").value = "";
5204
5382
  document.getElementById("wdSubNote").value = "";
5383
+ document.getElementById("wdSubWeight").value = 1;
5205
5384
  document.getElementById("wdSubModal").style.display = "flex";
5206
5385
  }
5207
5386
 
@@ -5218,6 +5397,7 @@ function wdSubShowEditModal(id) {
5218
5397
  document.getElementById("wdSubName").value = s.name || "";
5219
5398
  document.getElementById("wdSubUrl").value = s.url || "";
5220
5399
  document.getElementById("wdSubNote").value = s.note || "";
5400
+ document.getElementById("wdSubWeight").value = parseInt(s.weight, 10) || 1;
5221
5401
  document.getElementById("wdSubModal").style.display = "flex";
5222
5402
  }
5223
5403
 
@@ -5226,10 +5406,14 @@ function wdSubCloseModal() {
5226
5406
  }
5227
5407
 
5228
5408
  async function wdSubSave() {
5409
+ var w = parseInt(document.getElementById("wdSubWeight").value, 10);
5410
+ if (!Number.isFinite(w)) w = 1;
5411
+ w = Math.max(1, Math.min(10, w));
5229
5412
  var payload = {
5230
5413
  name: document.getElementById("wdSubName").value.trim(),
5231
5414
  url: document.getElementById("wdSubUrl").value.trim(),
5232
5415
  note: document.getElementById("wdSubNote").value.trim(),
5416
+ weight: w,
5233
5417
  };
5234
5418
  if (!payload.url) {
5235
5419
  alert("请填写订阅地址");
@@ -5395,7 +5579,7 @@ function renderLoginSection(c, userId, loginPorts) {
5395
5579
  style:
5396
5580
  "padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
5397
5581
  "border:1px solid #444;border-radius:3px;cursor:pointer",
5398
- title: "立即重检一次登录状态(绕过 2h 周期,结果随心跳自动刷新)",
5582
+ title: "立即重检上次未通过的端口(全部已登录则跳过;结果随心跳自动刷新)",
5399
5583
  },
5400
5584
  "重检",
5401
5585
  );
@@ -644,6 +644,10 @@
644
644
  <div class="label">命令池</div>
645
645
  <div class="value slate" id="wdCmdCount">0</div>
646
646
  </div>
647
+ <div class="stat-card clickable" onclick="wdToggleAllCards()" style="background:rgba(96,165,250,0.05)">
648
+ <div class="label" id="wdToggleAllLabel" style="font-size:18px">⊞</div>
649
+ <div class="value" style="font-size:11px;color:#60a5fa">全部展开</div>
650
+ </div>
647
651
  <div class="stat-card clickable" onclick="fetchWatchdogData()" style="background:rgba(96,165,250,0.05)">
648
652
  <div class="label" style="font-size:18px">🔄</div>
649
653
  <div class="value" style="font-size:11px;color:#60a5fa">刷新</div>
@@ -664,10 +668,18 @@
664
668
  </div>
665
669
  <div id="wdSubsList" style="color:#666;font-size:12px">加载中...</div>
666
670
  </div>
671
+ <!-- 账号中心入口(独立页 /login-pool,同登录门禁) -->
672
+ <div style="margin-bottom:16px;border:1px solid #2a2a3d;border-radius:10px;background:#16161f;padding:12px;display:flex;align-items:center;justify-content:space-between">
673
+ <div>
674
+ <span style="font-size:14px;color:#34d399">🧑‍💼 账户中心(登录池)</span>
675
+ <span style="color:#666;font-size:11px;margin-left:8px">账号在用状态 / 使用者 / 使用次数 / 停用启用</span>
676
+ </div>
677
+ <a href="/login-pool" target="_blank"
678
+ style="padding:5px 14px;font-size:12px;background:#34d399;color:#000;border-radius:6px;font-weight:600;text-decoration:none">打开账号中心 →</a>
679
+ </div>
667
680
  <div id="wdClientsList" style="color:#666;font-size:12px">加载中...</div>
668
681
  </div>
669
682
 
670
- <!-- 订阅中心 新增/编辑弹窗 -->
671
683
  <div id="wdSubModal"
672
684
  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
685
  <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
@@ -688,6 +700,12 @@
688
700
  <input id="wdSubNote" placeholder="如:每月1号重置流量"
689
701
  style="width:100%;box-sizing:border-box;padding:8px 10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px">
690
702
  </div>
703
+ <div style="margin-bottom:10px">
704
+ <div style="color:#94a3b8;font-size:12px;margin-bottom:4px">自动分配权重(1-10,默认 1)</div>
705
+ <input id="wdSubWeight" type="number" min="1" max="10" step="1" value="1"
706
+ style="width:120px;box-sizing:border-box;padding:8px 10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px">
707
+ <div style="color:#666;font-size:11px;margin-top:4px">仅对「⚡自动分配」的节点生效:占比 ≈ 权重比,如设 2 则用量 ≈ 普通订阅的 2 倍;全部为 1 时平均摊开。修改后下次心跳生效。</div>
708
+ </div>
691
709
  <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
692
710
  <button onclick="wdSubCloseModal()"
693
711
  style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
@@ -10,6 +10,17 @@ import { createStore } from "./data-store.js";
10
10
  import { getTagDb } from "./db-schema.js";
11
11
  import { getTargetLocations } from "../lib/app-config.js";
12
12
  import { tryHandleWatchdogRoute } from "./watchdog-server.js";
13
+ import {
14
+ getLoginPoolView,
15
+ addAccount,
16
+ updateAccount,
17
+ deleteAccount,
18
+ assignAccount,
19
+ releaseAccount,
20
+ takeAccount,
21
+ acquireAccount,
22
+ importAccount,
23
+ } from "./login-pool.js";
13
24
 
14
25
  const __filename = fileURLToPath(import.meta.url);
15
26
 
@@ -624,6 +635,174 @@ ${err}
624
635
  res.end(html);
625
636
  }
626
637
 
638
+ /** 账号中心(登录池)页面:自包含,无外部依赖,脱敏展示 + 基础管理动作 */
639
+ function serveLoginPoolPage(res) {
640
+ const html = `<!DOCTYPE html>
641
+ <html><head>
642
+ <meta charset="utf-8">
643
+ <meta name="viewport" content="width=device-width, initial-scale=1">
644
+ <title>账号中心 · 登录池</title>
645
+ <style>
646
+ body { font-family: system-ui, sans-serif; margin: 20px; background: #f7f8fa; color: #1f2329; }
647
+ h1 { font-size: 20px; }
648
+ .bar { margin: 12px 0; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
649
+ button { padding: 4px 10px; border: 1px solid #d0d3d9; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; }
650
+ button:hover { background: #f0f2f5; }
651
+ button:disabled { color: #c0c4cc; cursor: not-allowed; background: #fafafa; }
652
+ table { border-collapse: collapse; width: 100%; background: #fff; }
653
+ th, td { border: 1px solid #e5e7eb; padding: 6px 10px; font-size: 13px; text-align: left; vertical-align: top; }
654
+ th { background: #f2f3f5; white-space: nowrap; }
655
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
656
+ .b-leased { background: #dbeafe; color: #2563eb; }
657
+ .b-pool { background: #dcfce7; color: #16a34a; }
658
+ .b-suspect { background: #fef3c7; color: #d97706; }
659
+ .b-retired { background: #e5e7eb; color: #6b7280; }
660
+ .muted { color: #9ca3af; font-size: 12px; }
661
+ .warn { color: #dc2626; }
662
+ #msg { margin: 0 8px; }
663
+ details { margin-top: 2px; }
664
+ summary { cursor: pointer; }
665
+ </style>
666
+ </head>
667
+ <body>
668
+ <h1>账号中心 · 登录池</h1>
669
+ <div class="bar">
670
+ <button onclick="load()">刷新</button>
671
+ <label><input type="checkbox" id="auto" checked> 自动刷新(15s)</label>
672
+ <button onclick="addDlg()">入池</button>
673
+ <button onclick="takeDlg()" title="调试:走命令队列的 take(watchdog 注入流)">模拟取号</button>
674
+ <span id="msg"></span>
675
+ </div>
676
+ <div id="content">加载中…</div>
677
+ <script>
678
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\\'':'&#39;'}[c]));
679
+ const fmt = t => t ? new Date(t).toLocaleString('zh-CN', {hour12:false}) : '—';
680
+ const day = t => t ? new Date(t).toLocaleDateString('zh-CN') : '—';
681
+ const ST = { leased: ['使用中','b-leased'], pool: ['空闲','b-pool'], suspect: ['疑似异常','b-suspect'], retired: ['已停用','b-retired'] };
682
+ const ORDER = { leased: 0, pool: 1, suspect: 2, retired: 3 };
683
+ const rel = t => {
684
+ if (!t) return '—';
685
+ const d = Date.now() - t;
686
+ if (d < 60000) return '刚刚';
687
+ if (d < 3600000) return Math.floor(d / 60000) + '分钟前';
688
+ if (d < 86400000) return Math.floor(d / 3600000) + '小时前';
689
+ return Math.floor(d / 86400000) + '天前';
690
+ };
691
+ async function api(p, body) {
692
+ const r = await fetch(p, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : undefined);
693
+ const j = await r.json().catch(() => ({}));
694
+ if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
695
+ return j;
696
+ }
697
+ function msg(t, err) { const el = document.getElementById('msg'); el.textContent = t; el.style.color = err ? '#dc2626' : '#16a34a'; }
698
+ const openHist = new Set(); // 展开了历史明细的账号 id(跨自动刷新保留)
699
+ function onHistToggle(el) {
700
+ const id = el.dataset.acc;
701
+ if (el.open) openHist.add(id);
702
+ else openHist.delete(id);
703
+ }
704
+ async function load() {
705
+ try {
706
+ const j = await api('/api/login-pool');
707
+ const list = j.accounts.slice().sort((a, b) =>
708
+ ((ORDER[a.status] ?? 9) - (ORDER[b.status] ?? 9)) ||
709
+ ((b.lastUsedAt || b.createdAt || 0) - (a.lastUsedAt || a.createdAt || 0)));
710
+ const rows = list.map((a, idx) => {
711
+ const l = a.lease;
712
+ const biz = a.lastBusinessOkAt ? fmt(a.lastBusinessOkAt) : '无记录';
713
+ const expired = a.sessionExpiresAt && a.sessionExpiresAt < Date.now();
714
+ const st = ST[a.status] || [a.status, ''];
715
+ const who = l
716
+ ? esc(l.userId) + (l.port ? ':' + esc(l.port) : '') + (l.appName ? ' <span class="muted">· ' + esc(l.appName) + '</span>' : '')
717
+ : '<span class="muted">—</span>';
718
+ const meta = [];
719
+ if (a.note) meta.push(esc(a.note));
720
+ if (a.proxyHint) meta.push('出口 ' + esc(a.proxyHint));
721
+ if (a.cookieCount) meta.push('cookies ' + a.cookieCount + ' 条');
722
+ return '<tr>' +
723
+ '<td class="muted">' + (idx + 1) + '</td>' +
724
+ '<td><b>' + esc(a.username) + '</b>' +
725
+ (meta.length ? '<div class="muted">' + meta.join(' · ') + '</div>' : '') + '</td>' +
726
+ '<td><span class="badge ' + st[1] + '">' + st[0] + '</span></td>' +
727
+ '<td>' + who + '</td>' +
728
+ '<td title="最后使用: ' + fmt(a.lastUsedAt) + '\\n最后出活: ' + biz + '">' + rel(a.lastUsedAt) + '</td>' +
729
+ '<td>' + (a.useCount || 0) + '</td>' +
730
+ '<td' + (expired ? ' class="warn"' : '') + ' title="sessionid 到期: ' + fmt(a.sessionExpiresAt) + '(cookie 承诺寿命,非实时有效性)">' + day(a.sessionExpiresAt) + (expired ? '(已过)' : '') + '</td>' +
731
+ '<td>' +
732
+ (a.status === 'leased' ? '<button onclick="releaseDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')">释放</button> ' : '') +
733
+ (a.status === 'pool' ? '<button onclick="assignDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')" title="手动指派给节点端口(watchdog 注入流)">指派</button> ' : '') +
734
+ (a.status === 'suspect' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',true)">启用</button> ' : '') +
735
+ (a.status === 'retired' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',false)">启用</button> ' : '') +
736
+ (a.status !== 'retired' ? '<button onclick="disable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',' + (a.status === 'leased') + ')">停用</button> ' : '') +
737
+ (a.status === 'retired' ? '<button onclick="del(\\'' + a.id + '\\')">删除</button> ' : '') +
738
+ '<button disabled title="开发中:引导重新登录并更新池内登录态">重登录</button>' +
739
+ '</td>' +
740
+ '<td><details data-acc="' + esc(a.id) + '" ' + (openHist.has(a.id) ? 'open' : '') + ' ontoggle="onHistToggle(this)"><summary class="muted">' + (a.history ? a.history.length : 0) + ' 条</summary>' +
741
+ (a.history || []).slice(0, 8).map(h => '<div class="muted">' + fmt(h.at) + ' ' + esc(h.event) + ' ' + esc(h.detail) + '</div>').join('') + '</details></td>' +
742
+ '</tr>';
743
+ }).join('');
744
+ const nLeased = j.accounts.filter(a => a.status === 'leased').length;
745
+ const nFree = j.accounts.filter(a => a.status === 'pool').length;
746
+ document.getElementById('content').innerHTML =
747
+ '<table><tr><th>#</th><th>账号</th><th>状态</th><th>使用者</th><th>最近使用</th><th>次数</th><th>有效期</th><th>操作</th><th>历史</th></tr>' + rows + '</table>' +
748
+ '<p class="muted">共 ' + j.total + ' 个 · 使用中 ' + nLeased + ' · 空闲 ' + nFree + ' · 排序:使用中 → 空闲 → 疑似 → 已停用 · 有效期 = sessionid cookie 承诺寿命(非实时有效性,真实判据 = 业务出活)· cookies 明文不出服务端</p>';
749
+ } catch (e) { msg('加载失败: ' + e.message, true); }
750
+ }
751
+ async function addDlg() {
752
+ const username = prompt('账号名(uniqueId,可留空自动从 cookies 提取):'); if (username === null) return;
753
+ const cookiesRaw = prompt('cookies JSON 数组(可直接粘 export.mjs 导出的整个 JSON,会自动取 cookies 字段):'); if (!cookiesRaw) return;
754
+ try {
755
+ let cookies = JSON.parse(cookiesRaw);
756
+ if (cookies && !Array.isArray(cookies) && Array.isArray(cookies.cookies)) cookies = cookies.cookies;
757
+ const note = prompt('备注(可空,如来源 F-11:9223):');
758
+ await api('/api/login-pool/add', { username: username || undefined, cookies, note: note || undefined });
759
+ msg('入池成功'); load();
760
+ } catch (e) { msg('入池失败: ' + e.message, true); }
761
+ }
762
+ async function takeDlg() {
763
+ const userId = prompt('节点 userId(如 F-11):'); if (!userId) return;
764
+ const port = prompt('端口:'); if (!port) return;
765
+ try { const j = await api('/api/login-pool/take', { userId, port: Number(port) }); msg('已取号: ' + j.account.username); load(); }
766
+ catch (e) { msg('取号失败: ' + e.message, true); }
767
+ }
768
+ async function assignDlg(id, name) {
769
+ const userId = prompt('指派给节点 userId(如 F-11):'); if (!userId) return;
770
+ const port = prompt('端口:'); if (!port) return;
771
+ try { await api('/api/login-pool/assign', { id, userId, port: Number(port) }); msg('已指派 ' + name); load(); }
772
+ catch (e) { msg('指派失败: ' + e.message, true); }
773
+ }
774
+ async function releaseDlg(id, name) {
775
+ const clearOnNode = confirm('释放 ' + name + '。\\n确定 = 同时下发 clearLogin 清理节点登录态;取消 = 仅释放租约(节点保留当前登录态)');
776
+ try { await api('/api/login-pool/release', { id, clearOnNode }); msg('已释放 ' + name); load(); }
777
+ catch (e) { msg('释放失败: ' + e.message, true); }
778
+ }
779
+ async function enable(id, name, suspect) {
780
+ if (suspect && !confirm(name + ' 为疑似异常状态,确认启用回池?')) return;
781
+ try { await api('/api/login-pool/update', { id, status: 'pool' }); msg('已启用 ' + name); load(); }
782
+ catch (e) { msg('失败: ' + e.message, true); }
783
+ }
784
+ async function disable(id, name, leased) {
785
+ const q = leased
786
+ ? name + ' 使用中:停用将同时释放租约(节点本地登录态保留)。确认停用?'
787
+ : '确认停用 ' + name + '?停用后不参与自动取号';
788
+ if (!confirm(q)) return;
789
+ try { await api('/api/login-pool/update', { id, status: 'retired' }); msg('已停用 ' + name); load(); }
790
+ catch (e) { msg('失败: ' + e.message, true); }
791
+ }
792
+ async function del(id) {
793
+ if (!confirm('确认删除该账号?不可恢复')) return;
794
+ try { await api('/api/login-pool/delete', { id }); msg('已删除'); load(); }
795
+ catch (e) { msg('失败: ' + e.message, true); }
796
+ }
797
+ load();
798
+ setInterval(() => { if (document.getElementById('auto').checked) load(); }, 15000);
799
+ </script>
800
+ </body>
801
+ </html>`;
802
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
803
+ res.end(html);
804
+ }
805
+
627
806
  export function startWatchServer(
628
807
  dataAnchor,
629
808
  port = 3000,
@@ -1405,6 +1584,61 @@ export function startWatchServer(
1405
1584
  return;
1406
1585
  }
1407
1586
 
1587
+ // ── 登录池(账号中心)端点:输出一律脱敏,cookies 明文仅存服务端/心跳下发通道 ──
1588
+ if (req.method === "GET" && routePath === "/api/login-pool") {
1589
+ sendJSON(res, 200, getLoginPoolView());
1590
+ return;
1591
+ }
1592
+ if (req.method === "POST" && routePath.startsWith("/api/login-pool/")) {
1593
+ const lpAction = routePath.slice("/api/login-pool/".length);
1594
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
1595
+ try {
1596
+ const body = await readBody(req);
1597
+ let ret;
1598
+ if (lpAction === "add") {
1599
+ ret = addAccount(body);
1600
+ console.error(`[登录池 ${ts}] ADD: ${ret.username} (${ret.cookieCount} 条 cookies)`);
1601
+ } else if (lpAction === "take") {
1602
+ ret = takeAccount(body);
1603
+ console.error(`[登录池 ${ts}] TAKE: ${ret.username} → ${body.userId}:${body.port}`);
1604
+ } else if (lpAction === "acquire") {
1605
+ // Phase 2 直取(以号为主键):响应含明文 cookies(业务进程自注入,不经心跳命令队列)
1606
+ ret = acquireAccount(body);
1607
+ console.error(
1608
+ `[登录池 ${ts}] ACQUIRE: ${ret.username} → ${body.userId}${body.port ? ":" + body.port : ""} (${ret.cookieCount} 条 cookies)`,
1609
+ );
1610
+ } else if (lpAction === "import") {
1611
+ // 节点登录态导入(以号为主键,导入=入池登记为空闲,不自动租):按 username 去重
1612
+ const imp = importAccount(body);
1613
+ ret = imp.account;
1614
+ console.error(
1615
+ `[登录池 ${ts}] IMPORT: ${ret.username} ← ${body.userId}${body.port ? ":" + body.port : ""}${imp.dedup ? "(已在池,幂等跳过)" : "(新入池·空闲)"} (${ret.cookieCount} 条 cookies)`,
1616
+ );
1617
+ sendJSON(res, 200, { ok: true, dedup: imp.dedup, account: ret });
1618
+ return;
1619
+ } else if (lpAction === "assign") {
1620
+ ret = assignAccount(body);
1621
+ console.error(`[登录池 ${ts}] ASSIGN: ${ret.username} → ${body.userId}:${body.port}`);
1622
+ } else if (lpAction === "release") {
1623
+ ret = releaseAccount(body);
1624
+ console.error(`[登录池 ${ts}] RELEASE: ${ret.username} → pool`);
1625
+ } else if (lpAction === "update") {
1626
+ ret = updateAccount(body);
1627
+ console.error(`[登录池 ${ts}] UPDATE: ${ret.username} status=${ret.status}`);
1628
+ } else if (lpAction === "delete") {
1629
+ ret = deleteAccount(body.id);
1630
+ console.error(`[登录池 ${ts}] DELETE: ${ret.username}`);
1631
+ } else {
1632
+ sendJSON(res, 404, { error: `未知登录池操作: ${lpAction}` });
1633
+ return;
1634
+ }
1635
+ sendJSON(res, 200, { ok: true, account: ret });
1636
+ } catch (e) {
1637
+ sendJSON(res, 400, { error: e.message });
1638
+ }
1639
+ return;
1640
+ }
1641
+
1408
1642
  // ── recheck-jobs 端点 ──
1409
1643
  if (req.method === "GET" && routePath === "/api/recheck-jobs") {
1410
1644
  const limit = params.limit;
@@ -3100,6 +3334,24 @@ export function startWatchServer(
3100
3334
  return;
3101
3335
  }
3102
3336
 
3337
+ // ── 账号中心(登录池)独立页面:与主页同一登录门禁 ──
3338
+ if (req.method === "GET" && routePath === "/login-pool") {
3339
+ if (PASSWORD_ENABLED) {
3340
+ const token = parseAuthToken(req, params);
3341
+ const valid =
3342
+ token &&
3343
+ authSessions.has(token) &&
3344
+ authSessions.get(token) > Date.now();
3345
+ if (!valid) {
3346
+ serveLoginPage(res);
3347
+ return;
3348
+ }
3349
+ authSessions.set(token, Date.now() + AUTH_MAX_AGE * 1000);
3350
+ }
3351
+ serveLoginPoolPage(res);
3352
+ return;
3353
+ }
3354
+
3103
3355
  // ====== 静态资源(HTML 页面需登录) ======
3104
3356
 
3105
3357
  if (