tt-help-cli-ycl 1.4.149 → 1.4.150

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.149",
3
+ "version": "1.4.150",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,28 @@
1
+ // 一次性运维脚本:为 task_queue 建 claim 加速复合索引(幂等)
2
+ import { createRequire } from "module";
3
+ const require = createRequire("D:/tiktok/tiktok-cli/package.json");
4
+ const Database = require("better-sqlite3");
5
+
6
+ const db = new Database("D:/tiktok/data/result.db");
7
+ db.pragma("busy_timeout = 30000");
8
+ const t0 = Date.now();
9
+ db.exec(
10
+ "CREATE INDEX IF NOT EXISTS idx_tq_claim_fast ON task_queue(status, UPPER(COALESCE(guessed_location,'')), follower_count DESC, created_at ASC, unique_id ASC)",
11
+ );
12
+ console.log(`index created in ${Date.now() - t0}ms`);
13
+ const plan = db
14
+ .prepare(
15
+ `EXPLAIN QUERY PLAN SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) IN ('PL') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`,
16
+ )
17
+ .all()
18
+ .map((r) => r.detail)
19
+ .join(" | ");
20
+ console.log("plan:", plan);
21
+ const t1 = Date.now();
22
+ db
23
+ .prepare(
24
+ `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) IN ('PL') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`,
25
+ )
26
+ .get();
27
+ console.log(`branch query: ${Date.now() - t1}ms`);
28
+ db.close();
@@ -0,0 +1,20 @@
1
+ // 一次性运维脚本:对主库执行 wal_checkpoint(TRUNCATE)
2
+ // 安全性:只把已提交的 WAL 帧合并回主库文件并截断 WAL,不丢数据
3
+ import { createRequire } from "module";
4
+ const require = createRequire("D:/tiktok/tiktok-cli/package.json");
5
+ const Database = require("better-sqlite3");
6
+
7
+ const dbPath = "D:/tiktok/data/result.db";
8
+ const db = new Database(dbPath);
9
+ db.pragma("busy_timeout = 10000");
10
+
11
+ const before = require("fs").statSync(dbPath + "-wal").size / 1024 / 1024;
12
+ console.log(`WAL before: ${before.toFixed(1)} MB`);
13
+
14
+ const t0 = Date.now();
15
+ const r = db.pragma("wal_checkpoint(TRUNCATE)");
16
+ console.log(`checkpoint result:`, JSON.stringify(r), `${Date.now() - t0}ms`);
17
+
18
+ const after = require("fs").statSync(dbPath + "-wal").size / 1024 / 1024;
19
+ console.log(`WAL after: ${after.toFixed(1)} MB`);
20
+ db.close();
@@ -10,7 +10,10 @@
10
10
  import { existsSync, readFileSync, writeFileSync } from "fs";
11
11
  import { homedir } from "os";
12
12
  import { join } from "path";
13
- import { DEFAULT_TARGET_LOCATIONS } from "./target-locations.js";
13
+ import {
14
+ DEFAULT_TARGET_LOCATIONS,
15
+ normalizeLocation,
16
+ } from "./target-locations.js";
14
17
 
15
18
  // 通用配置文件路径(与 ~/.tt-help.json 隔离,避免与看门狗 clientConfig 写入冲突)
16
19
  const CONFIG_FILE = join(homedir(), ".tt-help-config.json");
