tt-help-cli-ycl 1.4.16 → 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.
@@ -22,7 +22,13 @@ export function insertTag(tag, countries, source = "llm") {
22
22
  return { inserted: false, error: "invalid tag" };
23
23
  }
24
24
  try {
25
- // ON CONFLICT: 合并 countries 数组,避免跨国家共享 tag 时信息丢失
25
+ // 先查是否已存在且为 dead 状态,如果是则重置为 new(给二次机会)
26
+ const existing = db
27
+ .prepare("SELECT status FROM tags WHERE tag = ?")
28
+ .get(normalized);
29
+ const resetDead = existing && existing.status === "dead";
30
+
31
+ // ON CONFLICT: 合并 countries 数组;如果原状态是 dead 则重置为 new
26
32
  const result = db
27
33
  .prepare(
28
34
  `INSERT INTO tags (tag, countries, source) VALUES (@tag, @countries, @source)
@@ -32,9 +38,12 @@ export function insertTag(tag, countries, source = "llm") {
32
38
  UNION
33
39
  SELECT value FROM json_each(@countries)
34
40
  )
35
- )`,
41
+ )${resetDead ? ", status = 'new', score = NULL, last_error = NULL, dead_reason = NULL, scored_at = NULL, score_count = 0" : ""}`,
36
42
  )
37
43
  .run({ tag: normalized, countries: JSON.stringify(countries), source });
44
+ if (resetDead) {
45
+ console.error(`[tags] 🔄 dead tag #${normalized} 重新生成,重置为 new`);
46
+ }
38
47
  return { inserted: result.changes > 0, tag: normalized };
39
48
  } catch (e) {
40
49
  return { inserted: false, error: e.message };
@@ -55,7 +64,8 @@ export function getTagsByStatus(
55
64
  sql += " AND countries LIKE ?";
56
65
  params.push(`%"${country}"%`);
57
66
  }
58
- sql += " ORDER BY score ASC, created_at ASC LIMIT ? OFFSET ?";
67
+ sql +=
68
+ " ORDER BY refreshed_at DESC NULLS LAST, score ASC, created_at ASC LIMIT ? OFFSET ?";
59
69
  params.push(limit, offset);
60
70
  const rows = db.prepare(sql).all(...params);
61
71
  return rows.map(parseTagRow);
@@ -96,6 +106,48 @@ export function getTagStats(country = null) {
96
106
  };
97
107
  }
98
108
 
