tt-help-cli-ycl 1.4.17 → 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.
@@ -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 });
@@ -256,13 +264,21 @@ function renderActiveClients(clients) {
256
264
  const tbody = document.getElementById("activeClientsBody");
257
265
  if (!section || !bar) return;
258
266
 
259
- const types = ["explore", "refresh", "attach", "comments", "scoring"];
267
+ const types = [
268
+ "explore",
269
+ "refresh",
270
+ "attach",
271
+ "comments",
272
+ "scoring",
273
+ "tag-refresh",
274
+ ];
260
275
  const labels = {
261
276
  explore: "Explore",
262
277
  refresh: "Refresh",
263
278
  attach: "Attach",
264
279
  comments: "Comments",
265
280
  scoring: "Scoring",
281
+ "tag-refresh": "TagRefresh",
266
282
  };
267
283
  const grouped = {};
268
284
  for (const c of clients) {
@@ -352,7 +368,7 @@ function renderCountryChart(countries) {
352
368
  const isTarget = targetLocations.includes(c.country);
353
369
  const targetBadge =
354
370
  c.targetCount > 0
355
- ? `<span class="target-badge">🎯 ${c.targetCount}</span>`
371
+ ? `<span class="target-badge${isTarget ? "" : " target-badge-other"}">🎯 ${c.targetCount}</span>`
356
372
  : `<span class="target-badge" style="visibility:hidden"> </span>`;
357
373
  return `
358
374
  <div class="bar-row${isTarget ? " is-target" : ""}">
@@ -796,6 +812,55 @@ async function confirmNonSeller(uniqueId) {
796
812
  }
797
813
  }
798
814
 
815
+ async function moveToBase(uniqueId) {
816
+ if (
817
+ !confirm(
818
+ `确定要将 @${uniqueId} 移到 jobs_base 并重走流程吗?\n这会将用户从 jobs 表移到 jobs_base,完全从头开始采集。`,
819
+ )
820
+ ) {
821
+ return;
822
+ }
823
+ showLoading("正在移动...");
824
+ try {
825
+ const res = await fetch(
826
+ `/api/user-move-to-base/${encodeURIComponent(uniqueId)}`,
827
+ {
828
+ method: "PUT",
829
+ headers: { "Content-Type": "application/json" },
830
+ },
831
+ );
832
+ const data = await res.json();
833
+ if (data.error) {
834
+ showToast(data.error, true);
835
+ return;
836
+ }
837
+ showToast(`@${uniqueId} 已移到 jobs_base,将重走流程`);
838
+
839
+ // 从当前列表中移除该用户
840
+ currentTargetUsers = currentTargetUsers.filter(
841
+ (u) => u.uniqueId !== uniqueId,
842
+ );
843
+ currentTargetTotal = Math.max(0, currentTargetTotal - 1);
844
+ renderTargetTable();
845
+
846
+ // 更新统计
847
+ const statEl = document.getElementById("targetPageStatTotal");
848
+ if (statEl)
849
+ statEl.textContent = formatStatNum(currentTargetTotal, { full: true });
850
+ if (currentStats) {
851
+ currentStats.targetUsers = Math.max(
852
+ 0,
853
+ (currentStats.targetUsers || 0) - 1,
854
+ );
855
+ flashEl("statTarget", currentStats.targetUsers, { full: true });
856
+ }
857
+ } catch (e) {
858
+ showToast("移动失败: " + e.message, true);
859
+ } finally {
860
+ hideLoading();
861
+ }
862
+ }
863
+
799
864
  async function submitAddUsers() {
800
865
  const ta = document.getElementById("modalUserInput");
801
866
  const raw = ta.value.trim();
@@ -1051,6 +1116,121 @@ function scrollToTop() {
1051
1116
  window.scrollTo({ top: 0, behavior: "smooth" });
1052
1117
  }
1053
1118
 
1119
+ // ── 回顶部按钮拖拽 ──
1120
+ (function initBackToTopDrag() {
1121
+ const btn = document.getElementById("backToTop");
1122
+ if (!btn) return;
1123
+
1124
+ let startX,
1125
+ startY,
1126
+ startLeft,
1127
+ startTop,
1128
+ dragging = false,
1129
+ moved = false;
1130
+ const DRAG_THRESHOLD = 5;
1131
+ const STORAGE_KEY = "backToTopPos";
1132
+
1133
+ // 恢复保存的位置
1134
+ const saved = (function () {
1135
+ try {
1136
+ return JSON.parse(localStorage.getItem(STORAGE_KEY));
1137
+ } catch (_) {
1138
+ return null;
1139
+ }
1140
+ })();
1141
+ if (
1142
+ saved &&
1143
+ typeof saved.left === "number" &&
1144
+ typeof saved.top === "number"
1145
+ ) {
1146
+ btn.style.left = saved.left + "px";
1147
+ btn.style.top = saved.top + "px";
1148
+ btn.style.right = "auto";
1149
+ btn.style.bottom = "auto";
1150
+ }
1151
+
1152
+ function clamp(val, min, max) {
1153
+ return Math.min(Math.max(val, min), max);
1154
+ }
1155
+
1156
+ function getPos(e) {
1157
+ if (e.touches) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
1158
+ return { x: e.clientX, y: e.clientY };
1159
+ }
1160
+
1161
+ function onStart(e) {
1162
+ if (e.button !== undefined && e.button !== 0) return; // 只响应左键
1163
+ const pos = getPos(e);
1164
+ startX = pos.x;
1165
+ startY = pos.y;
1166
+ const rect = btn.getBoundingClientRect();
1167
+ startLeft = rect.left;
1168
+ startTop = rect.top;
1169
+ moved = false;
1170
+ dragging = true;
1171
+ btn.classList.add("dragging");
1172
+ btn.style.right = "auto";
1173
+ btn.style.bottom = "auto";
1174
+ btn.style.left = startLeft + "px";
1175
+ btn.style.top = startTop + "px";
1176
+ e.preventDefault();
1177
+ }
1178
+
1179
+ function onMove(e) {
1180
+ if (!dragging) return;
1181
+ const pos = getPos(e);
1182
+ const dx = pos.x - startX;
1183
+ const dy = pos.y - startY;
1184
+ if (
1185
+ !moved &&
1186
+ Math.abs(dx) < DRAG_THRESHOLD &&
1187
+ Math.abs(dy) < DRAG_THRESHOLD
1188
+ )
1189
+ return;
1190
+ moved = true;
1191
+ const newLeft = clamp(
1192
+ startLeft + dx,
1193
+ 0,
1194
+ window.innerWidth - btn.offsetWidth,
1195
+ );
1196
+ const newTop = clamp(
1197
+ startTop + dy,
1198
+ 0,
1199
+ window.innerHeight - btn.offsetHeight,
1200
+ );
1201
+ btn.style.left = newLeft + "px";
1202
+ btn.style.top = newTop + "px";
1203
+ }
1204
+
1205
+ function onEnd() {
1206
+ if (!dragging) return;
1207
+ dragging = false;
1208
+ btn.classList.remove("dragging");
1209
+ if (moved) {
1210
+ // 保存位置
1211
+ try {
1212
+ localStorage.setItem(
1213
+ STORAGE_KEY,
1214
+ JSON.stringify({
1215
+ left: parseInt(btn.style.left),
1216
+ top: parseInt(btn.style.top),
1217
+ }),
1218
+ );
1219
+ } catch (_) {}
1220
+ } else {
1221
+ // 未拖动则视为点击
1222
+ scrollToTop();
1223
+ }
1224
+ }
1225
+
1226
+ btn.addEventListener("mousedown", onStart);
1227
+ btn.addEventListener("touchstart", onStart, { passive: false });
1228
+ window.addEventListener("mousemove", onMove);
1229
+ window.addEventListener("touchmove", onMove, { passive: false });
1230
+ window.addEventListener("mouseup", onEnd);
1231
+ window.addEventListener("touchend", onEnd);
1232
+ })();
1233
+
1054
1234
  function handleRoute() {
1055
1235
  const hash = window.location.hash;
1056
1236
  if (hash === "#pending") {
@@ -1291,6 +1471,8 @@ let currentTagsAllLoaded = false;
1291
1471
  let currentTagsSeq = 0;
1292
1472
  const TAGS_PAGE_SIZE = 200;
1293
1473
  let cachedTagCountries = [];
1474
+ let currentRefreshHours = 24;
1475
+ let currentTargetRefreshHours = 24;
1294
1476
 
1295
1477
  function showTagsPage() {
1296
1478
  document.getElementById("mainPage").classList.add("hidden");
@@ -1300,7 +1482,9 @@ function showTagsPage() {
1300
1482
  document.getElementById("targetPage").classList.remove("active");
1301
1483
  document.getElementById("tagsPage").classList.add("active");
1302
1484
  showLoading("正在加载关键词...");
1303
- Promise.all([loadTagStats(), loadTagsPage()]).finally(hideLoading);
1485
+ Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
1486
+ hideLoading,
1487
+ );
1304
1488
  }
1305
1489
 
1306
1490
  function setTagsFilter(filter) {
@@ -1325,13 +1509,94 @@ async function loadTagStats() {
1325
1509
  flashEl("tagsPageStatDead", stats.dead || 0);
1326
1510
  flashEl("tagsPageStatNew", (stats.new || 0) + (stats.scoring || 0));
1327
1511
  cachedTagCountries = stats.countries || [];
1328
- // 初始化国家下拉
1329
1512
  renderTagsCountryFilter();
1330
1513
  } catch (e) {
1331
1514
  console.error("获取关键词统计失败:", e);
1332
1515
  }
1333
1516
  }
1334
1517
 
1518
+ async function loadRefreshStats() {
1519
+ try {
1520
+ const res = await fetch(
1521
+ `/api/tags/refresh-stats?hours=${currentRefreshHours}`,
1522
+ );
1523
+ const data = await res.json();
1524
+ renderRefreshStats(data);
1525
+ } catch (e) {
1526
+ console.error("获取刷新统计失败:", e);
1527
+ }
1528
+ }
1529
+
1530
+ function renderRefreshStats(data) {
1531
+ const el = document.getElementById("tagsRefreshStats");
1532
+ if (!el || !data || !data.countries || data.countries.length === 0) {
1533
+ if (el) el.innerHTML = "";
1534
+ return;
1535
+ }
1536
+ const pct =
1537
+ data.total > 0 ? Math.round((data.refreshed / data.total) * 100) : 0;
1538
+ const barW = Math.min(pct, 100);
1539
+
1540
+ const HOUR_OPTIONS = [12, 24, 36, 48, 72];
1541
+ let html = `<div style="background:#1a1a2e;border:1px solid #2a2a3a;border-radius:10px;padding:16px">
1542
+ <div id="refreshStatsHeader" style="display:flex;align-items:center;gap:16px;cursor:pointer;user-select:none">
1543
+ <span style="font-size:14px;font-weight:600;color:#e0e0e0;position:relative">
1544
+ 📊 最近 <span id="refreshHoursLabel" style="color:#60a5fa;cursor:pointer;text-decoration:underline dotted" onclick="event.stopPropagation();toggleHoursDropdown(event)">${currentRefreshHours}h</span> 刷新进度
1545
+ <div id="refreshHoursDropdown" 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">
1546
+ ${HOUR_OPTIONS.map((h) => `<div class="hours-option" data-hours="${h}" style="padding:6px 14px;cursor:pointer;font-size:13px;color:${h === currentRefreshHours ? "#60a5fa" : "#ccc"};font-weight:${h === currentRefreshHours ? "600" : "400"};white-space:nowrap" onclick="event.stopPropagation();selectRefreshHours(${h})">${h}h</div>`).join("")}
1547
+ </div>
1548
+ </span>
1549
+ <span style="font-size:13px;color:#9ca3af">${data.refreshed} / ${data.total}</span>
1550
+ <div style="flex:1;max-width:200px;height:8px;background:#2a2a3a;border-radius:4px;overflow:hidden">
1551
+ <div style="height:100%;width:${barW}%;background:${pct >= 50 ? "#22c55e" : "#facc15"};border-radius:4px;transition:width 0.3s"></div>
1552
+ </div>
1553
+ <span style="font-size:14px;font-weight:700;color:${pct >= 50 ? "#22c55e" : "#facc15"}">${pct}%</span>
1554
+ <span id="refreshStatsArrow" style="color:#888;font-size:12px">▶</span>
1555
+ </div>
1556
+ <div id="refreshStatsBody" style="display:none;margin-top:12px">
1557
+ <table style="width:100%;border-collapse:collapse;font-size:12px">
1558
+ <thead><tr style="color:#888;text-align:left">
1559
+ <th style="padding:4px 8px">国家</th>
1560
+ <th style="padding:4px 8px;text-align:right">需刷新</th>
1561
+ <th style="padding:4px 8px;text-align:right">${currentRefreshHours}h已刷</th>
1562
+ <th style="padding:4px 8px;width:120px">进度</th>
1563
+ </tr></thead>
1564
+ <tbody>`;
1565
+
1566
+ for (const c of data.countries) {
1567
+ const cpct = c.total > 0 ? Math.round((c.refreshed / c.total) * 100) : 0;
1568
+ const cBarW = Math.min(cpct, 100);
1569
+ html += `<tr style="color:#ccc;border-top:1px solid #222">
1570
+ <td style="padding:4px 8px">${c.country}</td>
1571
+ <td style="padding:4px 8px;text-align:right">${c.total}</td>
1572
+ <td style="padding:4px 8px;text-align:right;color:${cpct >= 50 ? "#22c55e" : "#facc15"}">${c.refreshed}</td>
1573
+ <td style="padding:4px 8px">
1574
+ <div style="display:flex;align-items:center;gap:6px">
1575
+ <div style="flex:1;height:6px;background:#2a2a3a;border-radius:3px;overflow:hidden">
1576
+ <div style="height:100%;width:${cBarW}%;background:${cpct >= 50 ? "#22c55e" : "#facc15"};border-radius:3px"></div>
1577
+ </div>
1578
+ <span style="font-size:11px;color:#9ca3af;min-width:28px;text-align:right">${cpct}%</span>
1579
+ </div>
1580
+ </td>
1581
+ </tr>`;
1582
+ }
1583
+
1584
+ html += `</tbody></table></div></div>`;
1585
+ el.innerHTML = html;
1586
+
1587
+ // 绑定展开/折叠
1588
+ const header = document.getElementById("refreshStatsHeader");
1589
+ const body = document.getElementById("refreshStatsBody");
1590
+ const arrow = document.getElementById("refreshStatsArrow");
1591
+ if (header && body && arrow) {
1592
+ header.addEventListener("click", () => {
1593
+ const open = body.style.display !== "none";
1594
+ body.style.display = open ? "none" : "";
1595
+ arrow.textContent = open ? "▶" : "▼";
1596
+ });
1597
+ }
1598
+ }
1599
+
1335
1600
  function renderTagsCountryFilter() {
1336
1601
  const sel = document.getElementById("tagsCountryFilter");
1337
1602
  if (!sel) return;
@@ -1441,7 +1706,7 @@ function renderTagsTable() {
1441
1706
  const matchedAuthors = t.matched_authors ?? "-";
1442
1707
  const pushedUsers = t.pushed_users ?? "-";
1443
1708
  const refreshedAt = t.refreshed_at
1444
- ? t.refreshed_at.slice(0, 16).replace("T", " ")
1709
+ ? formatTime(new Date(t.refreshed_at).getTime())
1445
1710
  : "-";
1446
1711
 
1447
1712
  return `<tr>
@@ -1486,7 +1751,7 @@ document
1486
1751
  .addEventListener("click", async () => {
1487
1752
  showLoading("正在刷新...");
1488
1753
  try {
1489
- await Promise.all([loadTagStats(), loadTagsPage()]);
1754
+ await Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]);
1490
1755
  showToast("刷新完成");
1491
1756
  } catch (e) {
1492
1757
  showToast("刷新失败: " + e.message, true);
@@ -1519,6 +1784,151 @@ function loadMoreTags() {
1519
1784
  loadTagsPage(true);
1520
1785
  }
1521
1786
 
1787
+ // ====== 刷新统计时间切换 ======
1788
+
1789
+ function toggleHoursDropdown(event) {
1790
+ const dropdown = document.getElementById("refreshHoursDropdown");
1791
+ if (!dropdown) return;
1792
+ const isOpen = dropdown.style.display !== "none";
1793
+ dropdown.style.display = isOpen ? "none" : "block";
1794
+ }
1795
+
1796
+ async function selectRefreshHours(hours) {
1797
+ const dropdown = document.getElementById("refreshHoursDropdown");
1798
+ if (dropdown) dropdown.style.display = "none";
1799
+ if (hours === currentRefreshHours) return;
1800
+ currentRefreshHours = hours;
1801
+ showLoading("正在加载...");
1802
+ try {
1803
+ await loadRefreshStats();
1804
+ const label = document.getElementById("refreshHoursLabel");
1805
+ if (label) label.textContent = hours + "h";
1806
+ } catch (e) {
1807
+ console.error("刷新统计失败:", e);
1808
+ } finally {
1809
+ hideLoading();
1810
+ }
1811
+ }
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
+
1918
+ // 点击空白处关闭下拉
1919
+ document.addEventListener("click", (e) => {
1920
+ const dropdown = document.getElementById("refreshHoursDropdown");
1921
+ const label = document.getElementById("refreshHoursLabel");
1922
+ if (
1923
+ dropdown &&
1924
+ dropdown.style.display !== "none" &&
1925
+ !label?.contains(e.target) &&
1926
+ !dropdown.contains(e.target)
1927
+ ) {
1928
+ dropdown.style.display = "none";
1929
+ }
1930
+ });
1931
+
1522
1932
  // 排序
1523
1933
  document.querySelectorAll(".sortable-tag").forEach((th) => {
1524
1934
  th.addEventListener("click", () => {
@@ -1548,6 +1958,7 @@ function showTargetPage() {
1548
1958
  showLoading("正在加载目标商家数据...");
1549
1959
  fetchTargetByCountry();
1550
1960
  fetchTargetOtherCountries();
1961
+ loadTargetRefreshStats();
1551
1962
  // 同步统计
1552
1963
  if (currentStats) {
1553
1964
  document.getElementById("targetPageStatTotal").textContent = formatStatNum(
@@ -1564,7 +1975,7 @@ let currentTargetLoading = false;
1564
1975
  let currentTargetAllLoaded = false;
1565
1976
  let currentTargetSeq = 0;
1566
1977
  let currentTargetSort = { key: null, asc: true };
1567
- const TARGET_PAGE_SIZE = 200;
1978
+ const TARGET_PAGE_SIZE = 500;
1568
1979
 
1569
1980
  async function fetchTargetOtherCountries() {
1570
1981
  try {
@@ -1816,7 +2227,7 @@ function renderTargetTable() {
1816
2227
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
1817
2228
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
1818
2229
  <td data-label="操作">
1819
- <button class="btn-non-seller" onclick="confirmNonSeller('${escapeJsString(u.uniqueId)}')" title="将此用户标记为非商家">设为非商家</button>
2230
+ <button class="btn-pending" onclick="moveToBase('${escapeJsString(u.uniqueId)}')" title="移到 jobs_base 重走流程">待确认</button>
1820
2231
  </td>
1821
2232
  </tr>`;
1822
2233
  })
@@ -1960,6 +2371,7 @@ document
1960
2371
  const btn = document.getElementById("targetReprocessBtn");
1961
2372
  if (btn) btn.style.display = "none";
1962
2373
  await fetchTargetByCountry();
2374
+ await loadTargetRefreshStats();
1963
2375
  showToast("刷新完成");
1964
2376
  } catch (e) {
1965
2377
  showToast("刷新失败: " + e.message, true);
@@ -2580,6 +2992,136 @@ function initTargetTableSorting() {
2580
2992
  });
2581
2993
  }
2582
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
+
2583
3125
  // 初始化
2584
3126
  fetchStats();
2585
3127
  fetchUsers();
@@ -2587,7 +3129,8 @@ fetchClientErrors();
2587
3129
  initTableSorting();
2588
3130
  initTargetTableSorting();
2589
3131
 
3132
+ // 错峰轮询,避免同时发 4 个请求阻塞服务端
2590
3133
  setInterval(fetchStats, 10000);
2591
- setInterval(fetchUsers, 10000);
2592
- setInterval(fetchClientErrors, 10000);
2593
- setInterval(refreshUserUpdateIfActive, 10000);
3134
+ setTimeout(() => setInterval(fetchUsers, 10000), 2500);
3135
+ setTimeout(() => setInterval(fetchClientErrors, 10000), 5000);
3136
+ setTimeout(() => setInterval(refreshUserUpdateIfActive, 10000), 7500);