@@ -117,3 +120,14 @@ export function setTargetLocations(locations) {
117
120
  export function reloadConfig() {
118
121
  _cache = null;
119
122
  }
123
+
124
+ /**
125
+ * 国家优先级序号:位置越前优先级越高。
126
+ * 空/未配置国家返回 MAX_SAFE_INTEGER(排最后)。
127
+ */
128
+ export function locationRank(location) {
129
+ const normalized = normalizeLocation(location);
130
+ if (!normalized) return Number.MAX_SAFE_INTEGER;
131
+ const idx = getTargetLocations().indexOf(normalized);
132
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
133
+ }
@@ -241,6 +241,8 @@ function launchEdgeWithCDP(port, userDataDir, proxyServer) {
241
241
  "--disable-breakpad",
242
242
  "--disable-background-networking",
243
243
  "--disable-sync",
244
+ // 显式打开 about:blank,避免 Edge 默认打开 MSN 新标签页
245
+ "about:blank",
244
246
  ];
245
247
  if (proxyServer) {
246
248
  extraArgs.push(`--proxy-server=${proxyServer}`);
@@ -307,11 +307,25 @@ export async function findTikTokPage(browser) {
307
307
  }
308
308
 
309
309
  export async function getOrCreatePage(browser) {
310
+ // 先确定工作页:优先复用已有 TikTok 页,没有才新建。
311
+ // 必须先建后关:任何时刻至少保留 1 个标签页,避免 Windows 下
312
+ // 关闭最后一个标签页导致浏览器进程退出、CDP 断连。
310
313
  let page = await findTikTokPage(browser);
311
314
  if (!page) {
312
315
  const defaultCtx = browser.contexts()[0] || (await browser.newContext());
313
316
  page = await defaultCtx.newPage();
314
317
  }
318
+ // 关闭其余所有页面:清掉启动残留的默认新标签页(NTP/MSN),
319
+ // 以及进程崩溃泄漏的搜索 tab。不做 URL 匹配,无需随 Edge 改版维护。
320
+ // search-collector 的瞬态多 tab 在 finally 自行关闭,且本函数只在
321
+ // 启动/账户切换/浏览器重建时调用,不会撞上在途搜索 tab。
322
+ for (const ctx of browser.contexts()) {
323
+ for (const p of ctx.pages()) {
324
+ if (p !== page) {
325
+ await p.close().catch(() => {});
326
+ }
327
+ }
328
+ }
315
329
  return page;
316
330
  }
317
331
 
@@ -1,9 +1,5 @@
1
+ // 顺序即领取优先级:越靠前越先领取(旧 tier1/tier2 硬编码分层已改为配置顺序驱动)
1
2
  const DEFAULT_TARGET_LOCATIONS = [
2
- "CZ",
3
- "GR",
4
- "HU",
5
- "PT",
6
- "ES",
7
3
  "PL",
8
4
  "NL",
9
5
  "BE",
@@ -11,9 +7,16 @@ const DEFAULT_TARGET_LOCATIONS = [
11
7
  "FR",
12
8
  "IT",
13
9
  "IE",
10
+ "ES",
11
+ "CZ",
12
+ "GR",
13
+ "HU",
14
+ "PT",
14
15
  "AT",
15
16
  "TH",
16
17
  "US",
18
+ "MY",
19
+ "VN",
17
20
  ];
18
21
 
19
22
  const DEFAULT_TARGET_LOCATIONS_CSV = DEFAULT_TARGET_LOCATIONS.join(",");
@@ -20,7 +20,23 @@ import fs from "fs";
20
20
  import path from "path";
21
21
  import Database from "better-sqlite3";
22
22
  import { isLocationInList } from "../lib/target-locations.js";
23
- import { getTargetLocations } from "../lib/app-config.js";
23
+ import { getTargetLocations, locationRank } from "../lib/app-config.js";
24
+
25
+ /**
26
+ * 按目标国家配置顺序解析领取顺序(优先级 = 配置顺序,越靠前越先领)。
27
+ * 传入子集时:配置内国家按配置顺序在前,配置外国家按传入顺序附在后面。
28
+ */
29
+ function resolveOrderedLocations(normalizedLocations) {
30
+ const configured = getTargetLocations();
31
+ if (!normalizedLocations || normalizedLocations.length === 0)
32
+ return [...configured];
33
+ const configSet = new Set(configured);
34
+ const requestedSet = new Set(normalizedLocations);
35
+ return [
36
+ ...configured.filter((loc) => requestedSet.has(loc)),
37
+ ...normalizedLocations.filter((loc) => !configSet.has(loc)),
38
+ ];
39
+ }
24
40
 
25
41
  // Schema 与连接管理
26
42
  import {
@@ -925,13 +941,9 @@ export function createStore(filePath, options = {}) {
925
941
  let statusGroups = null;
926
942
  let groupsDirty = true;
927
943
 
928
- const tier1LocSet = new Set(["PL", "NL", "BE"]);
929
- const tier2LocSet = new Set(["DE", "FR", "IT", "IE", "ES"]);
944
+ // 优先级 = 目标国家配置顺序(越靠前越优先),空/配置外国家排最后
930
945
  function locationTier(u) {
931
- const loc = (u.guessedLocation || "").toUpperCase();
932
- if (tier1LocSet.has(loc)) return 0;
933
- if (tier2LocSet.has(loc)) return 1;
934
- return 2;
946
+ return locationRank(u.guessedLocation);
935
947
  }
936
948
 
937
949
  function sortGroup(key, arr) {
@@ -1278,13 +1290,13 @@ export function createStore(filePath, options = {}) {
1278
1290
  };
1279
1291
  }
1280
1292
 
1281
- const tier1 = new Set(["PL", "NL", "BE"]);
1282
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1283
1293
  const normalizedLocations = Array.isArray(locations)
1284
1294
  ? locations
1285
1295
  .map((loc) => String(loc).trim().toUpperCase())
1286
1296
  .filter(Boolean)
1287
1297
  : [];
1298
+ // 优先级 = 目标国家配置顺序;传子集时按配置顺序取交集(配置外国家按传入顺序排后)
1299
+ const orderedCountries = resolveOrderedLocations(normalizedLocations);
1288
1300
 
1289
1301
  // ---- 过期 processing 回收 ----
1290
1302
  const expiredRow = getDb()
@@ -1304,31 +1316,19 @@ export function createStore(filePath, options = {}) {
1304
1316
  expiredCandidate = expiredRow;
1305
1317
  }
1306
1318
 
1307
- // ---- 构建统一优先级查询(合并原 20+ 次独立 SELECT 为 1 次 UNION) ----
1319
+ // ---- 逐级短路查询:按优先级顺序逐个分支执行,命中即领取 ----
1308
1320
  const statusCodeWhere = loggedIn
1309
1321
  ? "(status_code IS NULL OR status_code = 0 OR status_code = 209002)"
1310
1322
  : "(status_code IS NULL OR status_code = 0)";
1311
1323
  const notSellerWhere = loggedIn ? "" : "AND COALESCE(tt_seller, 0) != 1";
1312
1324
 
1313
- const tier1List = normalizedLocations.length
1314
- ? normalizedLocations.filter((loc) => tier1.has(loc))
1315
- : [...tier1];
1316
- const tier2List = normalizedLocations.length
1317
- ? normalizedLocations.filter((loc) => tier2.has(loc))
1318
- : [...tier2];
1319
- const otherFiltered = normalizedLocations.length
1320
- ? normalizedLocations.filter(
1321
- (loc) => !tier1.has(loc) && !tier2.has(loc),
1322
- )
1323
- : null;
1324
-
1325
- const locIn = (list) => {
1325
+ const locIn = (list, { includeEmpty = true } = {}) => {
1326
1326
  if (!list || list.length === 0) return null;
1327
1327
  const ph = list.map(() => "?").join(", ");
1328
- return {
1329
- sql: `(UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`,
1330
- params: [...list],
1331
- };
1328
+ const sql = includeEmpty
1329
+ ? `(UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`
1330
+ : `UPPER(COALESCE(guessed_location, '')) IN (${ph})`;
1331
+ return { sql, params: [...list] };
1332
1332
  };
1333
1333
  const locNotIn = (list) => {
1334
1334
  if (!list || list.length === 0) return null;
@@ -1338,13 +1338,6 @@ export function createStore(filePath, options = {}) {
1338
1338
  params: [...list],
1339
1339
  };
1340
1340
  };
1341
- const locOther = () => {
1342
- if (otherFiltered && otherFiltered.length > 0)
1343
- return locIn(otherFiltered);
1344
- if (normalizedLocations.length === 0)
1345
- return locNotIn([...tier1, ...tier2]);
1346
- return null;
1347
- };
1348
1341
 
1349
1342
  // 全局 location 过滤(pinned 查询用)
1350
1343
  let pinnedLocSql = "";
@@ -1355,45 +1348,38 @@ export function createStore(filePath, options = {}) {
1355
1348
  pinnedLocParams.push(...normalizedLocations);
1356
1349
  }
1357
1350
 
1358
- const sqlParts = [];
1359
- const allParams = [];
1351
+ // 逐级短路:单分支 LIMIT 1 走索引仅几十 ms;原 26 分支 UNION ALL 需 MERGE
1352
+ // 收集全部分支结果再排序,task_queue 增长后成为 CPU/延迟瓶颈。
1353
+ // 语义与原「UNION ALL + ORDER BY _prio,_sub_prio,... LIMIT 1」完全等价:
1354
+ // 分支添加顺序即 (_prio,_sub_prio),首个非空分支的最优行即全局最优行。
1355
+ const branchOrder =
1356
+ " ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1";
1357
+ const branches = [];
1360
1358
 
1361
- const addPart = (prio, subPrio, conditions, params) => {
1362
- sqlParts.push(
1363
- `SELECT *, ${prio} as _prio, ${subPrio} as _sub_prio FROM task_queue WHERE ${conditions}`,
1364
- );
1365
- allParams.push(...params);
1359
+ const addBranch = (conditions, params) => {
1360
+ branches.push({ conditions, params });
1366
1361
  };
1367
1362
 
1368
1363
  // prio 0: pinned + hasVideo
1369
- addPart(
1370
- 0,
1371
- 0,
1364
+ addBranch(
1372
1365
  `status = 'pending' AND pinned = 1 AND video_count > 0 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1373
1366
  pinnedLocParams,
1374
1367
  );
1375
1368
  // prio 1: pinned (no video req)
1376
- addPart(
1377
- 1,
1378
- 0,
1369
+ addBranch(
1379
1370
  `status = 'pending' AND pinned = 1 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1380
1371
  pinnedLocParams,
1381
1372
  );
1382
1373
 
1383
1374
  // prio 2-3: expired candidate(回收的超时任务优先)
1384
1375
  if (expiredCandidate) {
1385
- addPart(
1386
- 2,
1387
- 0,
1376
+ addBranch(
1388
1377
  `unique_id = ? AND video_count > 0 AND ${statusCodeWhere} ${notSellerWhere}`,
1389
1378
  [expiredCandidate.unique_id],
1390
1379
  );
1391
- addPart(
1392
- 3,
1393
- 0,
1394
- `unique_id = ? AND ${statusCodeWhere} ${notSellerWhere}`,
1395
- [expiredCandidate.unique_id],
1396
- );
1380
+ addBranch(`unique_id = ? AND ${statusCodeWhere} ${notSellerWhere}`, [
1381
+ expiredCandidate.unique_id,
1382
+ ]);
1397
1383
  }
1398
1384
 
1399
1385
  // 非 pinned 优先级:seed → seller → follow → other,每级 × 3 个 location tier × (hasVideo / noVideoReq)
@@ -1413,27 +1399,25 @@ export function createStore(filePath, options = {}) {
1413
1399
  let basePrio = 4;
1414
1400
  for (const p of priorities) {
1415
1401
  if (p.loggedInOnly && !loggedIn) continue;
1416
- const tierLocations = [
1417
- { list: tier1List, fn: locIn },
1418
- { list: tier2List, fn: locIn },
1419
- { list: null, fn: locOther },
1420
- ];
1402
+ // 逐国顺序分支:配置内国家按优先级顺序;未传子集时末尾补“配置外国家”兑底。
1403
+ // 空位置(loc='')只在首个国家的分支参与:首国的 noVideoReq 分支 miss 即意味着
1404
+ // 空位置池已耗尽,后续分支无需再 OR ''(避免反复扫描空位置大池)
1405
+ const locationBranches = orderedCountries.map((loc, idx) =>
1406
+ locIn([loc], { includeEmpty: idx === 0 }),
1407
+ );
1408
+ if (normalizedLocations.length === 0)
1409
+ locationBranches.push(locNotIn(orderedCountries));
1421
1410
  let subPrio = 0;
1422
- for (const tl of tierLocations) {
1423
- const loc = tl.fn(tl.list);
1411
+ for (const loc of locationBranches) {
1424
1412
  if (!loc) continue;
1425
1413
  // hasVideo
1426
- addPart(
1427
- basePrio,
1428
- subPrio,
1414
+ addBranch(
1429
1415
  `status = 'pending' AND pinned = 0 AND ${p.filter} AND video_count > 0 AND ${loc.sql} AND ${statusCodeWhere} ${notSellerWhere}`,
1430
1416
  loc.params,
1431
1417
  );
1432
1418
  subPrio++;
1433
1419
  // noVideoReq
1434
- addPart(
1435
- basePrio + 1,
1436
- subPrio,
1420
+ addBranch(
1437
1421
  `status = 'pending' AND pinned = 0 AND ${p.filter} AND ${loc.sql} AND ${statusCodeWhere} ${notSellerWhere}`,
1438
1422
  loc.params,
1439
1423
  );
@@ -1442,14 +1426,16 @@ export function createStore(filePath, options = {}) {
1442
1426
  basePrio += 2;
1443
1427
  }
1444
1428
 
1429
+ // 逐级执行:首个命中分支的最优行即领取结果(未命中继续下一优先级分支)
1445
1430
  let selectedRow = null;
1446
- if (sqlParts.length > 0) {
1447
- const unionSql =
1448
- sqlParts.join(" UNION ALL ") +
1449
- " ORDER BY _prio, _sub_prio, follower_count DESC, created_at ASC, unique_id ASC LIMIT 1";
1450
- selectedRow = getDb()
1451
- .prepare(unionSql)
1452
- .get(...allParams);
1431
+ for (const b of branches) {
1432
+ selectedRow =
1433
+ getDb()
1434
+ .prepare(
1435
+ `SELECT * FROM task_queue WHERE ${b.conditions}${branchOrder}`,
1436
+ )
1437
+ .get(...b.params) || null;
1438
+ if (selectedRow) break;
1453
1439
  }
1454
1440
 
1455
1441
  if (selectedRow) {
@@ -1493,15 +1479,8 @@ export function createStore(filePath, options = {}) {
1493
1479
  };
1494
1480
  }
1495
1481
 
1496
- // 按猜测国家梯队排序
1497
- const tier1 = new Set(["PL", "NL", "BE"]);
1498
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1499
- function locationTier(u) {
1500
- const loc = (u.guessedLocation || "").toUpperCase();
1501
- if (tier1.has(loc)) return 0;
1502
- if (tier2.has(loc)) return 1;
1503
- return 2;
1504
- }
1482
+ // 按目标国家配置顺序排序(越靠前越优先)
1483
+ const locationTier = (u) => locationRank(u.guessedLocation);
1505
1484
 
1506
1485
  // 国家过滤:如果指定了 locations,只保留 guessedLocation 在列表中的用户
1507
1486
  function locationFilter(u) {
@@ -1641,8 +1620,6 @@ export function createStore(filePath, options = {}) {
1641
1620
  }
1642
1621
  : null;
1643
1622
 
1644
- const tier1 = new Set(["PL", "NL", "BE"]);
1645
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1646
1623
  const normalizedLocations = Array.isArray(locations)
1647
1624
  ? locations
1648
1625
  .map((loc) => String(loc).trim().toUpperCase())
@@ -1650,29 +1627,11 @@ export function createStore(filePath, options = {}) {
1650
1627
  : [];
1651
1628
 
1652
1629
  function getLocationGroups() {
1653
- const selected = normalizedLocations.length
1654
- ? normalizedLocations
1655
- : null;
1656
- const tier1List = selected
1657
- ? selected.filter((loc) => tier1.has(loc))
1658
- : [...tier1];
1659
- const tier2List = selected
1660
- ? selected.filter((loc) => tier2.has(loc))
1661
- : [...tier2];
1662
- const otherList = selected
1663
- ? selected.filter((loc) => !tier1.has(loc) && !tier2.has(loc))
1664
- : null;
1665
- const groups = [];
1666
- if (tier1List.length > 0)
1667
- groups.push({ type: "include", values: tier1List });
1668
- if (tier2List.length > 0)
1669
- groups.push({ type: "include", values: tier2List });
1670
- if (selected) {
1671
- if (otherList.length > 0)
1672
- groups.push({ type: "include", values: otherList });
1673
- } else {
1674
- groups.push({ type: "exclude", values: [...tier1, ...tier2] });
1675
- }
1630
+ const ordered = resolveOrderedLocations(normalizedLocations);
1631
+ if (ordered.length === 0) return [];
1632
+ const groups = [{ type: "include", values: ordered }];
1633
+ if (normalizedLocations.length === 0)
1634
+ groups.push({ type: "exclude", values: [...ordered] });
1676
1635
  return groups;
1677
1636
  }
1678
1637
 
@@ -446,19 +446,28 @@ export function initDb(filePath) {
446
446
  db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_old_id ON videos_old(id)`);
447
447
  }
448
448
 
449
- // ===== task_queue 索引(对应 claimNextJob UNION 查询的 4 个部分索引) =====
449
+ // ===== task_queue 索引(claimNextJob 逐级短路查询 + 监控列表) =====
450
+ // idx_tq_claim_fast:claim 分支查询主索引(2026-09-17 实战验证:分支 7s→1ms)。
451
+ // 普通复合索引,不依赖部分索引的 WHERE 表达式文本匹配(旧 4 个部分索引因
452
+ // pinned=0 与 COALESCE(pinned,0)=0 文本不一致、首国分支 OR 空串,从未被命中,已废弃清理)。
450
453
  const tqIndexes = [
451
454
  `CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)`,
452
455
  `CREATE INDEX IF NOT EXISTS idx_tq_claimed_by_status ON task_queue(claimed_by, status, claimed_at)`,
453
456
  `CREATE INDEX IF NOT EXISTS idx_tq_status_claimed_at ON task_queue(status, claimed_at)`,
454
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_pinned ON task_queue(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
455
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_seller ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
456
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_follow ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
457
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_other ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
457
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_fast ON task_queue(status, UPPER(COALESCE(guessed_location,'')), follower_count DESC, created_at ASC, unique_id ASC)`,
458
458
  // 监控页面用户列表查询加速
459
459
  `CREATE INDEX IF NOT EXISTS idx_tq_list_pending ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'pending'`,
460
460
  `CREATE INDEX IF NOT EXISTS idx_tq_list_processing ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'processing'`,
461
461
  ];
462
+ // 清理从未被命中的旧 claim 部分索引(省写入开销与空间,幂等)
463
+ for (const oldIdx of [
464
+ "idx_tq_claim_pending_pinned",
465
+ "idx_tq_claim_pending_seller",
466
+ "idx_tq_claim_pending_follow",
467
+ "idx_tq_claim_pending_other",
468
+ ]) {
469
+ db.exec(`DROP INDEX IF EXISTS ${oldIdx}`);
470
+ }
462
471
  for (const sql of tqIndexes) {
463
472
  db.exec(sql);
464
473
  }
@@ -182,7 +182,7 @@ function renderStats() {
182
182
  flashEl("statTotal", d.totalUsers);
183
183
  flashEl("statProcessing", d.processingUsers || 0);
184
184
  flashEl("statDone", d.processedUsers);
185
- flashEl("statPending", d.pendingUsers);
185
+ flashEl("statPending", d.pendingUsers, { full: true });
186
186
  flashEl("statError", d.errorUsers);
187
187
  flashEl("statTarget", d.targetUsers, { full: true });
188
188
  flashEl("statPrecise", d.preciseMerchants, { full: true });
@@ -206,7 +206,8 @@ function renderStats() {
206
206
  const pendingTotal = document.getElementById("pendingStatTotal");
207
207
  if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
208
208
  const pendingCount = document.getElementById("pendingStatPending");
209
- if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
209
+ if (pendingCount)
210
+ pendingCount.textContent = formatStatNum(d.pendingUsers, { full: true });
210
211
  const pendingUserUpdate = document.getElementById(
211
212
  "pendingStatUserUpdateTasks",
212
213
  );
@@ -222,7 +223,9 @@ function renderStats() {
222
223
  userUpdateTotal.textContent = formatStatNum(d.totalUsers);
223
224
  const userUpdatePending = document.getElementById("userUpdateStatPending");
224
225
  if (userUpdatePending)
225
- userUpdatePending.textContent = formatStatNum(d.pendingUsers);
226
+ userUpdatePending.textContent = formatStatNum(d.pendingUsers, {
227
+ full: true,
228
+ });
226
229
  const userUpdateTasks = document.getElementById(
227
230
  "userUpdateStatUserUpdateTasks",
228
231
  );
@@ -238,7 +241,9 @@ function renderStats() {
238
241
  rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
239
242
  const rawPagePending = document.getElementById("rawPageStatPending");
240
243
  if (rawPagePending)
241
- rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
244
+ rawPagePending.textContent = formatStatNum(d.pendingUsers || 0, {
245
+ full: true,
246
+ });
242
247
  const rawPageUserUpdate = document.getElementById(
243
248
  "rawPageStatUserUpdateTasks",
244
249
  );
@@ -247,11 +252,11 @@ function renderStats() {
247
252
  document.getElementById("lastUpdate").textContent =
248
253
  "更新于 " + new Date().toLocaleTimeString();
249
254
  document.getElementById("fileMeta").textContent =
250
- formatStatNum(d.processingUsers || 0) +
255
+ formatStatNum(d.processingUsers || 0, { full: true }) +
251
256
  " 处理中, " +
252
- formatStatNum(d.totalUsers) +
257
+ formatStatNum(d.totalUsers, { full: true }) +
253
258
  " 个用户, 待处理 " +
254
- formatStatNum(d.pendingUsers || 0);
259
+ formatStatNum(d.pendingUsers || 0, { full: true });
255
260
 
256
261
  renderCountryChart(d.countryStats);
257
262
  renderSourceChart(d.sourceStats);
@@ -700,6 +705,8 @@ const DEFAULT_LOCATION_OPTIONS = [
700
705
  "PT",
701
706
  "TH",
702
707
  "US",
708
+ "MY",
709
+ "VN",
703
710
  ];
704
711
  let locationOptions = [...DEFAULT_LOCATION_OPTIONS];
705
712
 
@@ -1299,8 +1306,12 @@ function handleRoute() {
1299
1306
  showPrecisePage();
1300
1307
  } else if (hash === "#tags") {
1301
1308
  showTagsPage();
1302
- } else if (hash === "#settings") {
1309
+ } else if (hash === "#settings" || hash.startsWith("#settings/")) {
1303
1310
  showSettingsPage();
1311
+ const sub = hash.startsWith("#settings/")
1312
+ ? hash.slice("#settings/".length)
1313
+ : "";
1314
+ switchSettingsPanel(sub || "llm", false);
1304
1315
  } else if (hash === "#watchdog") {
1305
1316
  showWatchdogPage();
1306
1317
  } else {
@@ -1437,6 +1448,24 @@ function showSettingsPage() {
1437
1448
  loadTargetLocations();
1438
1449
  }
1439
1450
 
1451
+ // 设置页左侧分类切换:panel 对应 settings-nav-item 的 data-panel 与 settingsPanel-<panel> 容器
1452
+ function switchSettingsPanel(panel, updateHash = true) {
1453
+ document
1454
+ .querySelectorAll("#settingsNav .settings-nav-item")
1455
+ .forEach((btn) =>
1456
+ btn.classList.toggle("active", btn.dataset.panel === panel),
1457
+ );
1458
+ document
1459
+ .querySelectorAll(".settings-panel")
1460
+ .forEach((el) =>
1461
+ el.classList.toggle("active", el.id === "settingsPanel-" + panel),
1462
+ );
1463
+ if (updateHash) {
1464
+ const target = panel === "llm" ? "#settings" : "#settings/" + panel;
1465
+ if (window.location.hash !== target) window.location.hash = target;
1466
+ }
1467
+ }
1468
+
1440
1469
  // ========== 目标国家配置(通用 app-config,落盘 ~/.tt-help-config.json) ==========
1441
1470
 
1442
1471
  const AVAILABLE_COUNTRIES = [
@@ -1455,6 +1484,8 @@ const AVAILABLE_COUNTRIES = [
1455
1484
  "PT",
1456
1485
  "TH",
1457
1486
  "US",
1487
+ "MY",
1488
+ "VN",
1458
1489
  "GB",
1459
1490
  "SE",
1460
1491
  "NO",
@@ -1476,6 +1507,11 @@ const AVAILABLE_COUNTRIES = [
1476
1507
  "CO",
1477
1508
  ];
1478
1509
 
1510
+ // ========== 目标国家配置(宫格 + 拖拽排序,位置即领取优先级) ==========
1511
+
1512
+ let _targetLocations = []; // 顺序即领取优先级,拖拽/增删后需点保存才落盘
1513
+ let _targetLocationsSaved = []; // 最近一次加载/保存的快照,用于脏状态提示
1514
+
1479
1515
  function normalizeCountryInput(str) {
1480
1516
  return [
1481
1517
  ...new Set(
@@ -1487,20 +1523,43 @@ function normalizeCountryInput(str) {
1487
1523
  ];
1488
1524
  }
1489
1525
 
1526
+ /** 两位国家代码 → 国旗 emoji */
1527
+ function countryFlag(code) {
1528
+ if (!/^[A-Za-z]{2}$/.test(code)) return "🏳️";
1529
+ return String.fromCodePoint(
1530
+ ...[...code.toUpperCase()].map(
1531
+ (c) => 0x1f1e6 + c.charCodeAt(0) - 65,
1532
+ ),
1533
+ );
1534
+ }
1535
+
1536
+ function targetLocationsDirty() {
1537
+ return (
1538
+ _targetLocations.join(",") !== _targetLocationsSaved.join(",")
1539
+ );
1540
+ }
1541
+
1542
+ function updateTargetLocationsStatus() {
1543
+ const status = document.getElementById("targetLocationsStatus");
1544
+ if (!status) return;
1545
+ if (targetLocationsDirty()) {
1546
+ status.textContent = `⛔ 有未保存的修改(当前 ${_targetLocations.length} 国,顺序: ${_targetLocations.join(" → ")}),点“保存目标国家”生效`;
1547
+ status.className = "settings-status warn";
1548
+ } else {
1549
+ status.textContent = `✅ 已生效:${_targetLocations.length} 国,位置即领取优先级(越靠前越先领)`;
1550
+ status.className = "settings-status ok";
1551
+ }
1552
+ }
1553
+
1490
1554
  async function loadTargetLocations() {
1491
- const input = document.getElementById("targetLocationsInput");
1492
1555
  const status = document.getElementById("targetLocationsStatus");
1493
- if (!input) return;
1494
1556
  try {
1495
1557
  const res = await fetch("/api/settings/target-locations");
1496
1558
  const data = await res.json();
1497
- const locations = data.locations || [];
1498
- input.value = locations.join(",");
1499
- renderTargetCountryGrid(locations);
1500
- if (status) {
1501
- status.textContent = `当前 ${locations.length} 个国家: ${locations.join(", ")}`;
1502
- status.className = "settings-status ok";
1503
- }
1559
+ _targetLocations = (data.locations || []).map(String);
1560
+ _targetLocationsSaved = [..._targetLocations];
1561
+ renderTargetLocationsGrid();
1562
+ updateTargetLocationsStatus();
1504
1563
  } catch (e) {
1505
1564
  console.error("loadTargetLocations error:", e);
1506
1565
  if (status) {
@@ -1510,47 +1569,154 @@ async function loadTargetLocations() {
1510
1569
  }
1511
1570
  }
1512
1571
 
1513
- function renderTargetCountryGrid(selected) {
1514
- const grid = document.getElementById("targetCountryGrid");
1572
+ function renderTargetLocationsGrid() {
1573
+ const grid = document.getElementById("targetLocationsGrid");
1515
1574
  if (!grid) return;
1516
- const selectedSet = new Set(selected || []);
1517
- grid.innerHTML = AVAILABLE_COUNTRIES.map(
1518
- (loc) =>
1519
- `<button type="button" class="loc-option ${selectedSet.has(loc) ? "active" : ""}" onclick="toggleTargetCountry('${loc}')">${loc}</button>`,
1520
- ).join("");
1575
+ const chips = _targetLocations
1576
+ .map(
1577
+ (loc, i) => `
1578
+ <div class="loc-chip" draggable="true" data-loc="${loc}" data-idx="${i}" title="拖动排序(位置即优先级)">
1579
+ <span class="loc-chip-idx">${i + 1}</span>
1580
+ <span class="loc-chip-flag">${countryFlag(loc)}</span>
1581
+ <span class="loc-chip-code">${loc}</span>
1582
+ <button type="button" class="loc-chip-del" onclick="removeTargetLocation('${loc}')" title="移除 ${loc}">×</button>
1583
+ </div>`,
1584
+ )
1585
+ .join("");
1586
+ grid.innerHTML =
1587
+ chips +
1588
+ `<button type="button" class="loc-chip loc-chip-add" onclick="toggleCountryPicker()" title="添加国家">+</button>`;
1589
+ bindLocChipDrag(grid);
1590
+ renderCountryPicker();
1591
+ }
1592
+
1593
+ // ---- 拖拽排序(HTML5 DnD) ----
1594
+ let _dragLoc = null;
1595
+
1596
+ function bindLocChipDrag(grid) {
1597
+ grid.querySelectorAll(".loc-chip[draggable]").forEach((chip) => {
1598
+ chip.addEventListener("dragstart", (e) => {
1599
+ _dragLoc = chip.dataset.loc;
1600
+ chip.classList.add("dragging");
1601
+ e.dataTransfer.effectAllowed = "move";
1602
+ e.dataTransfer.setData("text/plain", _dragLoc);
1603
+ });
1604
+ chip.addEventListener("dragend", () => {
1605
+ _dragLoc = null;
1606
+ chip.classList.remove("dragging");
1607
+ grid
1608
+ .querySelectorAll(".drop-before,.drop-after")
1609
+ .forEach((el) => el.classList.remove("drop-before", "drop-after"));
1610
+ });
1611
+ chip.addEventListener("dragover", (e) => {
1612
+ e.preventDefault();
1613
+ e.dataTransfer.dropEffect = "move";
1614
+ if (_dragLoc == null || chip.dataset.loc === _dragLoc) return;
1615
+ const rect = chip.getBoundingClientRect();
1616
+ const before = e.clientX < rect.left + rect.width / 2;
1617
+ chip.classList.toggle("drop-before", before);
1618
+ chip.classList.toggle("drop-after", !before);
1619
+ });
1620
+ chip.addEventListener("dragleave", () => {
1621
+ chip.classList.remove("drop-before", "drop-after");
1622
+ });
1623
+ chip.addEventListener("drop", (e) => {
1624
+ e.preventDefault();
1625
+ if (_dragLoc == null || chip.dataset.loc === _dragLoc) return;
1626
+ const rect = chip.getBoundingClientRect();
1627
+ const before = e.clientX < rect.left + rect.width / 2;
1628
+ moveTargetLocation(
1629
+ _dragLoc,
1630
+ Number(chip.dataset.idx) + (before ? 0 : 1),
1631
+ );
1632
+ });
1633
+ });
1634
+ }
1635
+
1636
+ /** 把 fromLoc 移动到目标插入位(insertAt 为移除前语义下的目标下标) */
1637
+ function moveTargetLocation(fromLoc, insertAt) {
1638
+ const fromIdx = _targetLocations.indexOf(fromLoc);
1639
+ if (fromIdx === -1) return;
1640
+ _targetLocations.splice(fromIdx, 1);
1641
+ const target = Math.max(0, Math.min(insertAt, _targetLocations.length));
1642
+ _targetLocations.splice(target, 0, fromLoc);
1643
+ renderTargetLocationsGrid();
1644
+ updateTargetLocationsStatus();
1645
+ }
1646
+
1647
+ // ---- 添加 / 删除 ----
1648
+ function removeTargetLocation(loc) {
1649
+ _targetLocations = _targetLocations.filter((l) => l !== loc);
1650
+ renderTargetLocationsGrid();
1651
+ updateTargetLocationsStatus();
1652
+ }
1653
+
1654
+ function addTargetLocation(locRaw) {
1655
+ const loc = String(locRaw || "").trim().toUpperCase();
1656
+ if (!/^[A-Z]{2}$/.test(loc)) return false;
1657
+ if (_targetLocations.includes(loc)) return false;
1658
+ _targetLocations.push(loc); // 新增排最后(优先级最低),可拖动调整
1659
+ renderTargetLocationsGrid();
1660
+ updateTargetLocationsStatus();
1661
+ return true;
1662
+ }
1663
+
1664
+ // ---- 添加面板(+展开) ----
1665
+ function toggleCountryPicker() {
1666
+ const picker = document.getElementById("countryPicker");
1667
+ if (!picker) return;
1668
+ picker.style.display = picker.style.display === "none" ? "block" : "none";
1669
+ renderCountryPicker();
1670
+ }
1671
+
1672
+ function renderCountryPicker() {
1673
+ const picker = document.getElementById("countryPicker");
1674
+ if (!picker || picker.style.display === "none") return;
1675
+ const selected = new Set(_targetLocations);
1676
+ const grid = document.getElementById("countryPickerGrid");
1677
+ if (grid) {
1678
+ grid.innerHTML = AVAILABLE_COUNTRIES.filter(
1679
+ (loc) => !selected.has(loc),
1680
+ )
1681
+ .map(
1682
+ (loc) =>
1683
+ `<button type="button" class="loc-option" onclick="addTargetLocation('${loc}')">${countryFlag(loc)} ${loc}</button>`,
1684
+ )
1685
+ .join("");
1686
+ }
1521
1687
  }
1522
1688
 
1523
- function toggleTargetCountry(loc) {
1524
- const input = document.getElementById("targetLocationsInput");
1689
+ function addCustomCountry() {
1690
+ const input = document.getElementById("customCountryInput");
1525
1691
  if (!input) return;
1526
- let current = normalizeCountryInput(input.value);
1527
- if (current.includes(loc)) {
1528
- current = current.filter((l) => l !== loc);
1692
+ const ok = addTargetLocation(input.value);
1693
+ if (ok) {
1694
+ input.value = "";
1695
+ input.focus();
1529
1696
  } else {
1530
- current.push(loc);
1697
+ input.placeholder = "无效或已存在(两位大写字母)";
1698
+ setTimeout(() => (input.placeholder = "其他代码,如 GB"), 2000);
1531
1699
  }
1532
- input.value = current.join(",");
1533
- renderTargetCountryGrid(current);
1700
+ renderCountryPicker();
1534
1701
  }
1535
1702
 
1703
+ // ---- 保存 / 恢复默认 ----
1536
1704
  async function saveTargetLocations() {
1537
- const input = document.getElementById("targetLocationsInput");
1538
1705
  const status = document.getElementById("targetLocationsStatus");
1539
- if (!input) return;
1540
- const locations = normalizeCountryInput(input.value);
1706
+ if (!status) return;
1541
1707
  status.textContent = "保存中...";
1542
1708
  status.className = "settings-status loading";
1543
1709
  try {
1544
1710
  const res = await fetch("/api/settings/target-locations", {
1545
1711
  method: "POST",
1546
1712
  headers: { "Content-Type": "application/json" },
1547
- body: JSON.stringify({ locations }),
1713
+ body: JSON.stringify({ locations: _targetLocations }),
1548
1714
  });
1549
1715
  const data = await res.json();
1550
- input.value = (data.locations || []).join(",");
1551
- renderTargetCountryGrid(data.locations || []);
1552
- status.textContent = `已保存 ${(data.locations || []).length} 个国家`;
1553
- status.className = "settings-status ok";
1716
+ _targetLocations = (data.locations || []).map(String);
1717
+ _targetLocationsSaved = [..._targetLocations];
1718
+ renderTargetLocationsGrid();
1719
+ updateTargetLocationsStatus();
1554
1720
  } catch (e) {
1555
1721
  console.error("saveTargetLocations error:", e);
1556
1722
  status.textContent = "保存失败";
@@ -1571,13 +1737,10 @@ async function resetTargetLocations() {
1571
1737
  body: JSON.stringify({ locations: [] }),
1572
1738
  });
1573
1739
  const data = await res.json();
1574
- const input = document.getElementById("targetLocationsInput");
1575
- if (input) input.value = (data.locations || []).join(",");
1576
- renderTargetCountryGrid(data.locations || []);
1577
- if (status) {
1578
- status.textContent = `已恢复默认 ${(data.locations || []).length} 个国家`;
1579
- status.className = "settings-status ok";
1580
- }
1740
+ _targetLocations = (data.locations || []).map(String);
1741
+ _targetLocationsSaved = [..._targetLocations];
1742
+ renderTargetLocationsGrid();
1743
+ updateTargetLocationsStatus();
1581
1744
  } catch (e) {
1582
1745
  console.error("resetTargetLocations error:", e);
1583
1746
  if (status) {
@@ -555,6 +555,13 @@
555
555
  <div class="label">← 返回主页面</div>
556
556
  </div>
557
557
  </div>
558
+ <div class="settings-layout">
559
+ <div class="settings-nav" id="settingsNav">
560
+ <button type="button" class="settings-nav-item active" data-panel="llm" onclick="switchSettingsPanel('llm')">⚙️ LLM 模型</button>
561
+ <button type="button" class="settings-nav-item" data-panel="countries" onclick="switchSettingsPanel('countries')">🎯 目标国家</button>
562
+ </div>
563
+ <div class="settings-panels">
564
+ <div class="settings-panel active" id="settingsPanel-llm">
558
565
  <div class="settings-card">
559
566
  <h3>⚙️ LLM 模型配置</h3>
560
567
  <p class="settings-hint">切换后立即生效,无需重启服务。配置保存在数据库中。</p>
@@ -586,18 +593,23 @@
586
593
  </div>
587
594
  </div>
588
595
  </div>
596
+ </div>
597
+ <div class="settings-panel" id="settingsPanel-countries">
589
598
  <div class="settings-card">
590
599
  <h3>🎯 目标国家</h3>
591
- <p class="settings-hint">设置采集的目标国家列表,保存后写入通用配置文件(~/.tt-help-config.json)。当前仅保存配置,后续接入采集流程后生效。</p>
600
+ <p class="settings-hint">设置采集的目标国家列表及领取优先级,保存后写入通用配置文件(~/.tt-help-config.json),立即生效。列表顺序即任务领取优先级:越靠前越先领取。</p>
592
601
  <div class="settings-form">
593
602
  <div class="settings-field">
594
- <label>目标国家(英文国家代码,逗号分隔,如 CZ,GR,TH)</label>
595
- <input type="text" id="targetLocationsInput" placeholder="CZ,GR,HU,PT,ES,PL,NL,BE,DE,FR,IT,IE,AT,TH,US">
596
- <span class="settings-note">可手动输入,或点击下方国家快速选择。</span>
597
- </div>
598
- <div class="settings-field">
599
- <label>快速选择</label>
600
- <div class="loc-grid" id="targetCountryGrid"></div>
603
+ <label>目标国家(拖动卡片排序,位置即领取优先级:越靠前越先领取)</label>
604
+ <div class="loc-chips" id="targetLocationsGrid"></div>
605
+ <div class="country-picker" id="countryPicker" style="display:none">
606
+ <div class="country-picker-title">选择要添加的国家(新加的排最后,可拖动调整优先级)</div>
607
+ <div class="loc-grid" id="countryPickerGrid"></div>
608
+ <div class="custom-loc-row">
609
+ <input type="text" id="customCountryInput" class="custom-loc-input" placeholder="其他代码,如 GB" maxlength="2" onkeydown="if(event.key==='Enter')addCustomCountry()">
610
+ <button type="button" class="btn-submit" onclick="addCustomCountry()">添加</button>
611
+ </div>
612
+ </div>
601
613
  </div>
602
614
  <div class="settings-status" id="targetLocationsStatus"></div>
603
615
  <div class="btn-row">
@@ -606,6 +618,9 @@
606
618
  </div>
607
619
  </div>
608
620
  </div>
621
+ </div>
622
+ </div>
623
+ </div>
609
624
  </div>
610
625
  <div id="watchdogPage">
611
626
  <div class="stats" style="margin-bottom:16px">
@@ -1703,6 +1703,197 @@ td.user-id:hover {
1703
1703
  display: block;
1704
1704
  }
1705
1705
 
1706
+ .settings-layout {
1707
+ display: flex;
1708
+ gap: 20px;
1709
+ max-width: 860px;
1710
+ margin: 0 auto;
1711
+ align-items: flex-start;
1712
+ }
1713
+
1714
+ .settings-nav {
1715
+ width: 170px;
1716
+ flex-shrink: 0;
1717
+ display: flex;
1718
+ flex-direction: column;
1719
+ gap: 6px;
1720
+ position: sticky;
1721
+ top: 16px;
1722
+ }
1723
+
1724
+ .settings-nav-item {
1725
+ text-align: left;
1726
+ padding: 10px 14px;
1727
+ background: #1a1a24;
1728
+ border: 1px solid transparent;
1729
+ border-radius: 10px;
1730
+ color: #999;
1731
+ cursor: pointer;
1732
+ font-size: 13px;
1733
+ transition:
1734
+ color 0.15s,
1735
+ background 0.15s,
1736
+ border-color 0.15s;
1737
+ }
1738
+
1739
+ .settings-nav-item:hover {
1740
+ color: #ccc;
1741
+ }
1742
+
1743
+ .settings-nav-item.active {
1744
+ color: #60a5fa;
1745
+ border-color: rgba(96, 165, 250, 0.4);
1746
+ background: rgba(96, 165, 250, 0.08);
1747
+ }
1748
+
1749
+ .settings-panels {
1750
+ flex: 1;
1751
+ min-width: 0;
1752
+ }
1753
+
1754
+ .settings-panel {
1755
+ display: none;
1756
+ }
1757
+
1758
+ .settings-panel.active {
1759
+ display: block;
1760
+ }
1761
+
1762
+ .settings-panel .settings-card {
1763
+ margin: 0;
1764
+ }
1765
+
1766
+ @media (max-width: 720px) {
1767
+ .settings-layout {
1768
+ flex-direction: column;
1769
+ }
1770
+ .settings-nav {
1771
+ width: 100%;
1772
+ flex-direction: row;
1773
+ flex-wrap: wrap;
1774
+ position: static;
1775
+ }
1776
+ }
1777
+
1778
+ /* ---- 目标国家宫格(拖拽排序) ---- */
1779
+ .loc-chips {
1780
+ display: flex;
1781
+ flex-wrap: wrap;
1782
+ gap: 10px;
1783
+ }
1784
+
1785
+ .loc-chip {
1786
+ display: flex;
1787
+ align-items: center;
1788
+ gap: 6px;
1789
+ padding: 8px 10px;
1790
+ background: #1a1a24;
1791
+ border: 1px solid #333;
1792
+ border-radius: 10px;
1793
+ cursor: grab;
1794
+ user-select: none;
1795
+ font-size: 13px;
1796
+ color: #e0e0e0;
1797
+ }
1798
+
1799
+ .loc-chip:active {
1800
+ cursor: grabbing;
1801
+ }
1802
+
1803
+ .loc-chip.dragging {
1804
+ opacity: 0.4;
1805
+ cursor: grabbing;
1806
+ }
1807
+
1808
+ .loc-chip.drop-before {
1809
+ box-shadow: -3px 0 0 0 #60a5fa;
1810
+ }
1811
+
1812
+ .loc-chip.drop-after {
1813
+ box-shadow: 3px 0 0 0 #60a5fa;
1814
+ }
1815
+
1816
+ .loc-chip-idx {
1817
+ min-width: 18px;
1818
+ height: 18px;
1819
+ display: inline-flex;
1820
+ align-items: center;
1821
+ justify-content: center;
1822
+ background: rgba(96, 165, 250, 0.15);
1823
+ color: #60a5fa;
1824
+ border-radius: 5px;
1825
+ font-size: 11px;
1826
+ font-weight: 600;
1827
+ }
1828
+
1829
+ .loc-chip-flag {
1830
+ font-size: 15px;
1831
+ }
1832
+
1833
+ .loc-chip-code {
1834
+ font-weight: 600;
1835
+ letter-spacing: 0.5px;
1836
+ }
1837
+
1838
+ .loc-chip-del {
1839
+ background: none;
1840
+ border: none;
1841
+ color: #666;
1842
+ font-size: 14px;
1843
+ cursor: pointer;
1844
+ padding: 0 2px;
1845
+ line-height: 1;
1846
+ }
1847
+
1848
+ .loc-chip-del:hover {
1849
+ color: #f87171;
1850
+ }
1851
+
1852
+ .loc-chip-add {
1853
+ border: 1px dashed #555;
1854
+ color: #888;
1855
+ background: transparent;
1856
+ font-size: 16px;
1857
+ cursor: pointer;
1858
+ padding: 8px 14px;
1859
+ }
1860
+
1861
+ .loc-chip-add:hover {
1862
+ border-color: #60a5fa;
1863
+ color: #60a5fa;
1864
+ }
1865
+
1866
+ .country-picker {
1867
+ margin-top: 12px;
1868
+ padding: 14px;
1869
+ background: #14141c;
1870
+ border: 1px solid #2a2a38;
1871
+ border-radius: 10px;
1872
+ }
1873
+
1874
+ .country-picker-title {
1875
+ font-size: 12px;
1876
+ color: #888;
1877
+ margin-bottom: 10px;
1878
+ }
1879
+
1880
+ .country-picker .custom-loc-row {
1881
+ display: flex;
1882
+ gap: 8px;
1883
+ margin-top: 12px;
1884
+ }
1885
+
1886
+ .country-picker .custom-loc-input {
1887
+ flex: 1;
1888
+ padding: 8px 10px;
1889
+ background: #111;
1890
+ border: 1px solid #333;
1891
+ color: #e0e0e0;
1892
+ border-radius: 6px;
1893
+ font-size: 12px;
1894
+ text-transform: uppercase;
1895
+ }
1896
+
1706
1897
  .settings-card {
1707
1898
  background: #1a1a24;
1708
1899
  border-radius: 12px;
@@ -2169,7 +2169,7 @@ export function startWatchServer(
2169
2169
  }
2170
2170
  const targetCountries = Array.isArray(body.countries)
2171
2171
  ? body.countries
2172
- : ["ES"];
2172
+ : getTargetLocations();
2173
2173
  const serverUrl = body.serverUrl || null;
2174
2174
 
2175
2175
  const { scoreTag } = await import("./tag-service.js");
@@ -2192,7 +2192,7 @@ export function startWatchServer(
2192
2192
  const limit = Math.min(parseInt(body.limit) || 10, 50);
2193
2193
  const targetCountries = Array.isArray(body.countries)
2194
2194
  ? body.countries
2195
- : ["ES"];
2195
+ : getTargetLocations();
2196
2196
  const serverUrl = body.serverUrl || null;
2197
2197
 
2198
2198
  const pendingTags = store.getTagsByStatus("new", limit);