tt-help-cli-ycl 1.4.172 → 1.4.176

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.
@@ -4394,6 +4394,17 @@ function renderOfflineCard(
4394
4394
  ) {
4395
4395
  var cmdCount = cmds ? cmds.length : 0;
4396
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
+ );
4397
4408
  var toggleBtn = tag(
4398
4409
  "button",
4399
4410
  {
@@ -4472,18 +4483,14 @@ function renderOfflineCard(
4472
4483
  toggleBtn,
4473
4484
  ),
4474
4485
  ) +
4486
+ summaryLine +
4475
4487
  tag(
4476
4488
  "div",
4477
4489
  {
4478
4490
  id: "wdCardBody-" + wdEscId(userId),
4479
4491
  style: expanded ? "" : "display:none",
4480
4492
  },
4481
- styled(
4482
- "div",
4483
- "color:#555;font-size:12px",
4484
- "该机器尚未连接看门狗,配置了 " + cmdCount + " 条命令",
4485
- ) +
4486
- renderCountrySection({}, userId, clashCountries) +
4493
+ renderCountrySection({}, userId, clashCountries) +
4487
4494
  renderErrorTriggerSection({}, userId, errorTriggers) +
4488
4495
  renderSwitchRestartSection({}, userId, switchRestarts) +
4489
4496
  renderAutoUpgradeSection({}, userId, autoUpgrades) +
@@ -4539,6 +4546,64 @@ function renderClientCard(
4539
4546
  var myCmds = userId && clientCommands[userId] ? clientCommands[userId] : [];
4540
4547
  var online = c.online;
4541
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
+
4542
4607
  // 折叠状态:默认只显示头部,点展开按钮看全部(需在 header 组装前定义)
4543
4608
  var expanded = wdCardIsExpanded(userId);
4544
4609
  var toggleBtn = tag(
@@ -4688,6 +4753,7 @@ function renderClientCard(
4688
4753
  ";border-radius:12px;padding:16px",
4689
4754
  },
4690
4755
  header +
4756
+ summaryLine +
4691
4757
  tag(
4692
4758
  "div",
4693
4759
  {
@@ -5167,6 +5233,14 @@ function wdSubRefsCell(s) {
5167
5233
  .replace("<span", '<span title="' + escHtml(names) + '"');
5168
5234
  }
5169
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
+
5170
5244
  function wdSubSourceCell(s) {
5171
5245
  if (s.source === "probe") {
5172
5246
  var p = s.lastProbe || {};
@@ -5282,6 +5356,7 @@ function renderSubscriptionsCenter(subs) {
5282
5356
  styled("div", "flex:0 0 160px", wdSubTrafficCell(s)) +
5283
5357
  styled("div", "flex:0 0 130px", wdSubExpireCell(s)) +
5284
5358
  styled("div", "flex:0 0 80px;text-align:center", wdSubRefsCell(s)) +
5359
+ styled("div", "flex:0 0 48px;text-align:center;", wdSubWeightCell(s)) +
5285
5360
  styled("div", "flex:1;overflow:hidden", wdSubSourceCell(s)) +
5286
5361
  styled("div", "display:flex;gap:6px;flex:0 0 auto", actions));
5287
5362
  });
@@ -5305,6 +5380,7 @@ function wdSubShowAddModal() {
5305
5380
  document.getElementById("wdSubName").value = "";
5306
5381
  document.getElementById("wdSubUrl").value = "";
5307
5382
  document.getElementById("wdSubNote").value = "";
5383
+ document.getElementById("wdSubWeight").value = 1;
5308
5384
  document.getElementById("wdSubModal").style.display = "flex";
5309
5385
  }
5310
5386
 
@@ -5321,6 +5397,7 @@ function wdSubShowEditModal(id) {
5321
5397
  document.getElementById("wdSubName").value = s.name || "";
5322
5398
  document.getElementById("wdSubUrl").value = s.url || "";
5323
5399
  document.getElementById("wdSubNote").value = s.note || "";
5400
+ document.getElementById("wdSubWeight").value = parseInt(s.weight, 10) || 1;
5324
5401
  document.getElementById("wdSubModal").style.display = "flex";
5325
5402
  }
5326
5403
 
@@ -5329,10 +5406,14 @@ function wdSubCloseModal() {
5329
5406
  }
5330
5407
 
5331
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));
5332
5412
  var payload = {
5333
5413
  name: document.getElementById("wdSubName").value.trim(),
5334
5414
  url: document.getElementById("wdSubUrl").value.trim(),
5335
5415
  note: document.getElementById("wdSubNote").value.trim(),
5416
+ weight: w,
5336
5417
  };
5337
5418
  if (!payload.url) {
5338
5419
  alert("请填写订阅地址");
@@ -5482,33 +5563,16 @@ function renderLoginSection(c, userId, loginPorts) {
5482
5563
  "padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
5483
5564
  "border:1px solid #444;border-radius:3px;cursor:pointer",
5484
5565
  title:
5485
- "配置要定期检测登录状态的 CDP 端口(逗号分隔,留空清空)\n当前: " +
5566
+ "配置启动时导入登录态的 CDP 端口(逗号分隔,留空清空)\n当前: " +
5486
5567
  (configured.length ? configured.join(",") : "无"),
5487
5568
  },
5488
5569
  (configured.length ? configured.length + "端口" : "未配置") + " ⚙️",
5489
5570
  );
5490
5571
 
5491
- // 重检按钮:仅已配置端口的在线客户端显示(手动触发立即检测,结果随心跳自动刷新)
5492
- var recheckBtn = "";
5493
- if (configured.length && c.clientId) {
5494
- recheckBtn = tag(
5495
- "button",
5496
- {
5497
- onclick: "wdRecheckLogin('" + esc(c.clientId) + "')",
5498
- style:
5499
- "padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
5500
- "border:1px solid #444;border-radius:3px;cursor:pointer",
5501
- title: "立即重检上次未通过的端口(全部已登录则跳过;结果随心跳自动刷新)",
5502
- },
5503
- "重检",
5504
- );
5505
- }
5506
-
5507
5572
  var head =
5508
5573
  styled("span", "font-size:14px", "🔑") +
5509
- styled("span", "color:#888", "登录检测:") +
5510
- cfgBtn +
5511
- recheckBtn;
5574
+ styled("span", "color:#888", "导入端口:") +
5575
+ cfgBtn;
5512
5576
 
5513
5577
  var wrapStyle =
5514
5578
  "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
@@ -5691,24 +5755,6 @@ async function wdSetLoginPorts() {
5691
5755
  }
5692
5756
  }
5693
5757
 
5694
- // 手动下发登录重检:服务端一次性队列随心跳通知客户端,下一跳立即检测
5695
- async function wdRecheckLogin(clientId) {
5696
- try {
5697
- var res = await fetch("/api/watchdog/recheck-login", {
5698
- method: "POST",
5699
- headers: { "Content-Type": "application/json" },
5700
- body: JSON.stringify({ clientId: clientId }),
5701
- });
5702
- var d = await res.json();
5703
- if (d.ok) {
5704
- alert("已下发重检,结果随心跳自动刷新");
5705
- } else {
5706
- alert("失败: " + (d.error || "未知错误"));
5707
- }
5708
- } catch (e) {
5709
- alert("下发失败: " + e.message);
5710
- }
5711
- }
5712
5758
 
5713
5759
  // ── 错误自动切节点配置区域(看门狗面板中每个客户端卡片) ──
5714
5760
 
@@ -668,10 +668,18 @@
668
668
  </div>
669
669
  <div id="wdSubsList" style="color:#666;font-size:12px">加载中...</div>
670
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>
671
680
  <div id="wdClientsList" style="color:#666;font-size:12px">加载中...</div>
672
681
  </div>
673
682
 
674
- <!-- 订阅中心 新增/编辑弹窗 -->
675
683
  <div id="wdSubModal"
676
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">
677
685
  <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
@@ -692,6 +700,12 @@
692
700
  <input id="wdSubNote" placeholder="如:每月1号重置流量"
693
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">
694
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>
695
709
  <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
696
710
  <button onclick="wdSubCloseModal()"
697
711
  style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
@@ -974,9 +988,9 @@
974
988
  <div id="wdLoginPortsModal"
975
989
  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">
976
990
  <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:480px">
977
- <div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑 登录检测端口 — <span id="wdLoginPortsUserId"></span>
991
+ <div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑 导入端口 — <span id="wdLoginPortsUserId"></span>
978
992
  </div>
979
- <div style="color:#888;font-size:12px;margin-bottom:12px">配置该客户端要定期检测登录状态的 CDP 端口(逗号分隔,如 <b
993
+ <div style="color:#888;font-size:12px;margin-bottom:12px">配置该客户端启动时导入登录态的 CDP 端口(逗号分隔,如 <b
980
994
  style="color:#4ade80">9222,9223,9225</b>)。<br>客户端每隔一段时间自动检测并上报结果,留空保存可清空配置。</div>
981
995
  <input id="wdLoginPortsInput" placeholder="9222,9223,9225"
982
996
  style="width:100%;padding:8px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:6px;font-size:13px;box-sizing:border-box">
@@ -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 (
@@ -14,13 +14,18 @@
14
14
  * 3 粘性 槽位原位保留——订阅还活着就不挪窝(端口 = 槽位序);
15
15
  * 不在池子的存量 URL 状态未知,同样视为可用(迁移无感)
16
16
  * 4 节点内摊开 同一机器槽位尽量用不同订阅;count > 可用去重数才回绕
17
- * 5 跨机器错开 补槽时优先选全集群占用数最少的可用订阅
18
- * (占用数 = 该订阅在所有机器 alloc 里出现的次数)
17
+ * 5 跨机器错开 补槽时优先选全集群「加权占用」最小的可用订阅
18
+ * (占用数 = 该订阅在所有机器 alloc 里出现的次数;
19
+ * 加权占用 = 占用数 / weight,weight 缺省 1 → 退化为大家 1:1:1:1 摊开,与无权重时代数完全一致)
19
20
  * 6 重复合法 回绕重复按池子顺序取
20
21
  * 7 空保护 可用集为空 → alloc 原地不动 + 标黄,绝不下发空列表
21
22
  * 8 决定性 纯函数 + 池子顺序决胜;心跳重跑输入不变则结果恒等,天然粘性
22
23
  *
23
- * 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数升序 → 池子顺序。
24
+ * 权重语义: weight 只影响跨机器份额,不破坏粘性——存量槽照旧占位计数,
25
+ * 份额超了后续新槽自然不再选它,平滑收敛到 weight 比例;
26
+ * 节点内摊开(规则4)不受权重影响,机器内部仍优先用不同订阅。
27
+ *
28
+ * 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数/weight 升序 → 池子顺序。
24
29
  * 全局占用数每次现算(遍历所有节点 alloc,几十个节点毫秒级);分配时机 = 心跳惰性计算,
25
30
  * 结果与存量不同才写回配置(下一行 sendJSON 读到的即是新 alloc,同一个心跳就下发)。
26
31
  *
@@ -86,11 +91,16 @@ function scoreLess(a, b) {
86
91
  * @returns {{ alloc: Array<{name,url}>, warn: string|null }}
87
92
  * warn: "pool-empty" 可用集为空且存在补不上的槽(规则 #7)
88
93
  */
89
- export function allocateSubscriptions({ available, count, prevAlloc, usage, isAvailUrl }) {
94
+ export function allocateSubscriptions({ available, count, prevAlloc, usage, weights, isAvailUrl }) {
90
95
  const prev = Array.isArray(prevAlloc) ? prevAlloc.map(normEntry) : [];
91
96
  const n = Math.max(1, Math.min(ALLOC_MAX_INSTANCES, parseInt(count, 10) || 1));
92
97
  const avail = Array.isArray(available) ? available : [];
93
98
  const use = usage && typeof usage === "object" ? usage : {};
99
+ const wt = weights && typeof weights === "object" ? weights : {};
100
+ const weightOf = (url) => {
101
+ const w = parseInt(wt[url], 10);
102
+ return Number.isFinite(w) && w > 0 ? w : 1; // 非法/缺省一律按 1,行为与无权重版一致
103
+ };
94
104
 
95
105
  const nodeUsed = new Map(); // 本节点新 alloc 内的占用(含粘性保留槽,跨节点占用走 usage)
96
106
  const globalUsed = (url) => (use[url] || 0) + (nodeUsed.get(url) || 0);
@@ -120,7 +130,7 @@ export function allocateSubscriptions({ available, count, prevAlloc, usage, isAv
120
130
  for (let k = 0; k < avail.length; k++) {
121
131
  const s = avail[k];
122
132
  const inNode = (nodeUsed.get(s.url) || 0) > 0;
123
- const score = [inNode ? 1 : 0, globalUsed(s.url), k];
133
+ const score = [inNode ? 1 : 0, globalUsed(s.url) / weightOf(s.url), k];
124
134
  if (!bestScore || scoreLess(score, bestScore)) {
125
135
  best = s;
126
136
  bestScore = score;
@@ -199,6 +209,8 @@ export function reconcileAutoAlloc(userId) {
199
209
  const views = getSubscriptionView();
200
210
  const viewByUrl = new Map(views.map((v) => [v.url, v]));
201
211
  const available = views.filter(isSubAvailable);
212
+ const weights = {};
213
+ for (const v of views) weights[v.url] = v.weight;
202
214
 
203
215
  // 全局占用数现算:其它节点 alloc 里该 URL 出现的次数(本节点走分配内计数)
204
216
  const usage = {};
@@ -220,6 +232,7 @@ export function reconcileAutoAlloc(userId) {
220
232
  count,
221
233
  prevAlloc,
222
234
  usage,
235
+ weights,
223
236
  isAvailUrl: (u) => isAllocUrlAvailable(u, viewByUrl),
224
237
  });
225
238