tt-help-cli-ycl 1.4.177 → 1.4.179

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.
@@ -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 后自动入池(本机需已安装 tt-help 并执行过 tt-help protocol register;未注册时自动回退为复制命令)">新增账号</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="一键拉起本机 tt-help 重登录:旧登录态自动复活(或人工登录),验证身份后覆盖刷新池内 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,90 @@ 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 明文不出服务端 · 「新增账号/重登录」一键拉起需本机装 tt-help 并执行过 <b>tt-help protocol register</b></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 protoHref(action, params) {
787
+ const q = [];
788
+ for (const k in params) q.push(k + '=' + encodeURIComponent(params[k]));
789
+ return 'tt-help://' + action + '?' + q.join('&');
790
+ }
791
+ let protoBlurred = false;
792
+ window.addEventListener('blur', () => { protoBlurred = true; });
793
+ function launchProto(url, fallbackCmd, okMsg) {
794
+ protoBlurred = false;
795
+ location.href = url;
796
+ setProgress('已发起一键拉起 tt-help…');
797
+ setTimeout(() => {
798
+ if (protoBlurred) { msg(okMsg); return; }
799
+ const el = document.getElementById('msg');
800
+ el.style.color = '#d97706';
801
+ el.innerHTML = '未检测到协议处理器(或已在弹窗取消)。<button onclick="copyCmd(\\'' + fallbackCmd + '\\')">复制命令</button> <span class="muted">或本机执行 <b>tt-help protocol register</b> 注册后即可一键拉起</span>';
802
+ }, 2500);
803
+ }
804
+ function addCmdDlg() {
805
+ launchProto(protoHref('login-pool/add', { server: location.origin }),
806
+ 'tt-help login-pool add --server ' + location.origin,
807
+ '已拉起 tt-help:请在弹出的窗口登录 TikTok,完成后回来点刷新');
808
+ }
809
+ function reloginDlg(name) {
810
+ launchProto(protoHref('login-pool/relogin', { server: location.origin, username: name }),
811
+ 'tt-help login-pool relogin ' + name + ' --server ' + location.origin,
812
+ '已拉起 tt-help 重登录 @' + name + ':看弹出窗口(旧登录态会先试自动复活),完成后点刷新');
813
+ }
814
+ function setProgress(t) {
815
+ const el = document.getElementById('msg');
816
+ el.innerHTML = '<span class="spin"></span>' + esc(t);
817
+ el.style.color = '#2563eb';
818
+ }
819
+ let checking = false;
820
+ async function checkNow() {
821
+ if (checking) return;
822
+ let targets;
823
+ try {
824
+ const j = await api('/api/login-pool');
825
+ targets = j.accounts.filter(a => a.status === 'pool' || a.status === 'suspect');
826
+ } catch (e) { msg('加载失败: ' + e.message, true); return; }
827
+ if (!targets.length) { msg('没有待检测账号(空闲+疑似为空,使用中的由业务自验)'); return; }
828
+ if (!confirm('批量检测 ' + targets.length + ' 个账号的登录态?(死号自动标疑似)')) return;
829
+ checking = true;
830
+ const btn = document.getElementById('btn-check');
831
+ btn.disabled = true; btn.textContent = '检测中…';
832
+ let done = 0, alive = 0, dead = 0, err = 0, mig = 0;
833
+ try {
834
+ for (const a of targets) {
835
+ setProgress('巡检中 ' + (done + 1) + '/' + targets.length + ' · 正在检测 @' + a.username + ' …');
836
+ let health = 'error';
837
+ try {
838
+ const r = await api('/api/login-pool/check', { ids: [a.id] });
839
+ const x = r.results && r.results[0];
840
+ if (x) { health = x.health; if (x.migrated) mig++; }
841
+ } catch (e) { health = 'error'; }
842
+ if (health === 'alive') alive++; else if (health === 'dead') dead++; else err++;
843
+ done++;
844
+ setProgress('巡检中 ' + done + '/' + targets.length + ' · 活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err);
845
+ load();
846
+ if (done < targets.length) await new Promise(r => setTimeout(r, 300));
847
+ }
848
+ msg('巡检完成: 探测 ' + done + ',活 ' + alive + ' / 死 ' + dead + ' / 异常 ' + err + (mig ? ',死号标疑似 ' + mig + ' 个' : ''));
849
+ } finally {
850
+ checking = false;
851
+ btn.disabled = false; btn.textContent = '批量检测';
852
+ }
767
853
  }
768
854
  async function assignDlg(id, name) {
769
855
  const userId = prompt('指派给节点 userId(如 F-11):'); if (!userId) return;
@@ -795,8 +881,9 @@ async function del(id) {
795
881
  catch (e) { msg('失败: ' + e.message, true); }
796
882
  }
797
883
  load();
798
- setInterval(() => { if (document.getElementById('auto').checked) load(); }, 15000);
884
+ setInterval(() => load(), 15000);
799
885
  </script>
886
+ </div>
800
887
  </body>
801
888
  </html>`;
802
889
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -1616,6 +1703,28 @@ export function startWatchServer(
1616
1703
  );
1617
1704
  sendJSON(res, 200, { ok: true, dedup: imp.dedup, account: ret });
1618
1705
  return;
1706
+ } else if (lpAction === "relogin") {
1707
+ // 重登录覆盖刷新(以号为主键):目标必须已在池;使用中拒绝;retired 复活、suspect 回池
1708
+ const rel = reloginAccount(body);
1709
+ ret = rel.account;
1710
+ console.error(
1711
+ `[登录池 ${ts}] RELOGIN: ${ret.username} ← ${body.userId}${rel.revived ? "(复活)" : rel.wasSuspect ? "(疑似→池)" : ""} (${ret.cookieCount} 条 cookies)`,
1712
+ );
1713
+ sendJSON(res, 200, { ok: true, revived: rel.revived, wasSuspect: rel.wasSuspect, account: ret });
1714
+ return;
1715
+ } else if (lpAction === "prepare") {
1716
+ // 重登录预备:返回目标账号明文 cookies(relogin 命令注入旧登录态用;与 acquire 同级信任)
1717
+ const prep = prepareRelogin(body);
1718
+ sendJSON(res, 200, { ok: true, account: prep.account });
1719
+ return;
1720
+ } else if (lpAction === "check") {
1721
+ // 巡检:无浏览器纯 HTTP 探测登录态(默认 pool+suspect,指定 ids 精确取)
1722
+ const sum = await checkPoolAccounts({ ids: body.ids, proxy: body.proxy });
1723
+ console.error(
1724
+ `[登录池 ${ts}] CHECK: 探测 ${sum.checked},alive ${sum.alive} / dead ${sum.dead} / error ${sum.error},migrated ${sum.migrated}`,
1725
+ );
1726
+ sendJSON(res, 200, { ok: true, ...sum });
1727
+ return;
1619
1728
  } else if (lpAction === "assign") {
1620
1729
  ret = assignAccount(body);
1621
1730
  console.error(`[登录池 ${ts}] ASSIGN: ${ret.username} → ${body.userId}:${body.port}`);