tt-help-cli-ycl 1.4.18 → 1.4.20

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.
@@ -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
+ }
@@ -92,6 +92,7 @@ function sendCSV(res, columns, rows) {
92
92
  function inferClientType(routePath) {
93
93
  if (routePath.startsWith("/api/redo-job")) return "refresh";
94
94
  if (routePath.startsWith("/api/user-update-tasks")) return "attach";
95
+ if (routePath.startsWith("/api/recheck-jobs")) return "recheck";
95
96
  if (routePath.startsWith("/api/comment-task")) return "comments";
96
97
  if (routePath.startsWith("/api/tags/refresh")) return "tag-refresh";
97
98
  if (routePath.startsWith("/api/tags")) return "scoring";
@@ -482,6 +483,30 @@ export function startWatchServer(
482
483
  return;
483
484
  }
484
485
 
486
+ // 将 jobs 中 status='done' 且无国家数据的记录移动到 jobs_base
487
+ if (
488
+ req.method === "POST" &&
489
+ routePath === "/api/jobs/move-no-country-to-base"
490
+ ) {
491
+ try {
492
+ const result = store.moveNoCountryJobsToBase();
493
+ const ts = new Date().toISOString().slice(11, 19);
494
+ if (result.ok) {
495
+ console.error(
496
+ `[JOB ${ts}] MOVE-NO-COUNTRY-TO-BASE: moved=${result.moved}, total=${result.total}`,
497
+ );
498
+ } else {
499
+ console.error(
500
+ `[JOB ${ts}] MOVE-NO-COUNTRY-TO-BASE ERROR: ${result.error}`,
501
+ );
502
+ }
503
+ sendJSON(res, result.ok ? 200 : 400, result);
504
+ } catch (e) {
505
+ sendJSON(res, 500, { error: e.message });
506
+ }
507
+ return;
508
+ }
509
+
485
510
  // 将 jobs/raw_jobs 中 tt_seller=1 且 video_count=0 的记录移动到 jobs_base
486
511
  if (
487
512
  req.method === "POST" &&
@@ -730,6 +755,47 @@ export function startWatchServer(
730
755
  return;
731
756
  }
732
757
 
758
+ // ── recheck-jobs 端点 ──
759
+ if (req.method === "GET" && routePath === "/api/recheck-jobs") {
760
+ const limit = params.limit;
761
+ const countries = params.countries
762
+ ? params.countries
763
+ .split(",")
764
+ .map((c) => c.trim().toUpperCase())
765
+ .filter(Boolean)
766
+ : [];
767
+ const tasks = store.getRecheckJobs(limit, countries);
768
+ const ts = new Date().toISOString().slice(11, 19);
769
+ console.error(
770
+ `[JOB ${ts}] RECHECK-JOBS: ${tasks.length} tasks${countries.length ? ` (countries: ${countries.join(",")})` : ""}`,
771
+ );
772
+ sendJSON(res, 200, { total: tasks.length, tasks });
773
+ return;
774
+ }
775
+
776
+ if (req.method === "POST" && routePath === "/api/recheck-jobs-batch") {
777
+ try {
778
+ const body = await readBody(req);
779
+ const updates = body.updates || [];
780
+ const { results } = store.batchUpdateRecheckJobs(updates);
781
+ const okCount = results.filter((r) => r.ok).length;
782
+ const errCount = results.filter((r) => r.error).length;
783
+ const ts = new Date().toISOString().slice(11, 19);
784
+ console.error(
785
+ `[JOB ${ts}] RECHECK-JOBS-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`,
786
+ );
787
+ sendJSON(res, 200, {
788
+ results,
789
+ total: updates.length,
790
+ ok: okCount,
791
+ error: errCount,
792
+ });
793
+ } catch (e) {
794
+ sendJSON(res, 400, { error: e.message });
795
+ }
796
+ return;
797
+ }
798
+
733
799
  const userInfoCommitMatch = routePath.match(
734
800
  /^\/api\/user-info\/([^/]+)$/,
735
801
  );
@@ -1047,6 +1113,14 @@ export function startWatchServer(
1047
1113
  return;
1048
1114
  }
1049
1115
 
1116
+ // GET /api/targets/refresh-stats — 目标商家最近 N 小时刷新进度
1117
+ if (req.method === "GET" && routePath === "/api/targets/refresh-stats") {
1118
+ const hours = Math.max(1, parseInt(params.hours) || 24);
1119
+ const data = store.getTargetRefreshStats(hours);
1120
+ sendJSON(res, 200, data);
1121
+ return;
1122
+ }
1123
+
1050
1124
  if (req.method === "GET" && routePath === "/api/client-errors") {
1051
1125
  sendJSON(res, 200, { clients: store.getClientErrors() });
1052
1126
  return;
@@ -2219,6 +2293,27 @@ export function startWatchServer(
2219
2293
  );
2220
2294
  }
2221
2295
 
2296
+ // 每 2 小时自动将无国家数据的 done job 移入 jobs_base 重走流程
2297
+ const NO_COUNTRY_MOVE_INTERVAL_MS = 2 * 60 * 60 * 1000;
2298
+ setInterval(() => {
2299
+ try {
2300
+ const result = store.moveNoCountryJobsToBase();
2301
+ const ts = new Date().toISOString().slice(11, 19);
2302
+ if (result.moved > 0) {
2303
+ console.error(
2304
+ `[JOB ${ts}] AUTO-MOVE-NO-COUNTRY: moved=${result.moved}, total=${result.total}`,
2305
+ );
2306
+ }
2307
+ } catch (e) {
2308
+ console.error(
2309
+ `[JOB] AUTO-MOVE-NO-COUNTRY 定时任务异常: ${e.message}`,
2310
+ );
2311
+ }
2312
+ }, NO_COUNTRY_MOVE_INTERVAL_MS);
2313
+ console.error(
2314
+ `[job] 无国家数据自动回炉已启动: 间隔 ${NO_COUNTRY_MOVE_INTERVAL_MS / 3600000} 小时`,
2315
+ );
2316
+
2222
2317
  _resolve({ server, port });
2223
2318
  });
2224
2319