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.
- package/package.json +1 -1
- package/src/cli/comments.js +6 -4
- package/src/cli/refresh.js +5 -0
- package/src/cli/tag.js +20 -18
- package/src/cli/watch.js +15 -4
- package/src/lib/args.js +14 -0
- package/src/lib/constants.js +13 -2
- package/src/lib/tag-fetcher.js +7 -1
- package/src/watch/data-store.js +114 -8
- package/src/watch/db-crud.js +16 -1
- package/src/watch/db-schema.js +5 -0
- package/src/watch/db-stats.js +10 -6
- package/src/watch/db-tags.js +106 -6
- package/src/watch/public/app.js +308 -10
- package/src/watch/public/index.html +6 -1
- package/src/watch/public/style.css +30 -0
- package/src/watch/server.js +240 -12
- package/src/watch/tag-service.js +317 -10
package/src/watch/db-tags.js
CHANGED
|
@@ -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
|
-
//
|
|
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 +=
|
|
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 +=
|
|
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 [];
|
package/src/watch/public/app.js
CHANGED
|
@@ -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 = [
|
|
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(
|
|
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
|
|
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-
|
|
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="
|
|
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;
|