tt-help-cli-ycl 1.4.17 → 1.4.18

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.
@@ -256,13 +256,21 @@ function renderActiveClients(clients) {
256
256
  const tbody = document.getElementById("activeClientsBody");
257
257
  if (!section || !bar) return;
258
258
 
259
- const types = ["explore", "refresh", "attach", "comments", "scoring"];
259
+ const types = [
260
+ "explore",
261
+ "refresh",
262
+ "attach",
263
+ "comments",
264
+ "scoring",
265
+ "tag-refresh",
266
+ ];
260
267
  const labels = {
261
268
  explore: "Explore",
262
269
  refresh: "Refresh",
263
270
  attach: "Attach",
264
271
  comments: "Comments",
265
272
  scoring: "Scoring",
273
+ "tag-refresh": "TagRefresh",
266
274
  };
267
275
  const grouped = {};
268
276
  for (const c of clients) {
@@ -352,7 +360,7 @@ function renderCountryChart(countries) {
352
360
  const isTarget = targetLocations.includes(c.country);
353
361
  const targetBadge =
354
362
  c.targetCount > 0
355
- ? `<span class="target-badge">🎯 ${c.targetCount}</span>`
363
+ ? `<span class="target-badge${isTarget ? "" : " target-badge-other"}">🎯 ${c.targetCount}</span>`
356
364
  : `<span class="target-badge" style="visibility:hidden"> </span>`;
357
365
  return `
358
366
  <div class="bar-row${isTarget ? " is-target" : ""}">
@@ -796,6 +804,55 @@ async function confirmNonSeller(uniqueId) {
796
804
  }
797
805
  }
798
806
 
807
+ async function moveToBase(uniqueId) {
808
+ if (
809
+ !confirm(
810
+ `确定要将 @${uniqueId} 移到 jobs_base 并重走流程吗?\n这会将用户从 jobs 表移到 jobs_base,完全从头开始采集。`,
811
+ )
812
+ ) {
813
+ return;
814
+ }
815
+ showLoading("正在移动...");
816
+ try {
817
+ const res = await fetch(
818
+ `/api/user-move-to-base/${encodeURIComponent(uniqueId)}`,
819
+ {
820
+ method: "PUT",
821
+ headers: { "Content-Type": "application/json" },
822
+ },
823
+ );
824
+ const data = await res.json();
825
+ if (data.error) {
826
+ showToast(data.error, true);
827
+ return;
828
+ }
829
+ showToast(`@${uniqueId} 已移到 jobs_base,将重走流程`);
830
+
831
+ // 从当前列表中移除该用户
832
+ currentTargetUsers = currentTargetUsers.filter(
833
+ (u) => u.uniqueId !== uniqueId,
834
+ );
835
+ currentTargetTotal = Math.max(0, currentTargetTotal - 1);
836
+ renderTargetTable();
837
+
838
+ // 更新统计
839
+ const statEl = document.getElementById("targetPageStatTotal");
840
+ if (statEl)
841
+ statEl.textContent = formatStatNum(currentTargetTotal, { full: true });
842
+ if (currentStats) {
843
+ currentStats.targetUsers = Math.max(
844
+ 0,
845
+ (currentStats.targetUsers || 0) - 1,
846
+ );
847
+ flashEl("statTarget", currentStats.targetUsers, { full: true });
848
+ }
849
+ } catch (e) {
850
+ showToast("移动失败: " + e.message, true);
851
+ } finally {
852
+ hideLoading();
853
+ }
854
+ }
855
+
799
856
  async function submitAddUsers() {
800
857
  const ta = document.getElementById("modalUserInput");
801
858
  const raw = ta.value.trim();
@@ -1051,6 +1108,121 @@ function scrollToTop() {
1051
1108
  window.scrollTo({ top: 0, behavior: "smooth" });
1052
1109
  }
1053
1110
 
1111
+ // ── 回顶部按钮拖拽 ──
1112
+ (function initBackToTopDrag() {
1113
+ const btn = document.getElementById("backToTop");
1114
+ if (!btn) return;
1115
+
1116
+ let startX,
1117
+ startY,
1118
+ startLeft,
1119
+ startTop,
1120
+ dragging = false,
1121
+ moved = false;
1122
+ const DRAG_THRESHOLD = 5;
1123
+ const STORAGE_KEY = "backToTopPos";
1124
+
1125
+ // 恢复保存的位置
1126
+ const saved = (function () {
1127
+ try {
1128
+ return JSON.parse(localStorage.getItem(STORAGE_KEY));
1129
+ } catch (_) {
1130
+ return null;
1131
+ }
1132
+ })();
1133
+ if (
1134
+ saved &&
1135
+ typeof saved.left === "number" &&
1136
+ typeof saved.top === "number"
1137
+ ) {
1138
+ btn.style.left = saved.left + "px";
1139
+ btn.style.top = saved.top + "px";
1140
+ btn.style.right = "auto";
1141
+ btn.style.bottom = "auto";
1142
+ }
1143
+
1144
+ function clamp(val, min, max) {
1145
+ return Math.min(Math.max(val, min), max);
1146
+ }
1147
+
1148
+ function getPos(e) {
1149
+ if (e.touches) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
1150
+ return { x: e.clientX, y: e.clientY };
1151
+ }
1152
+
1153
+ function onStart(e) {
1154
+ if (e.button !== undefined && e.button !== 0) return; // 只响应左键
1155
+ const pos = getPos(e);
1156
+ startX = pos.x;
1157
+ startY = pos.y;
1158
+ const rect = btn.getBoundingClientRect();
1159
+ startLeft = rect.left;
1160
+ startTop = rect.top;
1161
+ moved = false;
1162
+ dragging = true;
1163
+ btn.classList.add("dragging");
1164
+ btn.style.right = "auto";
1165
+ btn.style.bottom = "auto";
1166
+ btn.style.left = startLeft + "px";
1167
+ btn.style.top = startTop + "px";
1168
+ e.preventDefault();
1169
+ }
1170
+
1171
+ function onMove(e) {
1172
+ if (!dragging) return;
1173
+ const pos = getPos(e);
1174
+ const dx = pos.x - startX;
1175
+ const dy = pos.y - startY;
1176
+ if (
1177
+ !moved &&
1178
+ Math.abs(dx) < DRAG_THRESHOLD &&
1179
+ Math.abs(dy) < DRAG_THRESHOLD
1180
+ )
1181
+ return;
1182
+ moved = true;
1183
+ const newLeft = clamp(
1184
+ startLeft + dx,
1185
+ 0,
1186
+ window.innerWidth - btn.offsetWidth,
1187
+ );
1188
+ const newTop = clamp(
1189
+ startTop + dy,
1190
+ 0,
1191
+ window.innerHeight - btn.offsetHeight,
1192
+ );
1193
+ btn.style.left = newLeft + "px";
1194
+ btn.style.top = newTop + "px";
1195
+ }
1196
+
1197
+ function onEnd() {
1198
+ if (!dragging) return;
1199
+ dragging = false;
1200
+ btn.classList.remove("dragging");
1201
+ if (moved) {
1202
+ // 保存位置
1203
+ try {
1204
+ localStorage.setItem(
1205
+ STORAGE_KEY,
1206
+ JSON.stringify({
1207
+ left: parseInt(btn.style.left),
1208
+ top: parseInt(btn.style.top),
1209
+ }),
1210
+ );
1211
+ } catch (_) {}
1212
+ } else {
1213
+ // 未拖动则视为点击
1214
+ scrollToTop();
1215
+ }
1216
+ }
1217
+
1218
+ btn.addEventListener("mousedown", onStart);
1219
+ btn.addEventListener("touchstart", onStart, { passive: false });
1220
+ window.addEventListener("mousemove", onMove);
1221
+ window.addEventListener("touchmove", onMove, { passive: false });
1222
+ window.addEventListener("mouseup", onEnd);
1223
+ window.addEventListener("touchend", onEnd);
1224
+ })();
1225
+
1054
1226
  function handleRoute() {
1055
1227
  const hash = window.location.hash;
1056
1228
  if (hash === "#pending") {
@@ -1291,6 +1463,7 @@ let currentTagsAllLoaded = false;
1291
1463
  let currentTagsSeq = 0;
1292
1464
  const TAGS_PAGE_SIZE = 200;
1293
1465
  let cachedTagCountries = [];
1466
+ let currentRefreshHours = 24;
1294
1467
 
1295
1468
  function showTagsPage() {
1296
1469
  document.getElementById("mainPage").classList.add("hidden");
@@ -1300,7 +1473,9 @@ function showTagsPage() {
1300
1473
  document.getElementById("targetPage").classList.remove("active");
1301
1474
  document.getElementById("tagsPage").classList.add("active");
1302
1475
  showLoading("正在加载关键词...");
1303
- Promise.all([loadTagStats(), loadTagsPage()]).finally(hideLoading);
1476
+ Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
1477
+ hideLoading,
1478
+ );
1304
1479
  }
1305
1480
 
1306
1481
  function setTagsFilter(filter) {
@@ -1325,13 +1500,94 @@ async function loadTagStats() {
1325
1500
  flashEl("tagsPageStatDead", stats.dead || 0);
1326
1501
  flashEl("tagsPageStatNew", (stats.new || 0) + (stats.scoring || 0));
1327
1502
  cachedTagCountries = stats.countries || [];
1328
- // 初始化国家下拉
1329
1503
  renderTagsCountryFilter();
1330
1504
  } catch (e) {
1331
1505
  console.error("获取关键词统计失败:", e);
1332
1506
  }
1333
1507
  }
1334
1508
 
1509
+ async function loadRefreshStats() {
1510
+ try {
1511
+ const res = await fetch(
1512
+ `/api/tags/refresh-stats?hours=${currentRefreshHours}`,
1513
+ );
1514
+ const data = await res.json();
1515
+ renderRefreshStats(data);
1516
+ } catch (e) {
1517
+ console.error("获取刷新统计失败:", e);
1518
+ }
1519
+ }
1520
+
1521
+ function renderRefreshStats(data) {
1522
+ const el = document.getElementById("tagsRefreshStats");
1523
+ if (!el || !data || !data.countries || data.countries.length === 0) {
1524
+ if (el) el.innerHTML = "";
1525
+ return;
1526
+ }
1527
+ const pct =
1528
+ data.total > 0 ? Math.round((data.refreshed / data.total) * 100) : 0;
1529
+ const barW = Math.min(pct, 100);
1530
+
1531
+ const HOUR_OPTIONS = [12, 24, 36, 48, 72];
1532
+ let html = `<div style="background:#1a1a2e;border:1px solid #2a2a3a;border-radius:10px;padding:16px">
1533
+ <div id="refreshStatsHeader" style="display:flex;align-items:center;gap:16px;cursor:pointer;user-select:none">
1534
+ <span style="font-size:14px;font-weight:600;color:#e0e0e0;position:relative">
1535
+ 📊 最近 <span id="refreshHoursLabel" style="color:#60a5fa;cursor:pointer;text-decoration:underline dotted" onclick="event.stopPropagation();toggleHoursDropdown(event)">${currentRefreshHours}h</span> 刷新进度
1536
+ <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">
1537
+ ${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("")}
1538
+ </div>
1539
+ </span>
1540
+ <span style="font-size:13px;color:#9ca3af">${data.refreshed} / ${data.total}</span>
1541
+ <div style="flex:1;max-width:200px;height:8px;background:#2a2a3a;border-radius:4px;overflow:hidden">
1542
+ <div style="height:100%;width:${barW}%;background:${pct >= 50 ? "#22c55e" : "#facc15"};border-radius:4px;transition:width 0.3s"></div>
1543
+ </div>
1544
+ <span style="font-size:14px;font-weight:700;color:${pct >= 50 ? "#22c55e" : "#facc15"}">${pct}%</span>
1545
+ <span id="refreshStatsArrow" style="color:#888;font-size:12px">▶</span>
1546
+ </div>
1547
+ <div id="refreshStatsBody" style="display:none;margin-top:12px">
1548
+ <table style="width:100%;border-collapse:collapse;font-size:12px">
1549
+ <thead><tr style="color:#888;text-align:left">
1550
+ <th style="padding:4px 8px">国家</th>
1551
+ <th style="padding:4px 8px;text-align:right">需刷新</th>
1552
+ <th style="padding:4px 8px;text-align:right">${currentRefreshHours}h已刷</th>
1553
+ <th style="padding:4px 8px;width:120px">进度</th>
1554
+ </tr></thead>
1555
+ <tbody>`;
1556
+
1557
+ for (const c of data.countries) {
1558
+ const cpct = c.total > 0 ? Math.round((c.refreshed / c.total) * 100) : 0;
1559
+ const cBarW = Math.min(cpct, 100);
1560
+ html += `<tr style="color:#ccc;border-top:1px solid #222">
1561
+ <td style="padding:4px 8px">${c.country}</td>
1562
+ <td style="padding:4px 8px;text-align:right">${c.total}</td>
1563
+ <td style="padding:4px 8px;text-align:right;color:${cpct >= 50 ? "#22c55e" : "#facc15"}">${c.refreshed}</td>
1564
+ <td style="padding:4px 8px">
1565
+ <div style="display:flex;align-items:center;gap:6px">
1566
+ <div style="flex:1;height:6px;background:#2a2a3a;border-radius:3px;overflow:hidden">
1567
+ <div style="height:100%;width:${cBarW}%;background:${cpct >= 50 ? "#22c55e" : "#facc15"};border-radius:3px"></div>
1568
+ </div>
1569
+ <span style="font-size:11px;color:#9ca3af;min-width:28px;text-align:right">${cpct}%</span>
1570
+ </div>
1571
+ </td>
1572
+ </tr>`;
1573
+ }
1574
+
1575
+ html += `</tbody></table></div></div>`;
1576
+ el.innerHTML = html;
1577
+
1578
+ // 绑定展开/折叠
1579
+ const header = document.getElementById("refreshStatsHeader");
1580
+ const body = document.getElementById("refreshStatsBody");
1581
+ const arrow = document.getElementById("refreshStatsArrow");
1582
+ if (header && body && arrow) {
1583
+ header.addEventListener("click", () => {
1584
+ const open = body.style.display !== "none";
1585
+ body.style.display = open ? "none" : "";
1586
+ arrow.textContent = open ? "▶" : "▼";
1587
+ });
1588
+ }
1589
+ }
1590
+
1335
1591
  function renderTagsCountryFilter() {
1336
1592
  const sel = document.getElementById("tagsCountryFilter");
1337
1593
  if (!sel) return;
@@ -1441,7 +1697,7 @@ function renderTagsTable() {
1441
1697
  const matchedAuthors = t.matched_authors ?? "-";
1442
1698
  const pushedUsers = t.pushed_users ?? "-";
1443
1699
  const refreshedAt = t.refreshed_at
1444
- ? t.refreshed_at.slice(0, 16).replace("T", " ")
1700
+ ? formatTime(new Date(t.refreshed_at).getTime())
1445
1701
  : "-";
1446
1702
 
1447
1703
  return `<tr>
@@ -1486,7 +1742,7 @@ document
1486
1742
  .addEventListener("click", async () => {
1487
1743
  showLoading("正在刷新...");
1488
1744
  try {
1489
- await Promise.all([loadTagStats(), loadTagsPage()]);
1745
+ await Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]);
1490
1746
  showToast("刷新完成");
1491
1747
  } catch (e) {
1492
1748
  showToast("刷新失败: " + e.message, true);
@@ -1519,6 +1775,46 @@ function loadMoreTags() {
1519
1775
  loadTagsPage(true);
1520
1776
  }
1521
1777
 
1778
+ // ====== 刷新统计时间切换 ======
1779
+
1780
+ function toggleHoursDropdown(event) {
1781
+ const dropdown = document.getElementById("refreshHoursDropdown");
1782
+ if (!dropdown) return;
1783
+ const isOpen = dropdown.style.display !== "none";
1784
+ dropdown.style.display = isOpen ? "none" : "block";
1785
+ }
1786
+
1787
+ async function selectRefreshHours(hours) {
1788
+ const dropdown = document.getElementById("refreshHoursDropdown");
1789
+ if (dropdown) dropdown.style.display = "none";
1790
+ if (hours === currentRefreshHours) return;
1791
+ currentRefreshHours = hours;
1792
+ showLoading("正在加载...");
1793
+ try {
1794
+ await loadRefreshStats();
1795
+ const label = document.getElementById("refreshHoursLabel");
1796
+ if (label) label.textContent = hours + "h";
1797
+ } catch (e) {
1798
+ console.error("刷新统计失败:", e);
1799
+ } finally {
1800
+ hideLoading();
1801
+ }
1802
+ }
1803
+
1804
+ // 点击空白处关闭下拉
1805
+ document.addEventListener("click", (e) => {
1806
+ const dropdown = document.getElementById("refreshHoursDropdown");
1807
+ const label = document.getElementById("refreshHoursLabel");
1808
+ if (
1809
+ dropdown &&
1810
+ dropdown.style.display !== "none" &&
1811
+ !label?.contains(e.target) &&
1812
+ !dropdown.contains(e.target)
1813
+ ) {
1814
+ dropdown.style.display = "none";
1815
+ }
1816
+ });
1817
+
1522
1818
  // 排序
1523
1819
  document.querySelectorAll(".sortable-tag").forEach((th) => {
1524
1820
  th.addEventListener("click", () => {
@@ -1816,7 +2112,7 @@ function renderTargetTable() {
1816
2112
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
1817
2113
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
1818
2114
  <td data-label="操作">
1819
- <button class="btn-non-seller" onclick="confirmNonSeller('${escapeJsString(u.uniqueId)}')" title="将此用户标记为非商家">设为非商家</button>
2115
+ <button class="btn-pending" onclick="moveToBase('${escapeJsString(u.uniqueId)}')" title="移到 jobs_base 重走流程">待确认</button>
1820
2116
  </td>
1821
2117
  </tr>`;
1822
2118
  })
@@ -2587,7 +2883,8 @@ fetchClientErrors();
2587
2883
  initTableSorting();
2588
2884
  initTargetTableSorting();
2589
2885
 
2886
+ // 错峰轮询,避免同时发 4 个请求阻塞服务端
2590
2887
  setInterval(fetchStats, 10000);
2591
- setInterval(fetchUsers, 10000);
2592
- setInterval(fetchClientErrors, 10000);
2593
- setInterval(refreshUserUpdateIfActive, 10000);
2888
+ setTimeout(() => setInterval(fetchUsers, 10000), 2500);
2889
+ setTimeout(() => setInterval(fetchClientErrors, 10000), 5000);
2890
+ setTimeout(() => setInterval(refreshUserUpdateIfActive, 10000), 7500);
@@ -370,7 +370,7 @@
370
370
  </div>
371
371
  </div>
372
372
  </div>
373
- <div id="backToTop" title="回到顶部" onclick="scrollToTop()">↑</div>
373
+ <div id="backToTop" title="回到顶部(可拖动)">↑</div>
374
374
  </div>
375
375
  <div id="tagsPage">
376
376
  <div class="stats" style="margin-bottom:16px">
@@ -397,6 +397,7 @@
397
397
  <div class="label">🔄 刷新</div>
398
398
  </div>
399
399
  </div>
400
+ <div id="tagsRefreshStats" style="margin-bottom:16px"></div>
400
401
  <div class="tags-layout">
401
402
  <div class="tags-table-card">
402
403
  <h3>关键词列表</h3>
@@ -306,6 +306,10 @@ body {
306
306
  font-weight: 600;
307
307
  }
308
308
 
309
+ .target-badge-other {
310
+ color: #666 !important;
311
+ }
312
+
309
313
  .source-row {
310
314
  display: flex;
311
315
  align-items: center;
@@ -869,6 +873,12 @@ td.user-id:hover {
869
873
  display: flex;
870
874
  }
871
875
 
876
+ #backToTop.dragging {
877
+ transition: none;
878
+ opacity: 0.9;
879
+ cursor: grabbing;
880
+ }
881
+
872
882
  #mainPage.hidden {
873
883
  display: none;
874
884
  }
@@ -1483,6 +1493,26 @@ td.user-id:hover {
1483
1493
  color: #ef4444;
1484
1494
  }
1485
1495
 
1496
+ /* 待确认按钮 */
1497
+ .btn-pending {
1498
+ padding: 4px 10px;
1499
+ border: 1px solid #fbbf24;
1500
+ border-radius: 4px;
1501
+ background: transparent;
1502
+ color: #fbbf24;
1503
+ font-size: 11px;
1504
+ font-weight: 600;
1505
+ cursor: pointer;
1506
+ transition: all 0.15s;
1507
+ white-space: nowrap;
1508
+ }
1509
+
1510
+ .btn-pending:hover {
1511
+ background: rgba(251, 191, 36, 0.12);
1512
+ border-color: #f59e0b;
1513
+ color: #f59e0b;
1514
+ }
1515
+
1486
1516
  /* ===== 关键词页面 ===== */
1487
1517
  #tagsPage {
1488
1518
  display: none;