tt-help-cli-ycl 1.4.18 → 1.4.19
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/src/lib/args.js +12 -7
- package/src/watch/data-store.js +117 -8
- package/src/watch/public/app.js +247 -1
- package/src/watch/public/index.html +2 -0
- package/src/watch/public/style.css +11 -0
- package/src/watch/server.js +53 -0
package/package.json
CHANGED
package/src/lib/args.js
CHANGED
|
@@ -767,6 +767,8 @@ function parseTagArgs(args) {
|
|
|
767
767
|
let scoreTag = null;
|
|
768
768
|
let scoreCountries = null;
|
|
769
769
|
let scorePort = 9222;
|
|
770
|
+
// 延迟路由:--port 的值先暂存,等循环结束后根据已确定的 subcommand 分配
|
|
771
|
+
let rawTagPort = null;
|
|
770
772
|
|
|
771
773
|
for (let i = 0; i < args.length; i++) {
|
|
772
774
|
const arg = args[i];
|
|
@@ -808,13 +810,9 @@ function parseTagArgs(args) {
|
|
|
808
810
|
.map((s) => s.trim().toUpperCase())
|
|
809
811
|
.filter(Boolean);
|
|
810
812
|
} else if (arg === "--port") {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
refreshPort = parseInt(args[++i]) || 8222;
|
|
815
|
-
} else {
|
|
816
|
-
scorePort = parseInt(args[++i]) || 9222;
|
|
817
|
-
}
|
|
813
|
+
// 延迟路由:暂存端口值,等循环结束后根据 subcommand 分配
|
|
814
|
+
// 避免 --port 出现在 refresh/score-all 之前时被错误分配到其他变量
|
|
815
|
+
rawTagPort = parseInt(args[++i]);
|
|
818
816
|
} else if (arg === "--proxy") {
|
|
819
817
|
scoreProxy = args[++i];
|
|
820
818
|
} else if (arg === "-p" || arg === "--prompt") {
|
|
@@ -852,6 +850,13 @@ function parseTagArgs(args) {
|
|
|
852
850
|
}
|
|
853
851
|
}
|
|
854
852
|
|
|
853
|
+
// ── 延迟路由:将 --port 值分配到正确的 subcommand 变量 ──
|
|
854
|
+
if (rawTagPort !== null && !isNaN(rawTagPort)) {
|
|
855
|
+
if (isScoreAll) scoreAllPort = rawTagPort;
|
|
856
|
+
else if (isRefresh) refreshPort = rawTagPort;
|
|
857
|
+
else scorePort = rawTagPort;
|
|
858
|
+
}
|
|
859
|
+
|
|
855
860
|
if (isDiscover) {
|
|
856
861
|
return {
|
|
857
862
|
subcommand: "tag-discover",
|
package/src/watch/data-store.js
CHANGED
|
@@ -556,6 +556,64 @@ function getModifiedTargetUsersOutsideLocations(targetLocations = []) {
|
|
|
556
556
|
return { total: rows.length, countries };
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* 目标商家刷新进度统计:按国家统计最近 N 小时内刷新过的 tt_seller 用户数。
|
|
561
|
+
* 返回格式与 getRefreshStats(tags)一致,便于前端复用渲染逻辑。
|
|
562
|
+
*/
|
|
563
|
+
function getTargetRefreshStats(hours = 24) {
|
|
564
|
+
if (!getDb()) return { countries: [], total: 0, refreshed: 0 };
|
|
565
|
+
|
|
566
|
+
const targetLocations = DEFAULT_TARGET_LOCATIONS;
|
|
567
|
+
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
568
|
+
|
|
569
|
+
// 按国家统计总数
|
|
570
|
+
const totalRows = getDb()
|
|
571
|
+
.prepare(
|
|
572
|
+
`
|
|
573
|
+
SELECT location_created as country, COUNT(*) as cnt
|
|
574
|
+
FROM jobs
|
|
575
|
+
WHERE tt_seller = 1
|
|
576
|
+
AND verified = 0
|
|
577
|
+
AND location_created IN (${placeholders})
|
|
578
|
+
GROUP BY location_created
|
|
579
|
+
ORDER BY cnt DESC
|
|
580
|
+
`,
|
|
581
|
+
)
|
|
582
|
+
.all(...targetLocations);
|
|
583
|
+
|
|
584
|
+
if (!totalRows.length) return { countries: [], total: 0, refreshed: 0 };
|
|
585
|
+
|
|
586
|
+
// 按国家统计最近 N 小时内刷新过的数量
|
|
587
|
+
const threshold = Date.now() - hours * 3600 * 1000;
|
|
588
|
+
const refreshedRows = getDb()
|
|
589
|
+
.prepare(
|
|
590
|
+
`
|
|
591
|
+
SELECT location_created as country, COUNT(*) as cnt
|
|
592
|
+
FROM jobs
|
|
593
|
+
WHERE tt_seller = 1
|
|
594
|
+
AND verified = 0
|
|
595
|
+
AND location_created IN (${placeholders})
|
|
596
|
+
AND refresh_time >= ?
|
|
597
|
+
GROUP BY location_created
|
|
598
|
+
`,
|
|
599
|
+
)
|
|
600
|
+
.all(...targetLocations, threshold);
|
|
601
|
+
const refreshedMap = new Map();
|
|
602
|
+
for (const r of refreshedRows) refreshedMap.set(r.country, r.cnt);
|
|
603
|
+
|
|
604
|
+
const results = [];
|
|
605
|
+
let totalAll = 0;
|
|
606
|
+
let refreshedAll = 0;
|
|
607
|
+
for (const r of totalRows) {
|
|
608
|
+
const refreshed = refreshedMap.get(r.country) || 0;
|
|
609
|
+
totalAll += r.cnt;
|
|
610
|
+
refreshedAll += refreshed;
|
|
611
|
+
results.push({ country: r.country, total: r.cnt, refreshed });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return { countries: results, total: totalAll, refreshed: refreshedAll };
|
|
615
|
+
}
|
|
616
|
+
|
|
559
617
|
export function createStore(filePath, options = {}) {
|
|
560
618
|
if (!filePath) {
|
|
561
619
|
throw new Error("createStore requires an explicit .db path");
|
|
@@ -1008,7 +1066,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1008
1066
|
|
|
1009
1067
|
if (normalizedLocations && normalizedLocations.length > 0) {
|
|
1010
1068
|
conditions.push(
|
|
1011
|
-
`UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`,
|
|
1069
|
+
`(UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
|
|
1012
1070
|
);
|
|
1013
1071
|
args.push(...normalizedLocations);
|
|
1014
1072
|
}
|
|
@@ -1164,7 +1222,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1164
1222
|
const llmCount =
|
|
1165
1223
|
getDb()
|
|
1166
1224
|
.prepare(
|
|
1167
|
-
`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND guessed_location = ? AND (sources NOT LIKE '%tag%' OR sources IS NULL) AND (sources NOT LIKE '%comment%' OR sources IS NULL)`,
|
|
1225
|
+
`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql} AND (guessed_location = ? OR COALESCE(guessed_location, '') = '') AND (sources NOT LIKE '%tag%' OR sources IS NULL) AND (sources NOT LIKE '%comment%' OR sources IS NULL)`,
|
|
1168
1226
|
)
|
|
1169
1227
|
.get(...locationArgs)?.c || 0;
|
|
1170
1228
|
|
|
@@ -1190,7 +1248,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1190
1248
|
const samples = getDb()
|
|
1191
1249
|
.prepare(
|
|
1192
1250
|
`
|
|
1193
|
-
SELECT * FROM raw_jobs WHERE ${whereSql} AND guessed_location = ?
|
|
1251
|
+
SELECT * FROM raw_jobs WHERE ${whereSql} AND (guessed_location = ? OR COALESCE(guessed_location, '') = '')
|
|
1194
1252
|
AND (sources NOT LIKE '%tag%' OR sources IS NULL)
|
|
1195
1253
|
AND (sources NOT LIKE '%comment%' OR sources IS NULL)
|
|
1196
1254
|
ORDER BY COALESCE(video_count, 0) DESC, created_at DESC
|
|
@@ -1361,7 +1419,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1361
1419
|
const currentLimit = Math.max(0, Math.min(quota, safeLimit - moved));
|
|
1362
1420
|
if (!currentLimit) continue;
|
|
1363
1421
|
|
|
1364
|
-
const locationWhere = `${whereSql} AND UPPER(COALESCE(guessed_location, '')) =
|
|
1422
|
+
const locationWhere = `${whereSql} AND (UPPER(COALESCE(guessed_location, '')) = ? OR COALESCE(guessed_location, '') = '')`;
|
|
1365
1423
|
const locationArgs = [...args, location];
|
|
1366
1424
|
|
|
1367
1425
|
getDb()
|
|
@@ -1488,7 +1546,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1488
1546
|
if (!group) return;
|
|
1489
1547
|
if (group.type === "include") {
|
|
1490
1548
|
where.push(
|
|
1491
|
-
`UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")})`,
|
|
1549
|
+
`(UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
|
|
1492
1550
|
);
|
|
1493
1551
|
args.push(...group.values);
|
|
1494
1552
|
return;
|
|
@@ -1559,7 +1617,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1559
1617
|
}
|
|
1560
1618
|
if (normalizedLocations.length > 0) {
|
|
1561
1619
|
where.push(
|
|
1562
|
-
`UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`,
|
|
1620
|
+
`(UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
|
|
1563
1621
|
);
|
|
1564
1622
|
args.push(...normalizedLocations);
|
|
1565
1623
|
}
|
|
@@ -1957,7 +2015,7 @@ export function createStore(filePath, options = {}) {
|
|
|
1957
2015
|
if (!group) return;
|
|
1958
2016
|
if (group.type === "include") {
|
|
1959
2017
|
where.push(
|
|
1960
|
-
`UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")})`,
|
|
2018
|
+
`(UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
|
|
1961
2019
|
);
|
|
1962
2020
|
args.push(...group.values);
|
|
1963
2021
|
return;
|
|
@@ -2025,7 +2083,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2025
2083
|
}
|
|
2026
2084
|
if (normalizedLocations.length > 0) {
|
|
2027
2085
|
where.push(
|
|
2028
|
-
`UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`,
|
|
2086
|
+
`(UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
|
|
2029
2087
|
);
|
|
2030
2088
|
args.push(...normalizedLocations);
|
|
2031
2089
|
}
|
|
@@ -3375,6 +3433,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3375
3433
|
getActiveClients,
|
|
3376
3434
|
moveSellerJobsToBase, // 将 jobs/raw_jobs 中 tt_seller=1 且 video_count=0 的记录移动到 jobs_base
|
|
3377
3435
|
moveRestrictedJobsToBase, // 将 jobs 中 status='restricted' 的记录移动到 jobs_base
|
|
3436
|
+
moveNoCountryJobsToBase, // 将 jobs 中 status='done' 且无国家数据的记录移动到 jobs_base
|
|
3378
3437
|
moveSingleJobToBase, // 将单个 job 移动到 jobs_base 并重置(目标商家页面用)
|
|
3379
3438
|
registerVideos,
|
|
3380
3439
|
getVideo,
|
|
@@ -3404,6 +3463,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3404
3463
|
claimRefreshTag,
|
|
3405
3464
|
reportRefreshResult,
|
|
3406
3465
|
getRefreshStats,
|
|
3466
|
+
getTargetRefreshStats,
|
|
3407
3467
|
data,
|
|
3408
3468
|
};
|
|
3409
3469
|
|
|
@@ -3572,6 +3632,55 @@ export function createStore(filePath, options = {}) {
|
|
|
3572
3632
|
return { ok: true, moved: deleted.changes || 0 };
|
|
3573
3633
|
}
|
|
3574
3634
|
|
|
3635
|
+
// 将 jobs 中 status='done' 且无国家数据的记录移动到 jobs_base
|
|
3636
|
+
function moveNoCountryJobsToBase() {
|
|
3637
|
+
const db = getDb();
|
|
3638
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
3639
|
+
|
|
3640
|
+
const now = Date.now();
|
|
3641
|
+
const condition = `status = 'done'
|
|
3642
|
+
AND (guessed_location IS NULL OR guessed_location = '')
|
|
3643
|
+
AND (location_created IS NULL OR location_created = '')
|
|
3644
|
+
AND (confirmed_location IS NULL OR confirmed_location = '')`;
|
|
3645
|
+
|
|
3646
|
+
const countRow = db
|
|
3647
|
+
.prepare(`SELECT COUNT(*) as c FROM jobs WHERE ${condition}`)
|
|
3648
|
+
.get();
|
|
3649
|
+
if (!countRow?.c) return { ok: true, moved: 0 };
|
|
3650
|
+
|
|
3651
|
+
db.prepare(
|
|
3652
|
+
`
|
|
3653
|
+
INSERT OR REPLACE INTO jobs_base (
|
|
3654
|
+
unique_id, nickname, sources, pinned, no_video, user_update_count,
|
|
3655
|
+
tt_seller, verified, video_count, comment_count, guessed_location,
|
|
3656
|
+
location_created, follower_count, following_count, heart_count,
|
|
3657
|
+
refresh_time, region, signature, sec_uid, status_code,
|
|
3658
|
+
latest_video_time, bio_link, status, restricted, error, claimed_by,
|
|
3659
|
+
claimed_at, processed, processed_at, created_at, updated_at
|
|
3660
|
+
)
|
|
3661
|
+
SELECT
|
|
3662
|
+
unique_id, nickname, sources, pinned, no_video, 0,
|
|
3663
|
+
CASE WHEN tt_seller = 0 THEN NULL ELSE tt_seller END,
|
|
3664
|
+
verified, video_count, comment_count, guessed_location,
|
|
3665
|
+
location_created, follower_count, following_count, heart_count,
|
|
3666
|
+
refresh_time, region, signature, sec_uid, status_code,
|
|
3667
|
+
latest_video_time, bio_link,
|
|
3668
|
+
'pending', 0, NULL, NULL, NULL, 0, NULL,
|
|
3669
|
+
COALESCE(created_at, ?), ?
|
|
3670
|
+
FROM jobs WHERE ${condition}
|
|
3671
|
+
`,
|
|
3672
|
+
).run(now, now);
|
|
3673
|
+
|
|
3674
|
+
const deleted = db.prepare(`DELETE FROM jobs WHERE ${condition}`).run();
|
|
3675
|
+
|
|
3676
|
+
markStatsDirty();
|
|
3677
|
+
return {
|
|
3678
|
+
ok: true,
|
|
3679
|
+
moved: deleted.changes || 0,
|
|
3680
|
+
total: countRow.c,
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3575
3684
|
// 将单个 job 移动到 jobs_base(用于目标商家页面"移到base重走流程")
|
|
3576
3685
|
function moveSingleJobToBase(uniqueId) {
|
|
3577
3686
|
const db = getDb();
|
package/src/watch/public/app.js
CHANGED
|
@@ -21,6 +21,7 @@ let currentRawLocation = "";
|
|
|
21
21
|
let currentSort = { key: null, asc: true };
|
|
22
22
|
let prevStatValues = {};
|
|
23
23
|
let prevUserMap = {};
|
|
24
|
+
let prevTargetUsers = 0;
|
|
24
25
|
|
|
25
26
|
async function fetchStats() {
|
|
26
27
|
try {
|
|
@@ -164,6 +165,13 @@ function renderStats() {
|
|
|
164
165
|
statJobsBase.textContent = `jobs_base: ${formatStatNum(d.jobsBaseTotal || 0)}`;
|
|
165
166
|
flashEl("statError", d.errorUsers);
|
|
166
167
|
flashEl("statTarget", d.targetUsers, { full: true });
|
|
168
|
+
// 检测目标商家新增,放烟花(仅首页)
|
|
169
|
+
const hash = window.location.hash.replace(/^#/, "");
|
|
170
|
+
if (!hash && prevTargetUsers > 0 && d.targetUsers > prevTargetUsers) {
|
|
171
|
+
const diff = d.targetUsers - prevTargetUsers;
|
|
172
|
+
triggerFirework(diff);
|
|
173
|
+
}
|
|
174
|
+
prevTargetUsers = d.targetUsers || 0;
|
|
167
175
|
flashEl("statUserUpdateTasks", d.userUpdateTasks || 0, { full: true });
|
|
168
176
|
flashEl("statRawJobs", d.rawJobs || 0);
|
|
169
177
|
flashEl("statCommentTask", d.commentTaskCount || 0, { full: true });
|
|
@@ -1464,6 +1472,7 @@ let currentTagsSeq = 0;
|
|
|
1464
1472
|
const TAGS_PAGE_SIZE = 200;
|
|
1465
1473
|
let cachedTagCountries = [];
|
|
1466
1474
|
let currentRefreshHours = 24;
|
|
1475
|
+
let currentTargetRefreshHours = 24;
|
|
1467
1476
|
|
|
1468
1477
|
function showTagsPage() {
|
|
1469
1478
|
document.getElementById("mainPage").classList.add("hidden");
|
|
@@ -1801,6 +1810,111 @@ async function selectRefreshHours(hours) {
|
|
|
1801
1810
|
}
|
|
1802
1811
|
}
|
|
1803
1812
|
|
|
1813
|
+
// ===== 目标商家刷新进度(复用 tags 页面的 UI 模式)=====
|
|
1814
|
+
|
|
1815
|
+
async function loadTargetRefreshStats() {
|
|
1816
|
+
try {
|
|
1817
|
+
const res = await fetch(
|
|
1818
|
+
`/api/targets/refresh-stats?hours=${currentTargetRefreshHours}`,
|
|
1819
|
+
);
|
|
1820
|
+
const data = await res.json();
|
|
1821
|
+
renderTargetRefreshStats(data);
|
|
1822
|
+
} catch (e) {
|
|
1823
|
+
console.error("获取目标商家刷新统计失败:", e);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function renderTargetRefreshStats(data) {
|
|
1828
|
+
const el = document.getElementById("targetRefreshStats");
|
|
1829
|
+
if (!el || !data || !data.countries || data.countries.length === 0) {
|
|
1830
|
+
if (el) el.innerHTML = "";
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
const pct =
|
|
1834
|
+
data.total > 0 ? Math.round((data.refreshed / data.total) * 100) : 0;
|
|
1835
|
+
const barW = Math.min(pct, 100);
|
|
1836
|
+
|
|
1837
|
+
const HOUR_OPTIONS = [12, 24, 36, 48, 72];
|
|
1838
|
+
let html = `<div style="background:#1a1a2e;border:1px solid #2a2a3a;border-radius:10px;padding:16px">
|
|
1839
|
+
<div id="targetRefreshStatsHeader" style="display:flex;align-items:center;gap:16px;cursor:pointer;user-select:none">
|
|
1840
|
+
<span style="font-size:14px;font-weight:600;color:#e0e0e0;position:relative">
|
|
1841
|
+
📊 最近 <span id="targetRefreshHoursLabel" style="color:#60a5fa;cursor:pointer;text-decoration:underline dotted" onclick="event.stopPropagation();toggleTargetHoursDropdown(event)">${currentTargetRefreshHours}h</span> 刷新进度
|
|
1842
|
+
<div id="targetRefreshHoursDropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:#1e1e3a;border:1px solid #3b82f6;border-radius:8px;padding:4px 0;z-index:100;box-shadow:0 4px 16px rgba(0,0,0,0.5);min-width:60px">
|
|
1843
|
+
${HOUR_OPTIONS.map((h) => `<div class="hours-option" data-hours="${h}" style="padding:6px 14px;cursor:pointer;font-size:13px;color:${h === currentTargetRefreshHours ? "#60a5fa" : "#ccc"};font-weight:${h === currentTargetRefreshHours ? "600" : "400"};white-space:nowrap" onclick="event.stopPropagation();selectTargetRefreshHours(${h})">${h}h</div>`).join("")}
|
|
1844
|
+
</div>
|
|
1845
|
+
</span>
|
|
1846
|
+
<span style="font-size:13px;color:#9ca3af">${data.refreshed} / ${data.total}</span>
|
|
1847
|
+
<div style="flex:1;max-width:200px;height:8px;background:#2a2a3a;border-radius:4px;overflow:hidden">
|
|
1848
|
+
<div style="height:100%;width:${barW}%;background:${pct >= 50 ? "#22c55e" : "#facc15"};border-radius:4px;transition:width 0.3s"></div>
|
|
1849
|
+
</div>
|
|
1850
|
+
<span style="font-size:14px;font-weight:700;color:${pct >= 50 ? "#22c55e" : "#facc15"}">${pct}%</span>
|
|
1851
|
+
<span id="targetRefreshStatsArrow" style="color:#888;font-size:12px">▶</span>
|
|
1852
|
+
</div>
|
|
1853
|
+
<div id="targetRefreshStatsBody" style="display:none;margin-top:12px">
|
|
1854
|
+
<table style="width:100%;border-collapse:collapse;font-size:12px">
|
|
1855
|
+
<thead><tr style="color:#888;text-align:left">
|
|
1856
|
+
<th style="padding:4px 8px">国家</th>
|
|
1857
|
+
<th style="padding:4px 8px;text-align:right">商家数</th>
|
|
1858
|
+
<th style="padding:4px 8px;text-align:right">${currentTargetRefreshHours}h已刷</th>
|
|
1859
|
+
<th style="padding:4px 8px;width:120px">进度</th>
|
|
1860
|
+
</tr></thead>
|
|
1861
|
+
<tbody>`;
|
|
1862
|
+
|
|
1863
|
+
for (const c of data.countries) {
|
|
1864
|
+
const cpct = c.total > 0 ? Math.round((c.refreshed / c.total) * 100) : 0;
|
|
1865
|
+
const cBarW = Math.min(cpct, 100);
|
|
1866
|
+
html += `<tr style="color:#ccc;border-top:1px solid #222">
|
|
1867
|
+
<td style="padding:4px 8px">${c.country}</td>
|
|
1868
|
+
<td style="padding:4px 8px;text-align:right">${c.total}</td>
|
|
1869
|
+
<td style="padding:4px 8px;text-align:right;color:${cpct >= 50 ? "#22c55e" : "#facc15"}">${c.refreshed}</td>
|
|
1870
|
+
<td style="padding:4px 8px">
|
|
1871
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
1872
|
+
<div style="flex:1;height:6px;background:#2a2a3a;border-radius:3px;overflow:hidden">
|
|
1873
|
+
<div style="height:100%;width:${cBarW}%;background:${cpct >= 50 ? "#22c55e" : "#facc15"};border-radius:3px"></div>
|
|
1874
|
+
</div>
|
|
1875
|
+
<span style="font-size:11px;color:#9ca3af;min-width:28px;text-align:right">${cpct}%</span>
|
|
1876
|
+
</div>
|
|
1877
|
+
</td>
|
|
1878
|
+
</tr>`;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
html += `</tbody></table></div></div>`;
|
|
1882
|
+
el.innerHTML = html;
|
|
1883
|
+
|
|
1884
|
+
// 绑定展开/折叠
|
|
1885
|
+
const header = document.getElementById("targetRefreshStatsHeader");
|
|
1886
|
+
const body = document.getElementById("targetRefreshStatsBody");
|
|
1887
|
+
const arrow = document.getElementById("targetRefreshStatsArrow");
|
|
1888
|
+
if (header && body && arrow) {
|
|
1889
|
+
header.addEventListener("click", () => {
|
|
1890
|
+
const open = body.style.display !== "none";
|
|
1891
|
+
body.style.display = open ? "none" : "";
|
|
1892
|
+
arrow.textContent = open ? "▶" : "▼";
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function toggleTargetHoursDropdown(event) {
|
|
1898
|
+
const dropdown = document.getElementById("targetRefreshHoursDropdown");
|
|
1899
|
+
if (!dropdown) return;
|
|
1900
|
+
const isOpen = dropdown.style.display !== "none";
|
|
1901
|
+
dropdown.style.display = isOpen ? "none" : "block";
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
async function selectTargetRefreshHours(hours) {
|
|
1905
|
+
const dropdown = document.getElementById("targetRefreshHoursDropdown");
|
|
1906
|
+
if (dropdown) dropdown.style.display = "none";
|
|
1907
|
+
if (hours === currentTargetRefreshHours) return;
|
|
1908
|
+
currentTargetRefreshHours = hours;
|
|
1909
|
+
try {
|
|
1910
|
+
await loadTargetRefreshStats();
|
|
1911
|
+
const label = document.getElementById("targetRefreshHoursLabel");
|
|
1912
|
+
if (label) label.textContent = hours + "h";
|
|
1913
|
+
} catch (e) {
|
|
1914
|
+
console.error("目标商家刷新统计失败:", e);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1804
1918
|
// 点击空白处关闭下拉
|
|
1805
1919
|
document.addEventListener("click", (e) => {
|
|
1806
1920
|
const dropdown = document.getElementById("refreshHoursDropdown");
|
|
@@ -1844,6 +1958,7 @@ function showTargetPage() {
|
|
|
1844
1958
|
showLoading("正在加载目标商家数据...");
|
|
1845
1959
|
fetchTargetByCountry();
|
|
1846
1960
|
fetchTargetOtherCountries();
|
|
1961
|
+
loadTargetRefreshStats();
|
|
1847
1962
|
// 同步统计
|
|
1848
1963
|
if (currentStats) {
|
|
1849
1964
|
document.getElementById("targetPageStatTotal").textContent = formatStatNum(
|
|
@@ -1860,7 +1975,7 @@ let currentTargetLoading = false;
|
|
|
1860
1975
|
let currentTargetAllLoaded = false;
|
|
1861
1976
|
let currentTargetSeq = 0;
|
|
1862
1977
|
let currentTargetSort = { key: null, asc: true };
|
|
1863
|
-
const TARGET_PAGE_SIZE =
|
|
1978
|
+
const TARGET_PAGE_SIZE = 500;
|
|
1864
1979
|
|
|
1865
1980
|
async function fetchTargetOtherCountries() {
|
|
1866
1981
|
try {
|
|
@@ -2256,6 +2371,7 @@ document
|
|
|
2256
2371
|
const btn = document.getElementById("targetReprocessBtn");
|
|
2257
2372
|
if (btn) btn.style.display = "none";
|
|
2258
2373
|
await fetchTargetByCountry();
|
|
2374
|
+
await loadTargetRefreshStats();
|
|
2259
2375
|
showToast("刷新完成");
|
|
2260
2376
|
} catch (e) {
|
|
2261
2377
|
showToast("刷新失败: " + e.message, true);
|
|
@@ -2876,6 +2992,136 @@ function initTargetTableSorting() {
|
|
|
2876
2992
|
});
|
|
2877
2993
|
}
|
|
2878
2994
|
|
|
2995
|
+
// ========== 烟花/鞭炮动画 ==========
|
|
2996
|
+
function triggerFirework(newCount) {
|
|
2997
|
+
const canvas = document.getElementById("fireworkCanvas");
|
|
2998
|
+
if (!canvas) return;
|
|
2999
|
+
const ctx = canvas.getContext("2d");
|
|
3000
|
+
|
|
3001
|
+
canvas.width = window.innerWidth;
|
|
3002
|
+
canvas.height = window.innerHeight;
|
|
3003
|
+
|
|
3004
|
+
const colors = [
|
|
3005
|
+
"#fe2c55",
|
|
3006
|
+
"#ff6b6b",
|
|
3007
|
+
"#ffd93d",
|
|
3008
|
+
"#6bcb77",
|
|
3009
|
+
"#4d96ff",
|
|
3010
|
+
"#a78bfa",
|
|
3011
|
+
"#ff922b",
|
|
3012
|
+
"#20c997",
|
|
3013
|
+
"#f06595",
|
|
3014
|
+
"#ffe066",
|
|
3015
|
+
"#5c7cfa",
|
|
3016
|
+
"#ff8787",
|
|
3017
|
+
];
|
|
3018
|
+
|
|
3019
|
+
const particles = [];
|
|
3020
|
+
const burstCount = Math.min(newCount, 5) + 2; // 最多 7 朵烟花
|
|
3021
|
+
|
|
3022
|
+
// 创建多朵烟花
|
|
3023
|
+
for (let b = 0; b < burstCount; b++) {
|
|
3024
|
+
const cx = Math.random() * canvas.width * 0.8 + canvas.width * 0.1;
|
|
3025
|
+
const cy = Math.random() * canvas.height * 0.5 + canvas.height * 0.1;
|
|
3026
|
+
const hue = Math.random() * 360;
|
|
3027
|
+
const count = 80 + Math.floor(Math.random() * 60);
|
|
3028
|
+
|
|
3029
|
+
for (let i = 0; i < count; i++) {
|
|
3030
|
+
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.3;
|
|
3031
|
+
const speed = 2 + Math.random() * 6;
|
|
3032
|
+
const color = colors[Math.floor(Math.random() * colors.length)];
|
|
3033
|
+
particles.push({
|
|
3034
|
+
x: cx,
|
|
3035
|
+
y: cy,
|
|
3036
|
+
vx: Math.cos(angle) * speed,
|
|
3037
|
+
vy: Math.sin(angle) * speed,
|
|
3038
|
+
life: 1,
|
|
3039
|
+
decay: 0.008 + Math.random() * 0.02,
|
|
3040
|
+
size: 1.5 + Math.random() * 2.5,
|
|
3041
|
+
color,
|
|
3042
|
+
gravity: 0.04,
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
let animFrame;
|
|
3048
|
+
function animate() {
|
|
3049
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
3050
|
+
|
|
3051
|
+
let alive = false;
|
|
3052
|
+
for (const p of particles) {
|
|
3053
|
+
p.x += p.vx;
|
|
3054
|
+
p.y += p.vy;
|
|
3055
|
+
p.vy += p.gravity;
|
|
3056
|
+
p.life -= p.decay;
|
|
3057
|
+
|
|
3058
|
+
if (p.life > 0) {
|
|
3059
|
+
alive = true;
|
|
3060
|
+
ctx.save();
|
|
3061
|
+
ctx.globalAlpha = p.life;
|
|
3062
|
+
ctx.fillStyle = p.color;
|
|
3063
|
+
ctx.shadowColor = p.color;
|
|
3064
|
+
ctx.shadowBlur = 6;
|
|
3065
|
+
ctx.beginPath();
|
|
3066
|
+
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
3067
|
+
ctx.fill();
|
|
3068
|
+
ctx.restore();
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
if (alive) {
|
|
3073
|
+
animFrame = requestAnimationFrame(animate);
|
|
3074
|
+
} else {
|
|
3075
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
if (animFrame) cancelAnimationFrame(animFrame);
|
|
3080
|
+
animate();
|
|
3081
|
+
|
|
3082
|
+
// 显示提示文字
|
|
3083
|
+
showFireworkToast(newCount);
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
function showFireworkToast(count) {
|
|
3087
|
+
let toast = document.getElementById("firework-toast");
|
|
3088
|
+
if (!toast) {
|
|
3089
|
+
toast = document.createElement("div");
|
|
3090
|
+
toast.id = "firework-toast";
|
|
3091
|
+
toast.style.cssText = `
|
|
3092
|
+
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
|
|
3093
|
+
font-size:48px;font-weight:900;z-index:10000;pointer-events:none;
|
|
3094
|
+
color:#ffd93d;text-shadow:0 0 30px #fe2c55,0 0 60px #fe2c55,0 0 90px #ff6b6b;
|
|
3095
|
+
animation:fireworkToast 2.5s ease-out forwards;
|
|
3096
|
+
white-space:nowrap;
|
|
3097
|
+
`;
|
|
3098
|
+
document.body.appendChild(toast);
|
|
3099
|
+
// 注入动画 keyframes
|
|
3100
|
+
if (!document.getElementById("fw-style")) {
|
|
3101
|
+
const style = document.createElement("style");
|
|
3102
|
+
style.id = "fw-style";
|
|
3103
|
+
style.textContent = `
|
|
3104
|
+
@keyframes fireworkToast {
|
|
3105
|
+
0% { opacity:0; transform:translate(-50%,-50%) scale(0.3); }
|
|
3106
|
+
15% { opacity:1; transform:translate(-50%,-50%) scale(1.2); }
|
|
3107
|
+
25% { opacity:1; transform:translate(-50%,-50%) scale(1); }
|
|
3108
|
+
80% { opacity:1; transform:translate(-50%,-50%) scale(1); }
|
|
3109
|
+
100% { opacity:0; transform:translate(-50%,-60%) scale(1.5); }
|
|
3110
|
+
}
|
|
3111
|
+
`;
|
|
3112
|
+
document.head.appendChild(style);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
toast.textContent = `🎆 +${count} 目标商家!`;
|
|
3116
|
+
toast.style.animation = "none";
|
|
3117
|
+
void toast.offsetWidth;
|
|
3118
|
+
toast.style.animation = "fireworkToast 2.5s ease-out forwards";
|
|
3119
|
+
|
|
3120
|
+
setTimeout(() => {
|
|
3121
|
+
if (toast.parentNode) toast.remove();
|
|
3122
|
+
}, 2600);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
2879
3125
|
// 初始化
|
|
2880
3126
|
fetchStats();
|
|
2881
3127
|
fetchUsers();
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
</head>
|
|
10
10
|
|
|
11
11
|
<body>
|
|
12
|
+
<canvas id="fireworkCanvas"></canvas>
|
|
12
13
|
<div class="header">
|
|
13
14
|
<h1>TikTok 采集监控</h1>
|
|
14
15
|
<div class="meta" id="fileMeta">加载中...</div>
|
|
@@ -325,6 +326,7 @@
|
|
|
325
326
|
<div class="label">🔄 刷新</div>
|
|
326
327
|
</div>
|
|
327
328
|
</div>
|
|
329
|
+
<div id="targetRefreshStats" style="margin-bottom:16px"></div>
|
|
328
330
|
<div class="target-page-layout">
|
|
329
331
|
<div class="target-side-card">
|
|
330
332
|
<h3>🎯 目标商家国家分布</h3>
|
|
@@ -1612,3 +1612,14 @@ td.user-id:hover {
|
|
|
1612
1612
|
.checkbox-label input[type="checkbox"] {
|
|
1613
1613
|
accent-color: #a78bfa;
|
|
1614
1614
|
}
|
|
1615
|
+
|
|
1616
|
+
/* ========== 烟花画布 ========== */
|
|
1617
|
+
#fireworkCanvas {
|
|
1618
|
+
position: fixed;
|
|
1619
|
+
top: 0;
|
|
1620
|
+
left: 0;
|
|
1621
|
+
width: 100%;
|
|
1622
|
+
height: 100%;
|
|
1623
|
+
pointer-events: none;
|
|
1624
|
+
z-index: 9999;
|
|
1625
|
+
}
|
package/src/watch/server.js
CHANGED
|
@@ -482,6 +482,30 @@ export function startWatchServer(
|
|
|
482
482
|
return;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
// 将 jobs 中 status='done' 且无国家数据的记录移动到 jobs_base
|
|
486
|
+
if (
|
|
487
|
+
req.method === "POST" &&
|
|
488
|
+
routePath === "/api/jobs/move-no-country-to-base"
|
|
489
|
+
) {
|
|
490
|
+
try {
|
|
491
|
+
const result = store.moveNoCountryJobsToBase();
|
|
492
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
493
|
+
if (result.ok) {
|
|
494
|
+
console.error(
|
|
495
|
+
`[JOB ${ts}] MOVE-NO-COUNTRY-TO-BASE: moved=${result.moved}, total=${result.total}`,
|
|
496
|
+
);
|
|
497
|
+
} else {
|
|
498
|
+
console.error(
|
|
499
|
+
`[JOB ${ts}] MOVE-NO-COUNTRY-TO-BASE ERROR: ${result.error}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
sendJSON(res, result.ok ? 200 : 400, result);
|
|
503
|
+
} catch (e) {
|
|
504
|
+
sendJSON(res, 500, { error: e.message });
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
485
509
|
// 将 jobs/raw_jobs 中 tt_seller=1 且 video_count=0 的记录移动到 jobs_base
|
|
486
510
|
if (
|
|
487
511
|
req.method === "POST" &&
|
|
@@ -1047,6 +1071,14 @@ export function startWatchServer(
|
|
|
1047
1071
|
return;
|
|
1048
1072
|
}
|
|
1049
1073
|
|
|
1074
|
+
// GET /api/targets/refresh-stats — 目标商家最近 N 小时刷新进度
|
|
1075
|
+
if (req.method === "GET" && routePath === "/api/targets/refresh-stats") {
|
|
1076
|
+
const hours = Math.max(1, parseInt(params.hours) || 24);
|
|
1077
|
+
const data = store.getTargetRefreshStats(hours);
|
|
1078
|
+
sendJSON(res, 200, data);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1050
1082
|
if (req.method === "GET" && routePath === "/api/client-errors") {
|
|
1051
1083
|
sendJSON(res, 200, { clients: store.getClientErrors() });
|
|
1052
1084
|
return;
|
|
@@ -2219,6 +2251,27 @@ export function startWatchServer(
|
|
|
2219
2251
|
);
|
|
2220
2252
|
}
|
|
2221
2253
|
|
|
2254
|
+
// 每 2 小时自动将无国家数据的 done job 移入 jobs_base 重走流程
|
|
2255
|
+
const NO_COUNTRY_MOVE_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
|
2256
|
+
setInterval(() => {
|
|
2257
|
+
try {
|
|
2258
|
+
const result = store.moveNoCountryJobsToBase();
|
|
2259
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
2260
|
+
if (result.moved > 0) {
|
|
2261
|
+
console.error(
|
|
2262
|
+
`[JOB ${ts}] AUTO-MOVE-NO-COUNTRY: moved=${result.moved}, total=${result.total}`,
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
} catch (e) {
|
|
2266
|
+
console.error(
|
|
2267
|
+
`[JOB] AUTO-MOVE-NO-COUNTRY 定时任务异常: ${e.message}`,
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
}, NO_COUNTRY_MOVE_INTERVAL_MS);
|
|
2271
|
+
console.error(
|
|
2272
|
+
`[job] 无国家数据自动回炉已启动: 间隔 ${NO_COUNTRY_MOVE_INTERVAL_MS / 3600000} 小时`,
|
|
2273
|
+
);
|
|
2274
|
+
|
|
2222
2275
|
_resolve({ server, port });
|
|
2223
2276
|
});
|
|
2224
2277
|
|