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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.18",
3
+ "version": "1.4.19",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
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
- if (isScoreAll) {
812
- scoreAllPort = parseInt(args[++i]) || 7222;
813
- } else if (isRefresh) {
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",
@@ -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();
@@ -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 = 200;
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
+ }
@@ -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