tt-help-cli-ycl 1.4.176 → 1.4.178

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/src/main.js CHANGED
@@ -27,6 +27,7 @@ import { handleSearch } from "./cli/search.js";
27
27
  import { handleWatchdog } from "./cli/watchdog.js";
28
28
  import { handleClashDaemon } from "./cli/clash-daemon.js";
29
29
  import { handleLoginCheck } from "./cli/login-check.js";
30
+ import { handleLoginPool } from "./cli/login-pool.js";
30
31
 
31
32
  async function main() {
32
33
  const parsed = parseArgs();
@@ -80,6 +81,8 @@ async function main() {
80
81
  return handleClashDaemon(parsed);
81
82
  case "login-check":
82
83
  return handleLoginCheck(parsed);
84
+ case "login-pool":
85
+ return handleLoginPool(parsed);
83
86
  }
84
87
 
85
88
  const {
package/src/npm-main.js CHANGED
@@ -23,6 +23,7 @@ import { handleRawSync } from "./cli/raw-sync.js";
23
23
  import { handleSearch } from "./cli/search.js";
24
24
  import { handleWatchdog } from "./cli/watchdog.js";
25
25
  import { handleLoginCheck } from "./cli/login-check.js";
26
+ import { handleLoginPool } from "./cli/login-pool.js";
26
27
 
27
28
  function exitUnsupportedCommand(command) {
28
29
  console.error(
@@ -79,6 +80,8 @@ async function main() {
79
80
  return handleWatchdog(parsed);
80
81
  case "login-check":
81
82
  return handleLoginCheck(parsed);
83
+ case "login-pool":
84
+ return handleLoginPool(parsed);
82
85
  }
83
86
 
84
87
  const {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 登录态批量巡检(账号中心)— 服务端无浏览器纯 HTTP 探测
3
+ *
4
+ * 原理(POC 实证 2026-10-28,见知识库 worklog):
5
+ * undici + Cookie 头 + 真实 UA + 代理出口,直探 passport/web/account/info/。
6
+ * 浏览器里必须页面上下文 fetch(CORS),服务端无此限制。阳性对照(真实 cookies)
7
+ * 判活 / 阴性对照(垃圾 sessionid)判死均已验证,单次 ~700ms。
8
+ *
9
+ * 判据边界:探测只回答「登录态还活着吗」,不回答「号还能不能干活」——
10
+ * 风控限流/影子封禁只有真实业务出活能暴露。
11
+ */
12
+ import { request, ProxyAgent } from "undici";
13
+ import { healthTargets, recordHealth } from "./login-pool.js";
14
+
15
+ const ACCOUNT_INFO_URL = "https://www.tiktok.com/passport/web/account/info/";
16
+ const UA =
17
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36 Edg/155.0.0.0";
18
+ const DEFAULT_TIMEOUT_MS = 20 * 1000;
19
+ const DEFAULT_PACING_MS = 250;
20
+
21
+ /** proxyHint 归一化:纯数字 → http://127.0.0.1:<port>;http(s):// 开头 → 原样;其余忽略 */
22
+ function normalizeProxy(hint) {
23
+ const s = String(hint || "").trim();
24
+ if (!s) return null;
25
+ if (/^\d{2,5}$/.test(s)) return `http://127.0.0.1:${s}`;
26
+ if (/^https?:\/\//.test(s)) return s;
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * 无浏览器探测单个账号登录态
32
+ * @param {Array} cookies 池内 cookies 数组 [{name,value,domain?...}]
33
+ * @param {object} opts { proxy?: string|null, timeoutMs?: number }
34
+ * @returns {Promise<{alive: boolean, username: ?string, expired: boolean, httpStatus: number, error: ?string, ms: number}>}
35
+ */
36
+ export async function probeLogin(cookies, { proxy = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
37
+ const cookieHeader = (cookies || [])
38
+ .filter((c) => c && c.name && c.value != null)
39
+ .filter((c) => !c.domain || String(c.domain).includes("tiktok"))
40
+ .map((c) => `${c.name}=${c.value}`)
41
+ .join("; ");
42
+ const t0 = Date.now();
43
+ try {
44
+ const res = await request(ACCOUNT_INFO_URL, {
45
+ method: "GET",
46
+ headers: {
47
+ cookie: cookieHeader,
48
+ "user-agent": UA,
49
+ accept: "application/json",
50
+ "accept-language": "en-US,en;q=0.9",
51
+ },
52
+ ...(proxy ? { dispatcher: new ProxyAgent(proxy) } : {}),
53
+ headersTimeout: timeoutMs,
54
+ bodyTimeout: timeoutMs,
55
+ });
56
+ const body = await res.body.json().catch(() => null);
57
+ const ms = Date.now() - t0;
58
+ const d = (body && body.data) || null;
59
+ return {
60
+ httpStatus: res.status,
61
+ alive: !!d && !!(d.username || d.user_id || d.sec_user_id),
62
+ username: (d && d.username) || null,
63
+ expired: (d && d.name === "session_expired") || (body && body.error_code === 13),
64
+ error: body && !d ? JSON.stringify(body).slice(0, 120) : null,
65
+ ms,
66
+ };
67
+ } catch (e) {
68
+ return { httpStatus: 0, alive: false, username: null, expired: false, error: String(e).slice(0, 120), ms: Date.now() - t0 };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 批量巡检:默认探 pool+suspect(leased 由消费方验证兜底、retired 不探测),指定 ids 精确取。
74
+ * 结果逐号 recordHealth 落账(dead 且在池 → suspect);串行 + pacing,对 TikTok 友好。
75
+ * @param {object} opts { ids?: string[], proxy?: string|null(缺省用各号 proxyHint), pacingMs?, probeFn?(测试注入) }
76
+ * @returns {Promise<{checked: number, alive: number, dead: number, error: number, migrated: number, results: Array}>}
77
+ */
78
+ export async function checkPoolAccounts(opts = {}) {
79
+ const { ids, proxy = null, pacingMs = DEFAULT_PACING_MS, probeFn = probeLogin } = opts;
80
+ const targets = healthTargets({ ids });
81
+ const summary = { checked: 0, alive: 0, dead: 0, error: 0, migrated: 0, results: [] };
82
+ for (let i = 0; i < targets.length; i++) {
83
+ const t = targets[i];
84
+ const exit = proxy || normalizeProxy(t.proxyHint);
85
+ const r = await probeFn(t.cookies, { proxy: exit });
86
+ const ok = r.error ? "error" : r.alive ? "alive" : "dead";
87
+ let rec = { username: t.username, health: ok, status: t.status, ms: r.ms };
88
+ try {
89
+ const applied = recordHealth({
90
+ id: t.id,
91
+ ok,
92
+ detail: r.error ? `探测异常 ${r.error}` : r.alive ? "" : r.expired ? "会话过期" : "未登录",
93
+ });
94
+ rec.status = applied.status;
95
+ if (applied.migrated) rec.migrated = true;
96
+ } catch (e) {
97
+ rec.error = e.message;
98
+ }
99
+ summary[ok]++;
100
+ summary.checked++;
101
+ if (rec.migrated) summary.migrated++;
102
+ summary.results.push(rec);
103
+ if (i < targets.length - 1 && pacingMs > 0) {
104
+ await new Promise((res) => setTimeout(res, pacingMs));
105
+ }
106
+ }
107
+ return summary;
108
+ }
@@ -229,6 +229,7 @@ function publicView(acc) {
229
229
  lastImportFrom: acc.lastImportFrom || null,
230
230
  // 最近使用镜像(脱离 lease 独立存在,释放/回收后仍可见)
231
231
  lastHealthAt: acc.lastHealthAt || null,
232
+ lastHealth: acc.lastHealth || null,
232
233
  lastBusinessOkAt: acc.lastBusinessOkAt || null,
233
234
  lastUsedAt: acc.lastUsedAt || null,
234
235
  lease,
@@ -723,7 +724,10 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
723
724
 
724
725
  const dup = _accounts.find((a) => a.username === username && a.status !== "retired");
725
726
  if (dup) {
726
- // 幂等:不覆盖 cookies、不动租约
727
+ // 幂等:不覆盖 cookies、不动租约;仅补写来源元数据,
728
+ // 否则已在池的号永远不带 lastImportFrom,节点热路径匹配不上会每次启动全量重导
729
+ dup.lastImportFrom = importMeta(userId, portNum, t);
730
+ scheduleSave();
727
731
  return { account: publicView(dup), dedup: true };
728
732
  }
729
733
  if (_accounts.length >= POOL_MAX) throw new Error(`池已满(上限 ${POOL_MAX})`);
@@ -737,7 +741,7 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
737
741
  status: "pool",
738
742
  lease: null,
739
743
  useCount: 0, // 导入是登记不是消费;次数只在 acquire/assign 时 +1
740
- lastImportFrom: { userId, port: portNum, at: t }, // 来源元数据(供节点热路径跳过 + 账号中心展示),非租约
744
+ lastImportFrom: importMeta(userId, portNum, t),
741
745
  history: [],
742
746
  createdAt: t,
743
747
  updatedAt: t,
@@ -748,6 +752,111 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
748
752
  return { account: fullView(acc), dedup: false };
749
753
  }
750
754
 
755
+ /** 来源元数据(供节点热路径跳过 + 账号中心展示),非租约;新入池与幂等补写共用此定义 */
756
+ const importMeta = (userId, portNum, at) => ({ userId, port: portNum, at });
757
+
758
+ /**
759
+ * 重登录:按 username 覆盖刷新 cookies(区别于 import 的「已在池即幂等跳过」)。
760
+ * 守卫:目标必须已在池(新号走 import/add);使用中(leased)拒绝——先等节点换号/手动释放;
761
+ * retired 重登录=复活回 pool;suspect 刷新后回 pool;空闲则原地刷新。
762
+ * username 必须来自 passport 活体验证(调用方已验,且须与目标一致——身份不符由调用方拦截),
763
+ * cookies 必须非空。
764
+ */
765
+ export function reloginAccount({ username, cookies, userId, note } = {}) {
766
+ ensureLoaded();
767
+ reapExpiredLeases();
768
+ if (!username) throw new Error("username 必填(重登录目标)");
769
+ if (!userId) throw new Error("userId 必填(操作来源)");
770
+ const norm = normalizeCookies(cookies);
771
+ if (!norm) throw new Error("cookies 必须是非空数组,且每项含 name/value");
772
+ const acc = _accounts.find((a) => a.username === username);
773
+ if (!acc) throw new Error(`账号 ${username} 不在池中(重登录仅针对已有账号,新号走 import/add)`);
774
+ if (acc.status === "leased") {
775
+ const w = acc.lease;
776
+ throw new Error(
777
+ `账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
778
+ );
779
+ }
780
+ const revived = acc.status === "retired";
781
+ const wasSuspect = acc.status === "suspect";
782
+ acc.cookies = norm;
783
+ acc.status = "pool";
784
+ acc.updatedAt = now();
785
+ pushHistory(
786
+ acc,
787
+ "relogin",
788
+ `${revived ? "重登录复活(已停用→池)" : wasSuspect ? "重登录(疑似→池)" : "重登录刷新"} · ${userId}${note ? ` · ${String(note).slice(0, 80)}` : ""}`,
789
+ );
790
+ scheduleSave();
791
+ return { account: publicView(acc), revived, wasSuspect };
792
+ }
793
+
794
+ /**
795
+ * 巡检目标:默认取 pool+suspect(leased 由消费方验证兜底、retired 不探测);
796
+ * 指定 ids 时按 id 精确取(仍排除 retired)。返回含明文 cookies,仅限服务端进程内使用。
797
+ */
798
+ export function healthTargets({ ids } = {}) {
799
+ ensureLoaded();
800
+ reapExpiredLeases();
801
+ const idSet = Array.isArray(ids) && ids.length ? new Set(ids) : null;
802
+ return _accounts
803
+ .filter((a) => a.status !== "retired")
804
+ .filter((a) => (idSet ? idSet.has(a.id) : a.status === "pool" || a.status === "suspect"))
805
+ .map((a) => ({
806
+ id: a.id,
807
+ username: a.username,
808
+ status: a.status,
809
+ proxyHint: a.proxyHint || "",
810
+ cookies: a.cookies,
811
+ }));
812
+ }
813
+
814
+ /**
815
+ * 巡检结果落账:lastHealthAt + lastHealth('alive'|'dead'|'error')。
816
+ * dead 且在池 → 迁移 suspect(业务不再自动取到死号);alive 不写 history(避免刷屏,
817
+ * HISTORY_MAX 只有 20 条);dead/error 才记 history。leased/retired 仅记录不迁移状态。
818
+ */
819
+ export function recordHealth({ id, ok, detail } = {}) {
820
+ ensureLoaded();
821
+ const acc = _accounts.find((x) => x.id === id);
822
+ if (!acc) throw new Error(`账号不存在: ${id}`);
823
+ acc.lastHealthAt = now();
824
+ acc.lastHealth = ok; // 'alive' | 'dead' | 'error'
825
+ let migrated = false;
826
+ if (ok === "dead" && acc.status === "pool") {
827
+ acc.status = "suspect";
828
+ migrated = true;
829
+ }
830
+ if (ok !== "alive" || migrated) {
831
+ pushHistory(
832
+ acc,
833
+ "health",
834
+ `${ok}${detail ? ` · ${String(detail).slice(0, 80)}` : ""}${migrated ? " · pool→suspect" : ""}`,
835
+ );
836
+ }
837
+ scheduleSave();
838
+ return { username: acc.username, status: acc.status, migrated };
839
+ }
840
+
841
+ /**
842
+ * 重登录预备:返回目标账号明文 cookies(relogin 命令注入旧登录态用)。
843
+ * 守卫与 reloginAccount 一致:必须在池、使用中拒绝。
844
+ */
845
+ export function prepareRelogin({ username } = {}) {
846
+ ensureLoaded();
847
+ reapExpiredLeases();
848
+ if (!username) throw new Error("username 必填");
849
+ const acc = _accounts.find((a) => a.username === username);
850
+ if (!acc) throw new Error(`账号 ${username} 不在池中`);
851
+ if (acc.status === "leased") {
852
+ const w = acc.lease;
853
+ throw new Error(
854
+ `账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
855
+ );
856
+ }
857
+ return { account: fullView(acc) };
858
+ }
859
+
751
860
  /** 内部:注入账号(测试/迁移工具用,跳过命令队列直接改状态) */
752
861
  export function _accountsInternal() {
753
862
  ensureLoaded();
@@ -6044,7 +6044,14 @@ function renderProxySection(c, userId) {
6044
6044
  if (wdSubsConfigured(userId).length > 0) {
6045
6045
  return renderSubsOverview(c, userId, [], []);
6046
6046
  }
6047
- return renderProxyLegacy(c, userId);
6047
+ var legacy = renderProxyLegacy(c, userId);
6048
+ if (legacy) return legacy;
6049
+ // 新节点引导(2026-10-28):无通道 + 未配置订阅 + daemon 未跑过 clash 时,原逻辑返回 "",
6050
+ // 后台看不到任何订阅配置入口——而 proxySettings[userId] 条目只有配置动作才会创建
6051
+ // (注册/心跳从不建,订阅分配器对无条目直接 return null),构成死环(F-11/IB-1 都踩过)。
6052
+ // 渲染最小引导行:配置弹窗对空条目天然兼容(手动列表 / 切自动分配均可用),
6053
+ // 建条目后客户端下个心跳拿到订阅并自动拉起 mihomo,本区块随之变为正常视图。
6054
+ return renderSubsBootstrap(userId);
6048
6055
  }
6049
6056
 
6050
6057
  /** 兼容旧版:仅主控制端(legacy clashHealth) */
@@ -6061,6 +6068,27 @@ function renderProxyLegacy(c, userId) {
6061
6068
  );
6062
6069
  }
6063
6070
 
6071
+ /** 新节点引导行:订阅完全未配置且 daemon 无 clash 数据时,仍保留配置入口(打破 UI 死环) */
6072
+ function renderSubsBootstrap(userId) {
6073
+ return styled(
6074
+ "div",
6075
+ "margin-top:6px;padding:6px 10px;background:#111;border-radius:6px;display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
6076
+ styled("span", "color:#888", "🛜 订阅配置:") +
6077
+ styled("span", "color:#6b7280", "未配置(保存后下个心跳自动下发并拉起 mihomo)") +
6078
+ tag(
6079
+ "button",
6080
+ {
6081
+ onclick: "wdShowProxyModal('" + esc(userId) + "')",
6082
+ style:
6083
+ "padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
6084
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
6085
+ title: "配置该客户端的代理订阅地址(保存后自动热应用,无需重启)",
6086
+ },
6087
+ "配置 ⚙️",
6088
+ ),
6089
+ );
6090
+ }
6091
+
6064
6092
  /** 外部代理通道(Verge 等非内置实例):节点行 + 节点列表 + 自身订阅摘要 */
6065
6093
  function renderExternalChannel(ch, userId, withConfig) {
6066
6094
  if (!ch || !ch.node) return "";
@@ -20,7 +20,10 @@ import {
20
20
  takeAccount,
21
21
  acquireAccount,
22
22
  importAccount,
23
+ reloginAccount,
24
+ prepareRelogin,
23
25
  } from "./login-pool.js";
26
+ import { checkPoolAccounts } from "./login-health.js";
24
27
 
25
28
  const __filename = fileURLToPath(import.meta.url);
26
29
 
@@ -643,37 +646,52 @@ function serveLoginPoolPage(res) {
643
646
  <meta name="viewport" content="width=device-width, initial-scale=1">
644
647
  <title>账号中心 · 登录池</title>
645
648
  <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; }
649
+ body { font-family: system-ui, sans-serif; margin: 0; background: #f3f4f6; color: #1f2329; }
650
+ .wrap { max-width: 1280px; margin: 0 auto; padding: 22px 24px 48px; }
651
+ h1 { font-size: 20px; margin: 0 0 2px; }
652
+ .sub { color: #9ca3af; font-size: 12px; margin-bottom: 14px; }
653
+ .bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
654
+ button { padding: 6px 14px; border: 1px solid #d0d3d9; border-radius: 8px; background: #fff; cursor: pointer; font-size: 13px; transition: all .15s; }
655
+ button:hover { background: #f0f2f5; border-color: #b8bdc7; }
656
+ button:disabled { color: #9ca3af; cursor: not-allowed; background: #f5f5f5; border-color: #e5e7eb; }
657
+ button.primary { background: #2563eb; border-color: #2563eb; color: #fff; font-weight: 600; }
658
+ button.primary:hover:not(:disabled) { background: #1d4ed8; }
659
+ .chips { display: inline-flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
660
+ .chip { background: #fff; border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 12px; font-size: 12px; color: #6b7280; }
661
+ .chip b { color: #1f2329; }
662
+ .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: auto; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
663
+ table { border-collapse: collapse; width: 100%; }
664
+ th, td { padding: 9px 12px; font-size: 13px; text-align: left; vertical-align: top; border-bottom: 1px solid #f0f1f3; }
665
+ thead th { background: #f9fafb; color: #6b7280; font-weight: 600; white-space: nowrap; border-bottom: 1px solid #e5e7eb; }
666
+ tbody tr:hover { background: #f8faff; }
667
+ tbody tr:last-child td { border-bottom: none; }
668
+ .badge { display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
656
669
  .b-leased { background: #dbeafe; color: #2563eb; }
657
670
  .b-pool { background: #dcfce7; color: #16a34a; }
658
671
  .b-suspect { background: #fef3c7; color: #d97706; }
659
672
  .b-retired { background: #e5e7eb; color: #6b7280; }
660
673
  .muted { color: #9ca3af; font-size: 12px; }
661
674
  .warn { color: #dc2626; }
662
- #msg { margin: 0 8px; }
675
+ #msg { font-size: 13px; color: #16a34a; }
676
+ .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid #cbd5e1; border-top-color: #2563eb; border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; margin-right: 6px; }
677
+ @keyframes spin { to { transform: rotate(360deg); } }
663
678
  details { margin-top: 2px; }
664
679
  summary { cursor: pointer; }
680
+ td button { padding: 3px 10px; font-size: 12px; border-radius: 6px; margin: 1px 0; }
665
681
  </style>
666
682
  </head>
667
683
  <body>
684
+ <div class="wrap">
668
685
  <h1>账号中心 · 登录池</h1>
686
+ <div class="sub">登录态统一存放与租借:业务取号注入 / 节点启动导入 / 巡检与重登录</div>
669
687
  <div class="bar">
670
688
  <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>
689
+ <button onclick="addCmdDlg()" title="生成一条命令,在任意装有 tt-help 的机器执行:弹出 Edge 人工登录 TikTok 后自动入池">新增账号</button>
690
+ <button id="btn-check" class="primary" onclick="checkNow()" title="逐号 HTTP 探测登录态(空闲+疑似),死号自动标疑似">批量检测</button>
674
691
  <span id="msg"></span>
692
+ <span class="chips" id="stats"></span>
675
693
  </div>
676
- <div id="content">加载中…</div>
694
+ <div id="content" class="card">加载中…</div>
677
695
  <script>
678
696
  const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\\'':'&#39;'}[c]));
679
697
  const fmt = t => t ? new Date(t).toLocaleString('zh-CN', {hour12:false}) : '—';
@@ -715,6 +733,10 @@ async function load() {
715
733
  const who = l
716
734
  ? esc(l.userId) + (l.port ? ':' + esc(l.port) : '') + (l.appName ? ' <span class="muted">· ' + esc(l.appName) + '</span>' : '')
717
735
  : '<span class="muted">—</span>';
736
+ const health = a.lastHealth === 'alive' ? '<span style="color:#16a34a">✓</span>'
737
+ : a.lastHealth === 'dead' ? '<span class="warn">✗</span>'
738
+ : a.lastHealth === 'error' ? '<span class="muted">?</span>'
739
+ : '<span class="muted">—</span>';
718
740
  const meta = [];
719
741
  if (a.note) meta.push(esc(a.note));
720
742
  if (a.proxyHint) meta.push('出口 ' + esc(a.proxyHint));
@@ -728,6 +750,7 @@ async function load() {
728
750
  '<td title="最后使用: ' + fmt(a.lastUsedAt) + '\\n最后出活: ' + biz + '">' + rel(a.lastUsedAt) + '</td>' +
729
751
  '<td>' + (a.useCount || 0) + '</td>' +
730
752
  '<td' + (expired ? ' class="warn"' : '') + ' title="sessionid 到期: ' + fmt(a.sessionExpiresAt) + '(cookie 承诺寿命,非实时有效性)">' + day(a.sessionExpiresAt) + (expired ? '(已过)' : '') + '</td>' +
753
+ '<td title="上次检测: ' + fmt(a.lastHealthAt) + (a.lastHealth === 'dead' ? '\\n死号已标疑似,可重登录复活' : a.lastHealth === 'alive' ? '\\n登录态存活' : '') + '">' + health + ' <span class="muted">' + rel(a.lastHealthAt) + '</span></td>' +
731
754
  '<td>' +
732
755
  (a.status === 'leased' ? '<button onclick="releaseDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')">释放</button> ' : '') +
733
756
  (a.status === 'pool' ? '<button onclick="assignDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')" title="手动指派给节点端口(watchdog 注入流)">指派</button> ' : '') +
@@ -735,7 +758,7 @@ async function load() {
735
758
  (a.status === 'retired' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',false)">启用</button> ' : '') +
736
759
  (a.status !== 'retired' ? '<button onclick="disable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',' + (a.status === 'leased') + ')">停用</button> ' : '') +
737
760
  (a.status === 'retired' ? '<button onclick="del(\\'' + a.id + '\\')">删除</button> ' : '') +
738
- '<button disabled title="开发中:引导重新登录并更新池内登录态">重登录</button>' +
761
+ '<button onclick="reloginDlg(\\'' + esc(a.username) + '\\')" title="生成重登录命令:本机执行后旧登录态自动复活(或人工登录),验证身份后覆盖刷新池内 cookies">重登录</button>' +
739
762
  '</td>' +
740
763
  '<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
764
  (a.history || []).slice(0, 8).map(h => '<div class="muted">' + fmt(h.at) + ' ' + esc(h.event) + ' ' + esc(h.detail) + '</div>').join('') + '</details></td>' +
@@ -743,27 +766,70 @@ async function load() {
743
766
  }).join('');
744
767
  const nLeased = j.accounts.filter(a => a.status === 'leased').length;
745
768
  const nFree = j.accounts.filter(a => a.status === 'pool').length;
769
+ const nSus = j.accounts.filter(a => a.status === 'suspect').length;
770
+ const nRet = j.accounts.filter(a => a.status === 'retired').length;
771
+ document.getElementById('stats').innerHTML =
772
+ '<span class="chip">共 <b>' + j.total + '</b></span>' +
773
+ '<span class="chip">使用中 <b>' + nLeased + '</b></span>' +
774
+ '<span class="chip">空闲 <b>' + nFree + '</b></span>' +
775
+ '<span class="chip">疑似 <b>' + nSus + '</b></span>' +
776
+ '<span class="chip">已停用 <b>' + nRet + '</b></span>';
746
777
  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>';
778
+ '<table><tr><th>#</th><th>账号</th><th>状态</th><th>使用者</th><th>最近使用</th><th>次数</th><th>有效期</th><th>检测</th><th>操作</th><th>历史</th></tr>' + rows + '</table>' +
779
+ '<div style="padding: 10px 14px;"><p class="muted" style="margin:0">排序:使用中 → 空闲 → 疑似 → 已停用 · 每 15 秒自动刷新 · 有效期 = sessionid cookie 承诺寿命 · 检测 = 登录态探测(真实判据 = 业务出活)· cookies 明文不出服务端</p></div>';
749
780
  } catch (e) { msg('加载失败: ' + e.message, true); }
750
781
  }
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); }
782
+ function copyCmd(cmd) {
783
+ if (navigator.clipboard) navigator.clipboard.writeText(cmd).catch(() => {});
784
+ prompt('复制命令(在装有 tt-help 的机器上执行):', cmd);
761
785
  }
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); }
786
+ function addCmdDlg() {
787
+ copyCmd('tt-help login-pool add --server ' + location.origin);
788
+ msg('命令已生成:执行成功后点刷新查看新号');
789
+ }
790
+ function reloginDlg(name) {
791
+ copyCmd('tt-help login-pool relogin ' + name + ' --server ' + location.origin);
792
+ msg('重登录命令已生成(' + name + '):执行成功后点刷新');
793
+ }
794
+ function setProgress(t) {
795
+ const el = document.getElementById('msg');
796
+ el.innerHTML = '<span class="spin"></span>' + esc(t);
797
+ el.style.color = '#2563eb';
798
+ }
799
+ let checking = false;
800
+ async function checkNow() {
801
+ if (checking) return;
802
+ let targets;
803
+ try {
804
+ const j = await api('/api/login-pool');
805
+ targets = j.accounts.filter(a => a.status === 'pool' || a.status === 'suspect');
806
+ } catch (e) { msg('加载失败: ' + e.message, true); return; }
807
+ if (!targets.length) { msg('没有待检测账号(空闲+疑似为空,使用中的由业务自验)'); return; }
808
+ if (!confirm('批量检测 ' + targets.length + ' 个账号的登录态?(死号自动标疑似)')) return;
809
+ checking = true;
810
+ const btn = document.getElementById('btn-check');
811
+ btn.disabled = true; btn.textContent = '检测中…';
812
+ let done = 0, alive = 0, dead = 0, err = 0, mig = 0;
813
+ try {
814
+ for (const a of targets) {
815
+ setProgress('巡检中 ' + (done + 1) + '/' + targets.length + ' · 正在检测 @' + a.username + ' …');
816
+ let health = 'error';
817
+ try {
818
+ const r = await api('/api/login-pool/check', { ids: [a.id] });
819
+ const x = r.results && r.results[0];
820
+ if (x) { health = x.health; if (x.migrated) mig++; }
821
+ } catch (e) { health = 'error'; }
822
+ if (health === 'alive') alive++; else if (health === 'dead') dead++; else err++;
823
+ done++;
824
+ setProgress('巡检中 ' + done + '/' + targets.length + ' · 活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err);
825
+ load();
826
+ if (done < targets.length) await new Promise(r => setTimeout(r, 300));
827
+ }
828
+ msg('巡检完成: 探测 ' + done + ',活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err + (mig ? ',死号标疑似 ' + mig + ' 个' : ''));
829
+ } finally {
830
+ checking = false;
831
+ btn.disabled = false; btn.textContent = '批量检测';
832
+ }
767
833
  }
768
834
  async function assignDlg(id, name) {
769
835
  const userId = prompt('指派给节点 userId(如 F-11):'); if (!userId) return;
@@ -795,8 +861,9 @@ async function del(id) {
795
861
  catch (e) { msg('失败: ' + e.message, true); }
796
862
  }
797
863
  load();
798
- setInterval(() => { if (document.getElementById('auto').checked) load(); }, 15000);
864
+ setInterval(() => load(), 15000);
799
865
  </script>
866
+ </div>
800
867
  </body>
801
868
  </html>`;
802
869
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -1616,6 +1683,28 @@ export function startWatchServer(
1616
1683
  );
1617
1684
  sendJSON(res, 200, { ok: true, dedup: imp.dedup, account: ret });
1618
1685
  return;
1686
+ } else if (lpAction === "relogin") {
1687
+ // 重登录覆盖刷新(以号为主键):目标必须已在池;使用中拒绝;retired 复活、suspect 回池
1688
+ const rel = reloginAccount(body);
1689
+ ret = rel.account;
1690
+ console.error(
1691
+ `[登录池 ${ts}] RELOGIN: ${ret.username} ← ${body.userId}${rel.revived ? "(复活)" : rel.wasSuspect ? "(疑似→池)" : ""} (${ret.cookieCount} 条 cookies)`,
1692
+ );
1693
+ sendJSON(res, 200, { ok: true, revived: rel.revived, wasSuspect: rel.wasSuspect, account: ret });
1694
+ return;
1695
+ } else if (lpAction === "prepare") {
1696
+ // 重登录预备:返回目标账号明文 cookies(relogin 命令注入旧登录态用;与 acquire 同级信任)
1697
+ const prep = prepareRelogin(body);
1698
+ sendJSON(res, 200, { ok: true, account: prep.account });
1699
+ return;
1700
+ } else if (lpAction === "check") {
1701
+ // 巡检:无浏览器纯 HTTP 探测登录态(默认 pool+suspect,指定 ids 精确取)
1702
+ const sum = await checkPoolAccounts({ ids: body.ids, proxy: body.proxy });
1703
+ console.error(
1704
+ `[登录池 ${ts}] CHECK: 探测 ${sum.checked},alive ${sum.alive} / dead ${sum.dead} / error ${sum.error},migrated ${sum.migrated}`,
1705
+ );
1706
+ sendJSON(res, 200, { ok: true, ...sum });
1707
+ return;
1619
1708
  } else if (lpAction === "assign") {
1620
1709
  ret = assignAccount(body);
1621
1710
  console.error(`[登录池 ${ts}] ASSIGN: ${ret.username} → ${body.userId}:${body.port}`);