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 +1 -1
- package/scripts/add-claim-index.mjs +28 -0
- package/scripts/checkpoint-wal.mjs +20 -0
- package/src/lib/app-config.js +15 -1
- package/src/lib/browser/cdp.js +2 -0
- package/src/lib/browser/page.js +14 -0
- package/src/lib/target-locations.js +8 -5
- package/src/watch/data-store.js +69 -110
- package/src/watch/db-schema.js +14 -5
- package/src/watch/public/app.js +210 -47
- package/src/watch/public/index.html +23 -8
- package/src/watch/public/style.css +191 -0
- package/src/watch/server.js +2 -2
package/package.json
CHANGED
|
@@ -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();
|
package/src/lib/app-config.js
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -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}`);
|
package/src/lib/browser/page.js
CHANGED
|
@@ -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(",");
|
package/src/watch/data-store.js
CHANGED
|
@@ -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
|
-
|
|
929
|
-
const tier2LocSet = new Set(["DE", "FR", "IT", "IE", "ES"]);
|
|
944
|
+
// 优先级 = 目标国家配置顺序(越靠前越优先),空/配置外国家排最后
|
|
930
945
|
function locationTier(u) {
|
|
931
|
-
|
|
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
|
-
// ----
|
|
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
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
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
|
|
1362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
|
1423
|
-
const loc = tl.fn(tl.list);
|
|
1411
|
+
for (const loc of locationBranches) {
|
|
1424
1412
|
if (!loc) continue;
|
|
1425
1413
|
// hasVideo
|
|
1426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
|
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
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
|
package/src/watch/db-schema.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/watch/public/app.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
|
1514
|
-
const grid = document.getElementById("
|
|
1572
|
+
function renderTargetLocationsGrid() {
|
|
1573
|
+
const grid = document.getElementById("targetLocationsGrid");
|
|
1515
1574
|
if (!grid) return;
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
|
1524
|
-
const input = document.getElementById("
|
|
1689
|
+
function addCustomCountry() {
|
|
1690
|
+
const input = document.getElementById("customCountryInput");
|
|
1525
1691
|
if (!input) return;
|
|
1526
|
-
|
|
1527
|
-
if (
|
|
1528
|
-
|
|
1692
|
+
const ok = addTargetLocation(input.value);
|
|
1693
|
+
if (ok) {
|
|
1694
|
+
input.value = "";
|
|
1695
|
+
input.focus();
|
|
1529
1696
|
} else {
|
|
1530
|
-
|
|
1697
|
+
input.placeholder = "无效或已存在(两位大写字母)";
|
|
1698
|
+
setTimeout(() => (input.placeholder = "其他代码,如 GB"), 2000);
|
|
1531
1699
|
}
|
|
1532
|
-
|
|
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 (!
|
|
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
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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"
|
|
600
|
+
<p class="settings-hint">设置采集的目标国家列表及领取优先级,保存后写入通用配置文件(~/.tt-help-config.json),立即生效。列表顺序即任务领取优先级:越靠前越先领取。</p>
|
|
592
601
|
<div class="settings-form">
|
|
593
602
|
<div class="settings-field">
|
|
594
|
-
<label
|
|
595
|
-
<
|
|
596
|
-
<
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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;
|
package/src/watch/server.js
CHANGED
|
@@ -2169,7 +2169,7 @@ export function startWatchServer(
|
|
|
2169
2169
|
}
|
|
2170
2170
|
const targetCountries = Array.isArray(body.countries)
|
|
2171
2171
|
? body.countries
|
|
2172
|
-
:
|
|
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
|
-
:
|
|
2195
|
+
: getTargetLocations();
|
|
2196
2196
|
const serverUrl = body.serverUrl || null;
|
|
2197
2197
|
|
|
2198
2198
|
const pendingTags = store.getTagsByStatus("new", limit);
|