109
+ export function getRefreshStats(hours = 24) {
110
+ const db = getDb();
111
+ if (!db) return { countries: [], total: 0, refreshed: 0 };
112
+
113
+ const rows = db
114
+ .prepare(
115
+ "SELECT countries FROM tags WHERE status = 'productive' AND score > 70",
116
+ )
117
+ .all();
118
+ const countrySet = new Set();
119
+ for (const r of rows) {
120
+ try {
121
+ for (const c of JSON.parse(r.countries || "[]")) countrySet.add(c);
122
+ } catch {}
123
+ }
124
+
125
+ const results = [];
126
+ let totalAll = 0;
127
+ let refreshedAll = 0;
128
+ const countryList = [...countrySet].sort();
129
+
130
+ const stmtTotal = db.prepare(
131
+ "SELECT COUNT(*) as cnt FROM tags WHERE status = 'productive' AND score > 70 AND countries LIKE ?",
132
+ );
133
+ const stmtRefresh = db.prepare(
134
+ `SELECT COUNT(*) as cnt FROM tags WHERE status = 'productive' AND score > 70 AND countries LIKE ? AND refreshed_at >= datetime('now', ?)`,
135
+ );
136
+
137
+ for (const c of countryList) {
138
+ const like = `%"${c}"%`;
139
+ const total = stmtTotal.get(like).cnt;
140
+ const refreshed = stmtRefresh.get(like, `-${hours} hours`).cnt;
141
+ if (total > 0) {
142
+ totalAll += total;
143
+ refreshedAll += refreshed;
144
+ results.push({ country: c, total, refreshed });
145
+ }
146
+ }
147
+
148
+ return { countries: results, total: totalAll, refreshed: refreshedAll };
149
+ }
150
+
99
151
  export function getTagsByCountry(country, minScore = 0) {
100
152
  const db = getDb();
101
153
  if (!db) return [];
@@ -233,7 +285,8 @@ export function getAllTags(limit = 200, offset = 0, country = null) {
233
285
  sql += " WHERE countries LIKE ?";
234
286
  params.push(`%"${country}"%`);
235
287
  }
236
- sql += " ORDER BY score DESC, created_at DESC LIMIT ? OFFSET ?";
288
+ sql +=
289
+ " ORDER BY refreshed_at DESC NULLS LAST, score DESC, created_at DESC LIMIT ? OFFSET ?";
237
290
  params.push(limit, offset);
238
291
  const rows = db.prepare(sql).all(...params);
239
292
  return rows.map(parseTagRow);
@@ -319,6 +372,7 @@ export function insertDiscoverLog({
319
372
  round,
320
373
  strategy,
321
374
  tagsGenerated,
375
+ tagsRaw,
322
376
  tagsAdded,
323
377
  productiveSample,
324
378
  deadSample,
@@ -332,15 +386,16 @@ export function insertDiscoverLog({
332
386
  const result = db
333
387
  .prepare(
334
388
  `INSERT INTO discover_log
335
- (country, round, strategy, tags_generated, tags_added,
389
+ (country, round, strategy, tags_generated, tags_raw, tags_added,
336
390
  productive_sample, dead_sample, avg_productive_score, avg_dead_score)
337
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
391
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
338
392
  )
339
393
  .run(
340
394
  country,
341
395
  round,
342
396
  strategy || null,
343
397
  JSON.stringify(tagsGenerated || []),
398
+ tagsRaw ? JSON.stringify(tagsRaw) : null,
344
399
  tagsAdded || 0,
345
400
  JSON.stringify(productiveSample || []),
346
401
  JSON.stringify(deadSample || []),
@@ -386,6 +441,51 @@ export function getAllExistingTagNames() {
386
441
  return new Set(rows.map((r) => r.tag));
387
442
  }
388
443
 
444
+ /**
445
+ * 获取活跃 tag 名集合(仅 productive + scored),用于全局去重
446
+ * 排除 dead tag,因为 insertTag 会在冲突时将 dead 重置为 new。
447
+ */
448
+ export function getActiveTagNames() {
449
+ const db = getDb();
450
+ if (!db) return new Set();
451
+ const rows = db
452
+ .prepare("SELECT tag FROM tags WHERE status IN ('productive', 'scored')")
453
+ .all();
454
+ return new Set(rows.map((r) => r.tag));
455
+ }
456
+
457
+ /**
458
+ * 获取某国家最近一轮 discover 中 LLM 原始生成的全部 tag(去重前)
459
+ * 用于反馈给 LLM:"你上轮生成了这些,全部已被数据库覆盖,换方向"
460
+ */
461
+ export function getLastRoundAllGeneratedTags(country) {
462
+ const db = getDb();
463
+ if (!db) return null;
464
+
465
+ const row = db
466
+ .prepare(
467
+ `SELECT tags_raw, tags_generated, tags_added FROM discover_log
468
+ WHERE country = ?
469
+ ORDER BY round DESC
470
+ LIMIT 1`,
471
+ )
472
+ .get(country);
473
+
474
+ if (!row) return null;
475
+
476
+ const raw = JSON.parse(row.tags_raw || "[]");
477
+ const survived = JSON.parse(row.tags_generated || "[]");
478
+ const rejected = raw.filter((t) => !survived.includes(t));
479
+
480
+ return {
481
+ raw,
482
+ survived,
483
+ rejected,
484
+ tagsAdded: row.tags_added,
485
+ allRejected: row.tags_added === 0 && raw.length > 0,
486
+ };
487
+ }
488
+
389
489
  export function getRecentGeneratedTags(country, lookback = 10) {
390
490
  const db = getDb();
391
491
  if (!db) return [];
@@ -166,6 +166,7 @@ function renderStats() {
166
166
  flashEl("statTarget", d.targetUsers, { full: true });
167
167
  flashEl("statUserUpdateTasks", d.userUpdateTasks || 0, { full: true });
168
168
  flashEl("statRawJobs", d.rawJobs || 0);
169
+ flashEl("statCommentTask", d.commentTaskCount || 0, { full: true });
169
170
  flashEl("statTags", d.tagCount || 0, { full: true });
170
171
  // 同步子页面 stats
171
172
  const pendingTotal = document.getElementById("pendingStatTotal");
@@ -255,13 +256,21 @@ function renderActiveClients(clients) {
255
256
  const tbody = document.getElementById("activeClientsBody");
256
257
  if (!section || !bar) return;
257
258
 
258
- const types = ["explore", "refresh", "attach", "comments", "scoring"];
259
+ const types = [
260
+ "explore",
261
+ "refresh",
262
+ "attach",
263
+ "comments",
264
+ "scoring",
265
+ "tag-refresh",
266
+ ];
259
267
  const labels = {
260
268
  explore: "Explore",
261
269
  refresh: "Refresh",
262
270
  attach: "Attach",
263
271
  comments: "Comments",
264
272
  scoring: "Scoring",
273
+ "tag-refresh": "TagRefresh",
265
274
  };
266
275
  const grouped = {};
267
276
  for (const c of clients) {
@@ -351,7 +360,7 @@ function renderCountryChart(countries) {
351
360
  const isTarget = targetLocations.includes(c.country);
352
361
  const targetBadge =
353
362
  c.targetCount > 0
354
- ? `<span class="target-badge">🎯 ${c.targetCount}</span>`
363
+ ? `<span class="target-badge${isTarget ? "" : " target-badge-other"}">🎯 ${c.targetCount}</span>`
355
364
  : `<span class="target-badge" style="visibility:hidden"> </span>`;
356
365
  return `
357
366
  <div class="bar-row${isTarget ? " is-target" : ""}">
@@ -795,6 +804,55 @@ async function confirmNonSeller(uniqueId) {
795
804
  }
796
805
  }
797
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
+
798
856
  async function submitAddUsers() {
799
857
  const ta = document.getElementById("modalUserInput");
800
858
  const raw = ta.value.trim();
@@ -1050,6 +1108,121 @@ function scrollToTop() {
1050
1108
  window.scrollTo({ top: 0, behavior: "smooth" });
1051
1109
  }
1052
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
+
1053
1226
  function handleRoute() {
1054
1227
  const hash = window.location.hash;
1055
1228
  if (hash === "#pending") {
@@ -1290,6 +1463,7 @@ let currentTagsAllLoaded = false;
1290
1463
  let currentTagsSeq = 0;
1291
1464
  const TAGS_PAGE_SIZE = 200;
1292
1465
  let cachedTagCountries = [];
1466
+ let currentRefreshHours = 24;
1293
1467
 
1294
1468
  function showTagsPage() {
1295
1469
  document.getElementById("mainPage").classList.add("hidden");
@@ -1299,7 +1473,9 @@ function showTagsPage() {
1299
1473
  document.getElementById("targetPage").classList.remove("active");
1300
1474
  document.getElementById("tagsPage").classList.add("active");
1301
1475
  showLoading("正在加载关键词...");
1302
- Promise.all([loadTagStats(), loadTagsPage()]).finally(hideLoading);
1476
+ Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
1477
+ hideLoading,
1478
+ );
1303
1479
  }
1304
1480
 
1305
1481
  function setTagsFilter(filter) {
@@ -1324,13 +1500,94 @@ async function loadTagStats() {
1324
1500
  flashEl("tagsPageStatDead", stats.dead || 0);
1325
1501
  flashEl("tagsPageStatNew", (stats.new || 0) + (stats.scoring || 0));
1326
1502
  cachedTagCountries = stats.countries || [];
1327
- // 初始化国家下拉
1328
1503
  renderTagsCountryFilter();
1329
1504
  } catch (e) {
1330
1505
  console.error("获取关键词统计失败:", e);
1331
1506
  }
1332
1507
  }
1333
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
+
1334
1591
  function renderTagsCountryFilter() {
1335
1592
  const sel = document.getElementById("tagsCountryFilter");
1336
1593
  if (!sel) return;
@@ -1440,7 +1697,7 @@ function renderTagsTable() {
1440
1697
  const matchedAuthors = t.matched_authors ?? "-";
1441
1698
  const pushedUsers = t.pushed_users ?? "-";
1442
1699
  const refreshedAt = t.refreshed_at
1443
- ? t.refreshed_at.slice(0, 16).replace("T", " ")
1700
+ ? formatTime(new Date(t.refreshed_at).getTime())
1444
1701
  : "-";
1445
1702
 
1446
1703
  return `<tr>
@@ -1485,7 +1742,7 @@ document
1485
1742
  .addEventListener("click", async () => {
1486
1743
  showLoading("正在刷新...");
1487
1744
  try {
1488
- await Promise.all([loadTagStats(), loadTagsPage()]);
1745
+ await Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]);
1489
1746
  showToast("刷新完成");
1490
1747
  } catch (e) {
1491
1748
  showToast("刷新失败: " + e.message, true);
@@ -1518,6 +1775,46 @@ function loadMoreTags() {
1518
1775
  loadTagsPage(true);
1519
1776
  }
1520
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
+
1521
1818
  // 排序
1522
1819
  document.querySelectorAll(".sortable-tag").forEach((th) => {
1523
1820
  th.addEventListener("click", () => {
@@ -1815,7 +2112,7 @@ function renderTargetTable() {
1815
2112
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
1816
2113
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
1817
2114
  <td data-label="操作">
1818
- <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>
1819
2116
  </td>
1820
2117
  </tr>`;
1821
2118
  })
@@ -2586,7 +2883,8 @@ fetchClientErrors();
2586
2883
  initTableSorting();
2587
2884
  initTargetTableSorting();
2588
2885
 
2886
+ // 错峰轮询,避免同时发 4 个请求阻塞服务端
2589
2887
  setInterval(fetchStats, 10000);
2590
- setInterval(fetchUsers, 10000);
2591
- setInterval(fetchClientErrors, 10000);
2592
- setInterval(refreshUserUpdateIfActive, 10000);
2888
+ setTimeout(() => setInterval(fetchUsers, 10000), 2500);
2889
+ setTimeout(() => setInterval(fetchClientErrors, 10000), 5000);
2890
+ setTimeout(() => setInterval(refreshUserUpdateIfActive, 10000), 7500);
@@ -53,6 +53,10 @@
53
53
  <div class="label">目标商家</div>
54
54
  <div class="value target" id="statTarget">0</div>
55
55
  </div>
56
+ <div class="stat-card clickable pending-card" id="statCommentTaskCard">
57
+ <div class="label">待评论任务</div>
58
+ <div class="value target" id="statCommentTask">0</div>
59
+ </div>
56
60
  <div class="stat-card clickable pending-card" id="statTagsCard" onclick="navigateToTags()" style="cursor:pointer">
57
61
  <div class="label">关键词</div>
58
62
  <div class="value target" id="statTags">0</div>
@@ -366,7 +370,7 @@
366
370
  </div>
367
371
  </div>
368
372
  </div>
369
- <div id="backToTop" title="回到顶部" onclick="scrollToTop()">↑</div>
373
+ <div id="backToTop" title="回到顶部(可拖动)">↑</div>
370
374
  </div>
371
375
  <div id="tagsPage">
372
376
  <div class="stats" style="margin-bottom:16px">
@@ -393,6 +397,7 @@
393
397
  <div class="label">🔄 刷新</div>
394
398
  </div>
395
399
  </div>
400
+ <div id="tagsRefreshStats" style="margin-bottom:16px"></div>
396
401
  <div class="tags-layout">
397
402
  <div class="tags-table-card">
398
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;