tt-help-cli-ycl 1.4.155 → 1.4.157

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.
@@ -22,6 +22,7 @@ import {
22
22
  } from "../lib/target-locations.js";
23
23
  import { getTargetLocations } from "../lib/app-config.js";
24
24
  import { createApiClient } from "../lib/api-client.js";
25
+ import { syncTargetLocationsFromServer } from "../lib/client-locations.js";
25
26
  import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
26
27
  import { delay } from "../lib/delay.js";
27
28
  import {
@@ -248,6 +249,9 @@ async function runAutoMode(options) {
248
249
  locations,
249
250
  } = options;
250
251
 
252
+ // 目标国家以服务端配置为准(单一事实来源;拉取失败沿用本地列表)
253
+ await syncTargetLocationsFromServer(serverUrl);
254
+
251
255
  const actualParallel = Math.max(1, parallel || CFG.parallel);
252
256
  const actualInterval = interval || CFG.interval;
253
257
  const actualMaxComments = maxComments || CFG.maxComments;
@@ -434,6 +438,9 @@ async function runAutoMode(options) {
434
438
  // ════════════════════════════════════════
435
439
  try {
436
440
  while (!shuttingDown) {
441
+ // 周期性同步服务端目标国家(内部节流,几乎零开销)
442
+ await syncTargetLocationsFromServer(serverUrl);
443
+
437
444
  // 浏览器窗口被手动关闭(CDP 断开)→ 自动退出
438
445
  if (isBrowserDisconnected(browser)) {
439
446
  console.error("🚪 检测到浏览器窗口已关闭,自动退出");
@@ -19,6 +19,7 @@ import {
19
19
  proxy as configuredProxy,
20
20
  } from "../lib/constants.js";
21
21
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
22
+ import { syncTargetLocationsFromServer } from "../lib/client-locations.js";
22
23
  import {
23
24
  ensureBrowserReady as ensureBrowserReadyCDP,
24
25
  killEdgeProcesses,
@@ -62,7 +63,6 @@ export async function handleExplore(options) {
62
63
  exploreEnableFollow,
63
64
  exploreMaxFollowing,
64
65
  exploreMaxFollowers,
65
- exploreLocation,
66
66
  exploreJobLocations,
67
67
  exploreMaxUsers,
68
68
  serverUrl,
@@ -127,6 +127,12 @@ export async function handleExplore(options) {
127
127
  );
128
128
  }
129
129
 
130
+ // 未显式传 --location 时,以服务端目标国家为准(周期同步,失败回退本地)
131
+ let exploreLocation = options.exploreLocation;
132
+ if (!options.locationExplicit) {
133
+ const synced = await syncTargetLocationsFromServer(serverUrl);
134
+ if (synced) exploreLocation = synced.join(",");
135
+ }
130
136
  console.error(`\n国家筛选: ${exploreLocation}`);
131
137
  if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
132
138
  console.error(`视频采集: ${exploreMaxVideos || 1}`);
@@ -401,6 +407,11 @@ export async function handleExplore(options) {
401
407
 
402
408
  while (!shuttingDown) {
403
409
  try {
410
+ // 周期性同步服务端目标国家(内部节流,几乎零开销)
411
+ if (!options.locationExplicit) {
412
+ const synced = await syncTargetLocationsFromServer(serverUrl);
413
+ if (synced) exploreLocation = synced.join(",");
414
+ }
404
415
  const jobQuery = exploreJobLocations
405
416
  ? `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&locations=${encodeURIComponent(exploreJobLocations)}&loggedIn=${loggedIn}`
406
417
  : `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&loggedIn=${loggedIn}`;
@@ -32,6 +32,7 @@ import {
32
32
  saveUserId,
33
33
  } from "../lib/constants.js";
34
34
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
35
+ import { syncTargetLocationsFromServer } from "../lib/client-locations.js";
35
36
  import {
36
37
  ensureBrowserReady as ensureBrowserReadyCDP,
37
38
  killEdgeProcesses,
@@ -138,7 +139,6 @@ export async function handleRefreshMini(options) {
138
139
  const {
139
140
  explorePreset,
140
141
  exploreInterval,
141
- exploreLocation,
142
142
  exploreJobLocations,
143
143
  exploreMaxUsers,
144
144
  explorePort,
@@ -210,6 +210,12 @@ export async function handleRefreshMini(options) {
210
210
  console.error(`CDP 端口: ${explorePort || DEFAULT_PORT}(固定)`);
211
211
  console.error(`采集范围: mini(仅视频及视频相关信息)`);
212
212
  console.error(`视频采集: ${exploreMaxVideos || 16}`);
213
+ // 未显式传 --location 时,以服务端目标国家为准(周期同步,失败回退本地)
214
+ let exploreLocation = options.exploreLocation;
215
+ if (!options.locationExplicit) {
216
+ const synced = await syncTargetLocationsFromServer(serverUrl);
217
+ if (synced) exploreLocation = synced.join(",");
218
+ }
213
219
  console.error(`国家筛选: ${exploreLocation}`);
214
220
  if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
215
221
  console.error(`空闲间隔: ${exploreInterval || 30} 秒`);
@@ -468,6 +474,12 @@ export async function handleRefreshMini(options) {
468
474
  while (true) {
469
475
  if (shuttingDown) break;
470
476
 
477
+ // 周期性同步服务端目标国家(内部节流,几乎零开销)
478
+ if (!options.locationExplicit) {
479
+ const synced = await syncTargetLocationsFromServer(serverUrl);
480
+ if (synced) exploreLocation = synced.join(",");
481
+ }
482
+
471
483
  // 定时轮换(任务间隙,预防性换 IP,等回执后再继续)
472
484
  if (rotateMin > 0) {
473
485
  const elapsedMin = (Date.now() - lastProxyTriggerTime) / 60000;
@@ -28,6 +28,7 @@ import { createApiClient } from "../lib/api-client.js";
28
28
  import { createCDNHandler } from "../lib/cdn-handler.js";
29
29
  import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
30
30
  import path from "path";
31
+ import { syncTargetLocationsFromServer } from "../lib/client-locations.js";
31
32
  import os from "os";
32
33
 
33
34
  const MAX_RETRY_WAIT = 5 * 60 * 1000;
@@ -57,7 +58,6 @@ export async function handleRefresh(options) {
57
58
  exploreEnableFollow,
58
59
  exploreMaxFollowing,
59
60
  exploreMaxFollowers,
60
- exploreLocation,
61
61
  exploreJobLocations,
62
62
  exploreMaxUsers,
63
63
  serverUrl,
@@ -142,6 +142,12 @@ export async function handleRefresh(options) {
142
142
  console.error(
143
143
  `关注/粉丝: ${followEnabled ? "启用" : "禁用"} (${exploreMaxFollowing}/${exploreMaxFollowers})`,
144
144
  );
145
+ // 未显式传 --location 时,以服务端目标国家为准(周期同步,失败回退本地)
146
+ let exploreLocation = options.exploreLocation;
147
+ if (!options.locationExplicit) {
148
+ const synced = await syncTargetLocationsFromServer(serverUrl);
149
+ if (synced) exploreLocation = synced.join(",");
150
+ }
145
151
  console.error(`国家筛选: ${exploreLocation}`);
146
152
  if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
147
153
  console.error(`空闲间隔: ${exploreInterval || 30} 秒`);
@@ -379,6 +385,12 @@ export async function handleRefresh(options) {
379
385
  while (true) {
380
386
  if (shuttingDown) break;
381
387
 
388
+ // 周期性同步服务端目标国家(内部节流,几乎零开销)
389
+ if (!options.locationExplicit) {
390
+ const synced = await syncTargetLocationsFromServer(serverUrl);
391
+ if (synced) exploreLocation = synced.join(",");
392
+ }
393
+
382
394
  // 健康检查
383
395
  const checkResult = healthChecker.check();
384
396
  if (checkResult.shouldSwitch) {
@@ -20,6 +20,29 @@ const CONFIG_FILE = join(homedir(), ".tt-help-config.json");
20
20
 
21
21
  let _cache = null;
22
22
 
23
+ // 服务端同步的运行时目标国家覆盖(优先于本地文件与内置默认,不落盘)
24
+ let _runtimeTargetLocations = null;
25
+
26
+ /**
27
+ * 设置运行时目标国家(通常来自 Watch 服务端同步)。
28
+ * 传空/非法值 = 清除覆盖,回退本地配置。
29
+ * 返回生效后的列表。
30
+ */
31
+ export function setRuntimeTargetLocations(locations) {
32
+ const normalized = Array.isArray(locations)
33
+ ? [
34
+ ...new Set(
35
+ locations
36
+ .map((l) => String(l).trim().toUpperCase())
37
+ .filter(Boolean),
38
+ ),
39
+ ]
40
+ : [];
41
+ _runtimeTargetLocations =
42
+ normalized.length > 0 ? [...normalized] : null;
43
+ return getTargetLocations();
44
+ }
45
+
23
46
  export function getConfigFilePath() {
24
47
  return CONFIG_FILE;
25
48
  }
@@ -94,8 +117,9 @@ function normalizeLocations(locations) {
94
117
  ];
95
118
  }
96
119
 
97
- /** 读取当前目标国家,未配置则回退 DEFAULT_TARGET_LOCATIONS */
120
+ /** 读取当前目标国家:服务端同步覆盖 > 配置文件 > 内置默认 */
98
121
  export function getTargetLocations() {
122
+ if (_runtimeTargetLocations) return [..._runtimeTargetLocations];
99
123
  const raw = getConfig("targetLocations", []);
100
124
  const normalized = normalizeLocations(raw);
101
125
  return normalized.length > 0 ? normalized : [...DEFAULT_TARGET_LOCATIONS];
package/src/lib/args.js CHANGED
@@ -175,6 +175,7 @@ function parseExploreArgs(args) {
175
175
  let exploreMaxFollowing = 50;
176
176
  let exploreMaxFollowers = 50;
177
177
  let exploreLocation = DEFAULT_TARGET_LOCATIONS_CSV;
178
+ let locationExplicit = false; // 是否显式传了 --location(显式时跳过服务端同步)
178
179
  let exploreJobLocations = null;
179
180
  let exploreMaxUsers = 0;
180
181
  let explorePort = null;
@@ -204,6 +205,7 @@ function parseExploreArgs(args) {
204
205
  exploreMaxGuess = parseInt(args[++i]) || 0;
205
206
  } else if (arg === "--location") {
206
207
  exploreLocation = args[++i];
208
+ locationExplicit = true;
207
209
  } else if (arg === "--job-locations") {
208
210
  exploreJobLocations = args[++i];
209
211
  } else if (arg === "--enable-follow") {
@@ -275,6 +277,7 @@ function parseExploreArgs(args) {
275
277
  exploreMaxFollowing,
276
278
  exploreMaxFollowers,
277
279
  exploreLocation,
280
+ locationExplicit,
278
281
  exploreJobLocations,
279
282
  serverUrl,
280
283
  exploreMaxUsers,
@@ -543,6 +546,7 @@ function parseRefreshArgs(args) {
543
546
  let exploreMaxFollowing = 100;
544
547
  let exploreMaxFollowers = 100;
545
548
  let exploreLocation = DEFAULT_TARGET_LOCATIONS_CSV;
549
+ let locationExplicit = false; // 是否显式传了 --location(显式时跳过服务端同步)
546
550
  let exploreMaxUsers = 0;
547
551
  let explorePort = null;
548
552
  let exploreBasePort = null;
@@ -599,6 +603,7 @@ function parseRefreshArgs(args) {
599
603
  exploreNoProduct = true;
600
604
  } else if (arg === "--location") {
601
605
  exploreLocation = args[++i];
606
+ locationExplicit = true;
602
607
  } else if (arg === "--job-locations") {
603
608
  exploreJobLocations = args[++i];
604
609
  } else if (arg === "--enable-follow") {
@@ -620,6 +625,7 @@ function parseRefreshArgs(args) {
620
625
  exploreMaxFollowing,
621
626
  exploreMaxFollowers,
622
627
  exploreLocation,
628
+ locationExplicit,
623
629
  exploreJobLocations,
624
630
  exploreMaxUsers,
625
631
  explorePort,
@@ -652,6 +658,7 @@ function parseRefreshMiniArgs(args) {
652
658
  let explorePreset = "normal";
653
659
  let exploreInterval = 30;
654
660
  let exploreLocation = DEFAULT_TARGET_LOCATIONS_CSV;
661
+ let locationExplicit = false; // 是否显式传了 --location(显式时跳过服务端同步)
655
662
  let exploreMaxUsers = 0;
656
663
  let explorePort = 4222;
657
664
  let exploreUserId = null;
@@ -689,6 +696,7 @@ function parseRefreshMiniArgs(args) {
689
696
  exploreProxy = args[++i];
690
697
  } else if (arg === "--location") {
691
698
  exploreLocation = args[++i];
699
+ locationExplicit = true;
692
700
  } else if (arg === "--job-locations") {
693
701
  exploreJobLocations = args[++i];
694
702
  } else if (arg === "--max-users") {
@@ -709,6 +717,7 @@ function parseRefreshMiniArgs(args) {
709
717
  exploreCommentDelay,
710
718
  exploreInterval,
711
719
  exploreLocation,
720
+ locationExplicit,
712
721
  exploreJobLocations,
713
722
  exploreMaxUsers,
714
723
  explorePort,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 客户端目标国家同步:以 Watch 服务端 /api/settings/target-locations 为单一事实来源。
3
+ *
4
+ * 设计:
5
+ * - 采集客户端(explore / refresh / refresh-mini / comments)启动时及任务循环中调用,
6
+ * 成功后覆盖运行时 getTargetLocations()(含 locationRank 等派生逻辑,不写本地盘)
7
+ * - 内部节流:成功后 SYNC_INTERVAL_MS 内直接返回缓存,循环里每轮调用也几乎零开销
8
+ * - 失败回退:拉取失败时沿用本地配置/内置默认,不中断采集
9
+ * - 显式传 --location 的客户端不调用本模块,用户参数始终优先
10
+ */
11
+ import { setRuntimeTargetLocations } from "./app-config.js";
12
+
13
+ const SYNC_INTERVAL_MS = 10 * 60 * 1000; // 同步成功后 10 分钟内复用缓存
14
+ const FETCH_TIMEOUT_MS = 8000;
15
+
16
+ let _lastSyncAt = 0;
17
+ let _lastList = null; // 最近一次同步成功的列表
18
+
19
+ /**
20
+ * 从服务端同步目标国家(节流)。
21
+ * @param {string} serverUrl Watch 服务端地址
22
+ * @param {{force?: boolean}} options force=true 跳过节流
23
+ * @returns {Promise<string[]|null>} 成功返回国家数组;失败返回 null(调用方沿用本地列表)
24
+ */
25
+ export async function syncTargetLocationsFromServer(
26
+ serverUrl,
27
+ { force = false } = {},
28
+ ) {
29
+ if (!serverUrl) return _lastList;
30
+ const now = Date.now();
31
+ if (
32
+ !force &&
33
+ _lastList &&
34
+ now - _lastSyncAt < SYNC_INTERVAL_MS
35
+ ) {
36
+ return _lastList;
37
+ }
38
+ try {
39
+ const base = String(serverUrl).replace(/\/+$/, "");
40
+ const res = await fetch(`${base}/api/settings/target-locations`, {
41
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
42
+ });
43
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
+ const data = await res.json();
45
+ const list = Array.isArray(data.locations) ? data.locations : [];
46
+ const normalized = [
47
+ ...new Set(
48
+ list.map((l) => String(l).trim().toUpperCase()).filter(Boolean),
49
+ ),
50
+ ];
51
+ if (normalized.length === 0) throw new Error("服务端返回空列表");
52
+
53
+ const changed =
54
+ !_lastList || normalized.join(",") !== _lastList.join(",");
55
+ setRuntimeTargetLocations(normalized);
56
+ _lastSyncAt = now;
57
+ _lastList = [...normalized];
58
+ if (changed) {
59
+ console.error(
60
+ `[locations] 已同步服务端目标国家(${normalized.length}): ${normalized.join(",")}`,
61
+ );
62
+ }
63
+ return _lastList;
64
+ } catch (e) {
65
+ console.error(
66
+ `[locations] 同步服务端目标国家失败(${e.message}),沿用本地列表`,
67
+ );
68
+ return null;
69
+ }
70
+ }
@@ -4749,6 +4749,59 @@ async function wdSetCountries() {
4749
4749
  }
4750
4750
  }
4751
4751
 
4752
+ // ── 代理订阅配置弹窗(保存后心跳下发 → daemon 热应用) ──
4753
+
4754
+ var _wdProxyUserId = "";
4755
+
4756
+ function wdShowProxyModal(userId) {
4757
+ _wdProxyUserId = userId;
4758
+ document.getElementById("wdProxyUserId").textContent = userId;
4759
+ var ta = document.getElementById("wdProxySubsText");
4760
+ var configured = [];
4761
+ if (_wdData && _wdData.proxySettings && _wdData.proxySettings[userId]) {
4762
+ configured = (_wdData.proxySettings[userId].subscriptions || []).map(function (s) {
4763
+ return typeof s === "string" ? s : s.url;
4764
+ });
4765
+ }
4766
+ ta.value = configured.join("\n");
4767
+ document.getElementById("wdProxyModal").style.display = "flex";
4768
+ }
4769
+
4770
+ function wdCloseProxyModal() {
4771
+ document.getElementById("wdProxyModal").style.display = "none";
4772
+ }
4773
+
4774
+ async function wdSaveProxySettings() {
4775
+ var userId = _wdProxyUserId;
4776
+ var lines = (document.getElementById("wdProxySubsText").value || "")
4777
+ .split("\n")
4778
+ .map(function (s) {
4779
+ return s.trim();
4780
+ })
4781
+ .filter(Boolean);
4782
+ try {
4783
+ var res = await fetch("/api/watchdog/set-proxy-settings", {
4784
+ method: "POST",
4785
+ headers: { "Content-Type": "application/json" },
4786
+ body: JSON.stringify({ userId: userId, subscriptions: lines }),
4787
+ });
4788
+ var d = await res.json();
4789
+ if (!res.ok || !d.ok) throw new Error(d.error || "HTTP " + res.status);
4790
+ if (_wdData) {
4791
+ _wdData.proxySettings = _wdData.proxySettings || {};
4792
+ _wdData.proxySettings[userId] = { subscriptions: d.subscriptions || [] };
4793
+ }
4794
+ wdCloseProxyModal();
4795
+ alert(
4796
+ "已保存 " +
4797
+ (d.subscriptions ? d.subscriptions.length : lines.length) +
4798
+ " 条订阅\n\n客户端将在下次心跳时热应用(无需重启,约 15~30 秒后刷新页面可见订阅用量)。",
4799
+ );
4800
+ } catch (e) {
4801
+ alert("保存失败: " + e.message);
4802
+ }
4803
+ }
4804
+
4752
4805
  // ── 登录检测端口配置区域(看门狗面板中每个客户端卡片) ──
4753
4806
 
4754
4807
  // userId -> 详情展开状态(跨刷新保留)
@@ -5242,7 +5295,8 @@ function renderProxySection(c, userId) {
5242
5295
  var hasNodes = nodes.length > 0;
5243
5296
  return (
5244
5297
  renderProxyLine(h, nodes, userId, 0, hasNodes) +
5245
- renderNodeList(nodes, h.node, userId, 0, delays)
5298
+ renderNodeList(nodes, h.node, userId, 0, delays) +
5299
+ renderSubscriptions(h.meta && h.meta.proxy, userId)
5246
5300
  );
5247
5301
  }
5248
5302
 
@@ -5254,10 +5308,117 @@ function renderProxyChannel(ch, userId) {
5254
5308
  var hasNodes = nodes.length > 0;
5255
5309
  return (
5256
5310
  renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
5257
- renderNodeList(nodes, ch.node, userId, ch.port || 0, delays)
5311
+ renderNodeList(nodes, ch.node, userId, ch.port || 0, delays) +
5312
+ renderSubscriptions(ch.meta && ch.meta.proxy, userId)
5258
5313
  );
5259
5314
  }
5260
5315
 
5316
+ function fmtGB(bytes) {
5317
+ if (!bytes || bytes <= 0) return "0";
5318
+ var gb = bytes / 1073741824;
5319
+ return gb >= 1024 ? (gb / 1024).toFixed(2) + " TB" : gb.toFixed(1) + " GB";
5320
+ }
5321
+
5322
+ function fmtSpeed(bytesPerSec) {
5323
+ if (!bytesPerSec || bytesPerSec <= 0) return "0 KB/s";
5324
+ if (bytesPerSec >= 1048576) return (bytesPerSec / 1048576).toFixed(1) + " MB/s";
5325
+ return (bytesPerSec / 1024).toFixed(0) + " KB/s";
5326
+ }
5327
+
5328
+ /**
5329
+ * 订阅用量/到期 + 实时速率 + 订阅配置入口(数据: clashHealth.meta.proxy)
5330
+ * 临期(<7天)或超量(剩余<10%)标红
5331
+ */
5332
+ function renderSubscriptions(proxy, userId) {
5333
+ proxy = proxy || {};
5334
+ var subs = proxy.subscriptions || [];
5335
+ var rows = "";
5336
+ // 订阅应用结果(daemon 最近一次 setSubscription 执行反馈;随下次应用/daemon 重启刷新)
5337
+ var ar = proxy.applyResult;
5338
+ if (ar && ar.at) {
5339
+ if (ar.ok === false) {
5340
+ rows +=
5341
+ styled(
5342
+ "div",
5343
+ "font-size:11px;margin:2px 0 4px;padding:4px 6px;background:#3b1d1d;border:1px solid #7f1d1d;border-radius:4px;color:#f87171;line-height:1.4",
5344
+ "❌ 订阅应用失败: " + esc(ar.error || "未知错误") +
5345
+ styled("span", "color:#94a3b8;margin-left:6px;font-size:10px", new Date(ar.at).toLocaleTimeString()),
5346
+ );
5347
+ } else if (ar.changed) {
5348
+ rows +=
5349
+ styled(
5350
+ "div",
5351
+ "font-size:11px;margin:2px 0 4px;color:#4ade80",
5352
+ "✓ 订阅应用成功 (" + (ar.count || "?") + " 条," + new Date(ar.at).toLocaleTimeString() + ")",
5353
+ );
5354
+ }
5355
+ }
5356
+ // 实时速率
5357
+ var t = proxy.traffic;
5358
+ if (t && (t.downRate > 0 || t.upRate > 0)) {
5359
+ rows +=
5360
+ styled(
5361
+ "div",
5362
+ "font-size:11px;color:#94a3b8;margin:2px 0 4px",
5363
+ "⚡ 速率: ↓" + fmtSpeed(t.downRate) + " / ↑" + fmtSpeed(t.upRate),
5364
+ );
5365
+ }
5366
+ for (var i = 0; i < subs.length; i++) {
5367
+ var s = subs[i];
5368
+ var used = (s.upload || 0) + (s.download || 0);
5369
+ var total = s.total || 0;
5370
+ var remain = total > 0 ? total - used : 0;
5371
+ var pct = total > 0 ? Math.round((remain / total) * 100) : null;
5372
+ var daysLeft = s.expire ? Math.round((s.expire * 1000 - Date.now()) / 86400000) : null;
5373
+ var danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
5374
+ var color = danger ? "#f87171" : "#94a3b8";
5375
+ var parts = [
5376
+ s.name + "(" + (s.proxies || 0) + "节点)",
5377
+ total > 0 ? "剩余 " + fmtGB(remain) + (pct !== null ? " (" + pct + "%)" : "") : "用量未知",
5378
+ s.expire ?
5379
+ "到期 " + new Date(s.expire * 1000).toLocaleDateString() +
5380
+ (daysLeft !== null ? "(剩 " + daysLeft + " 天)" : "")
5381
+ : "无到期",
5382
+ ];
5383
+ rows +=
5384
+ styled(
5385
+ "div",
5386
+ "font-size:11px;margin:2px 0" + (danger ? ";background:#3b1d1d;border-radius:4px;padding:3px 6px" : ""),
5387
+ styled("span", "color:" + color, "📡 " + parts.join(" | ")) +
5388
+ (danger ? styled("span", "color:#f87171;margin-left:6px", "⚠️") : ""),
5389
+ );
5390
+ }
5391
+ // 配置入口(摘要双来源: 后台配置优先,回退 daemon 实际生效快照;显示条数+订阅名)
5392
+ var configured = (window._wdData && _wdData.proxySettings && _wdData.proxySettings[userId] && _wdData.proxySettings[userId].subscriptions) || proxy.subscriptions || [];
5393
+ var cfgNames = configured
5394
+ .map(function (s) {
5395
+ return s.name || String(s.url || "").replace(/^https?:\/\//, "").split("/")[0];
5396
+ })
5397
+ .filter(Boolean);
5398
+ var cfgSummary = configured.length
5399
+ ? configured.length + " 条" + (cfgNames.length ? "(" + cfgNames.slice(0, 3).join("、") + (cfgNames.length > 3 ? " 等" : "") + ")" : "")
5400
+ : "未配置";
5401
+ rows +=
5402
+ styled(
5403
+ "div",
5404
+ "margin-top:4px;padding:5px 8px;background:#111;border-radius:6px;display:flex;align-items:center;gap:8px;font-size:12px",
5405
+ styled("span", "color:#888", "🛜 订阅配置:") +
5406
+ styled("span", "color:#e0e0e0", cfgSummary) +
5407
+ tag(
5408
+ "button",
5409
+ {
5410
+ onclick: "wdShowProxyModal('" + esc(userId) + "')",
5411
+ style:
5412
+ "padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
5413
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
5414
+ title: "配置该客户端的代理订阅地址(保存后自动热应用,无需重启)",
5415
+ },
5416
+ "配置 ⚙️",
5417
+ ),
5418
+ );
5419
+ return rows;
5420
+ }
5421
+
5261
5422
  /** 代理状态行:当前节点 + 健康状态 + 展开按钮 */
5262
5423
  function renderProxyLine(h, nodes, userId, port, hasNodes) {
5263
5424
  var dot = h.healthy ? "#4ade80" : "#f87171";
@@ -781,6 +781,23 @@
781
781
  </div>
782
782
  </div>
783
783
 
784
+ <!-- 代理订阅配置弹窗 -->
785
+ <div id="wdProxyModal"
786
+ 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">
787
+ <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
788
+ <div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span></div>
789
+ <div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https)。保存后客户端下次心跳时自动热应用(无需重启 mihomo),<br>应用成功后此处可看到剩余流量与到期时间;临期(&lt;7天)或超量(剩余&lt;10%)会标红。</div>
790
+ <textarea id="wdProxySubsText" rows="6" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx\nhttps://another-airport.com/sub?token=yyyy"
791
+ style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
792
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
793
+ <button onclick="wdCloseProxyModal()"
794
+ style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
795
+ <button onclick="wdSaveProxySettings()"
796
+ style="padding:8px 16px;background:#fbbf24;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存并热应用</button>
797
+ </div>
798
+ </div>
799
+ </div>
800
+
784
801
  <!-- 节点国家配置弹窗 -->
785
802
  <div id="wdCountryModal"
786
803
  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">
@@ -315,6 +315,26 @@ function getAllSwitchRestarts() {
315
315
  return cfg.switchRestarts || {};
316
316
  }
317
317
 
318
+ // ── 代理订阅配置管理(per agent;页面改动 → 心跳下发 → daemon 热应用) ──
319
+
320
+ function getProxySettingsStored(userId) {
321
+ if (!userId) return null;
322
+ const cfg = loadConfig();
323
+ return (cfg.proxySettings && cfg.proxySettings[userId]) || null;
324
+ }
325
+
326
+ function setProxySettingsStored(userId, config) {
327
+ const cfg = loadConfig();
328
+ if (!cfg.proxySettings) cfg.proxySettings = {};
329
+ cfg.proxySettings[userId] = config;
330
+ saveConfig(cfg);
331
+ }
332
+
333
+ function getAllProxySettings() {
334
+ const cfg = loadConfig();
335
+ return cfg.proxySettings || {};
336
+ }
337
+
318
338
  // ── 自动升级开关配置管理(per agent,默认开启:未配置或 enabled=true 均开启,后端显式关闭才停用) ──
319
339
 
320
340
  function getAutoUpgradeStored(userId) {
@@ -517,6 +537,7 @@ async function handleHeartbeat(params, body, res) {
517
537
  clientConfig: getClientConfigStored(userId),
518
538
  errorTrigger: getErrorTriggerStored(userId),
519
539
  switchRestart: getSwitchRestartStored(userId),
540
+ proxySettings: getProxySettingsStored(userId),
520
541
  autoUpgrade: getAutoUpgradeStored(userId),
521
542
  loginPorts: getLoginPortsStored(userId),
522
543
  serverTime: Date.now(),
@@ -533,6 +554,7 @@ async function handleGetClients(params, body, res) {
533
554
  supportedCountries: SUPPORTED_COUNTRIES,
534
555
  errorTriggers: getAllErrorTriggers(),
535
556
  switchRestarts: getAllSwitchRestarts(),
557
+ proxySettings: getAllProxySettings(),
536
558
  autoUpgrades: getAllAutoUpgrades(),
537
559
  loginPorts: getAllLoginPorts(),
538
560
  configPath: WATCHDOG_CONFIG_PATH,
@@ -809,6 +831,33 @@ async function handleSetErrorTrigger(params, body, res) {
809
831
  sendJSON(res, 200, { ok: true, config: cleaned });
810
832
  }
811
833
 
834
+ /** 配置某 agent 的代理订阅(心跳下发 → daemon 热应用;空列表表示清空) */
835
+ async function handleSetProxySettings(params, body, res) {
836
+ const { userId, subscriptions } = body || {};
837
+ if (!userId) {
838
+ sendJSON(res, 400, { error: "缺少 userId" });
839
+ return;
840
+ }
841
+ if (!Array.isArray(subscriptions)) {
842
+ sendJSON(res, 400, { error: "subscriptions 须为数组" });
843
+ return;
844
+ }
845
+ // 归一化: 字符串或 {name,url},去重去空
846
+ const seen = new Set();
847
+ const cleaned = [];
848
+ for (const it of subscriptions) {
849
+ const url = typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
850
+ if (!url || !/^https?:\/\//.test(url) || seen.has(url)) continue;
851
+ seen.add(url);
852
+ const name = typeof it === "object" && it.name ? String(it.name).trim() : "";
853
+ cleaned.push({ name, url });
854
+ }
855
+ setProxySettingsStored(userId, { subscriptions: cleaned });
856
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
857
+ console.error(`[watchdog ${ts}] ${userId} 代理订阅配置已更新: ${cleaned.length} 条`);
858
+ sendJSON(res, 200, { ok: true, subscriptions: cleaned });
859
+ }
860
+
812
861
  /** 配置某 agent 的代理切换重启(代理切换后强制重启全部命令;默认不启用) */
813
862
  async function handleSetSwitchRestart(params, body, res) {
814
863
  const { userId, config } = body || {};
@@ -967,6 +1016,10 @@ export async function tryHandleWatchdogRoute(
967
1016
  await handleSetSwitchRestart(params, body, res);
968
1017
  return true;
969
1018
  }
1019
+ if (method === "POST" && routePath === "/api/watchdog/set-proxy-settings") {
1020
+ await handleSetProxySettings(params, body, res);
1021
+ return true;
1022
+ }
970
1023
  if (method === "POST" && routePath === "/api/watchdog/set-auto-upgrade") {
971
1024
  await handleSetAutoUpgrade(params, body, res);
972
1025
  return true;