tt-help-cli-ycl 1.3.57 → 1.3.58
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/lib/api-interceptor.js +6 -2
- package/src/scraper/explore-core.js +10 -0
- package/src/watch/data-store.js +30 -8
- package/src/watch/public/app.js +1266 -0
- package/src/watch/public/index.html +4 -2182
- package/src/watch/public/style.css +1070 -0
- package/src/watch/server.js +19 -0
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
const COLORS = [
|
|
2
|
+
"#fe2c55",
|
|
3
|
+
"#60a5fa",
|
|
4
|
+
"#4ade80",
|
|
5
|
+
"#facc15",
|
|
6
|
+
"#f97316",
|
|
7
|
+
"#a855f7",
|
|
8
|
+
"#ec4899",
|
|
9
|
+
"#14b8a6",
|
|
10
|
+
"#e11d48",
|
|
11
|
+
"#0ea5e9",
|
|
12
|
+
"#8b5cf6",
|
|
13
|
+
"#84cc16",
|
|
14
|
+
];
|
|
15
|
+
let currentFilter = "all";
|
|
16
|
+
let currentStats = null;
|
|
17
|
+
let currentUsers = [];
|
|
18
|
+
let currentLocation = "";
|
|
19
|
+
let currentTargetLocation = "";
|
|
20
|
+
let currentRawLocation = "";
|
|
21
|
+
let prevStatValues = {};
|
|
22
|
+
let prevUserMap = {};
|
|
23
|
+
|
|
24
|
+
async function fetchStats() {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch("/api/stats");
|
|
27
|
+
currentStats = await res.json();
|
|
28
|
+
renderStats();
|
|
29
|
+
renderLocationFilter();
|
|
30
|
+
} catch (e) {
|
|
31
|
+
document.getElementById("lastUpdate").textContent = "连接失败";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchUsers() {
|
|
36
|
+
try {
|
|
37
|
+
const params = new URLSearchParams();
|
|
38
|
+
if (currentFilter === "target") {
|
|
39
|
+
params.set("target", "1");
|
|
40
|
+
if (currentTargetLocation)
|
|
41
|
+
params.set("targetLocation", currentTargetLocation);
|
|
42
|
+
} else if (currentFilter !== "all") {
|
|
43
|
+
params.set("status", currentFilter);
|
|
44
|
+
}
|
|
45
|
+
const search = document.getElementById("searchInput").value.trim();
|
|
46
|
+
if (search) params.set("search", search);
|
|
47
|
+
if (currentLocation) params.set("location", currentLocation);
|
|
48
|
+
params.set("limit", "200");
|
|
49
|
+
const res = await fetch("/api/users?" + params.toString());
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
currentUsers = data.users || [];
|
|
52
|
+
renderTable(currentUsers);
|
|
53
|
+
} catch (e) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeHtml(str) {
|
|
57
|
+
const div = document.createElement("div");
|
|
58
|
+
div.textContent = str;
|
|
59
|
+
return div.innerHTML;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchClientErrors() {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch("/api/client-errors");
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
const clients = data.clients || [];
|
|
67
|
+
const section = document.getElementById("clientErrorsSection");
|
|
68
|
+
const badge = document.getElementById("clientErrorsBadge");
|
|
69
|
+
const tbody = document.getElementById("clientErrorsBody");
|
|
70
|
+
if (clients.length === 0) {
|
|
71
|
+
section.style.display = "none";
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
section.style.display = "";
|
|
75
|
+
badge.textContent = clients.length;
|
|
76
|
+
const typeMap = {
|
|
77
|
+
captcha: ["验证码", "error-type-captcha"],
|
|
78
|
+
network: ["网络", "error-type-network"],
|
|
79
|
+
other: ["其他", "error-type-other"],
|
|
80
|
+
被封: ["被封", "error-type-captcha"],
|
|
81
|
+
};
|
|
82
|
+
const stageMap = {
|
|
83
|
+
"video-page": "视频页",
|
|
84
|
+
comment: "评论",
|
|
85
|
+
follow: "关注/粉丝",
|
|
86
|
+
scrape: "scrape",
|
|
87
|
+
process: "处理",
|
|
88
|
+
};
|
|
89
|
+
tbody.innerHTML = clients
|
|
90
|
+
.map((c) => {
|
|
91
|
+
const [typeText, typeClass] = typeMap[c.errorType] || ["未知", ""];
|
|
92
|
+
const stageText = c.stage ? stageMap[c.stage] || c.stage : "";
|
|
93
|
+
const captchaText = c.captchaCount
|
|
94
|
+
? `${c.captchaCount}次${c.captchaStage ? " (" + (stageMap[c.captchaStage] || c.captchaStage) + ")" : ""}`
|
|
95
|
+
: "-";
|
|
96
|
+
const msgDetail = c.errorMessage
|
|
97
|
+
? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.errorMessage)}</span>`
|
|
98
|
+
: "";
|
|
99
|
+
const stackDetail = c.errorStack
|
|
100
|
+
? `<br><span style="color:#555;font-size:10px;max-width:250px;display:block;word-break:break-all">${escapeHtml(c.errorStack)}</span>`
|
|
101
|
+
: "";
|
|
102
|
+
const captchaDetail = c.captchaMessage
|
|
103
|
+
? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.captchaMessage)}</span>`
|
|
104
|
+
: "";
|
|
105
|
+
return `<tr>
|
|
106
|
+
<td style="font-family:monospace;font-weight:600;color:#60a5fa">${escapeHtml(c.userId)}</td>
|
|
107
|
+
<td class="${typeClass}">${typeText}</td>
|
|
108
|
+
<td style="color:#f87171;font-weight:600">${c.reportCount || 1}</td>
|
|
109
|
+
<td style="color:#f59e0b;font-weight:600">${captchaText}${captchaDetail}</td>
|
|
110
|
+
<td style="color:#a78bfa;font-size:12px">${stageText}</td>
|
|
111
|
+
<td style="color:#ccc;font-size:12px;max-width:300px;word-break:break-all">${msgDetail}${stackDetail}</td>
|
|
112
|
+
<td style="color:#60a5fa">@${escapeHtml(c.username || "-")}</td>
|
|
113
|
+
<td style="color:#888;font-size:12px">${new Date(c.timestamp).toLocaleTimeString()}</td>
|
|
114
|
+
<td><button class="btn-delete" onclick="deleteClientError('${escapeHtml(c.userId)}')" style="background:#991b1b;color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px">删除</button></td>
|
|
115
|
+
</tr>`;
|
|
116
|
+
})
|
|
117
|
+
.join("");
|
|
118
|
+
} catch (e) {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function deleteClientError(userId) {
|
|
122
|
+
try {
|
|
123
|
+
await fetch(`/api/client-error/${encodeURIComponent(userId)}`, {
|
|
124
|
+
method: "DELETE",
|
|
125
|
+
});
|
|
126
|
+
fetchClientErrors();
|
|
127
|
+
} catch (e) {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatStatNum(value, { full = false } = {}) {
|
|
131
|
+
const num = Number(value) || 0;
|
|
132
|
+
if (full) return num.toLocaleString("zh-CN");
|
|
133
|
+
if (Math.abs(num) < 1000) return String(num);
|
|
134
|
+
if (Math.abs(num) < 10000) return num.toLocaleString("zh-CN");
|
|
135
|
+
const wan = num / 10000;
|
|
136
|
+
return wan.toFixed(1).replace(/\.0+$/, "") + "万";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function flashEl(id, value, options) {
|
|
140
|
+
const el = document.getElementById(id);
|
|
141
|
+
if (!el) return;
|
|
142
|
+
const prev = prevStatValues[id];
|
|
143
|
+
el.textContent = formatStatNum(value, options);
|
|
144
|
+
if (prev !== undefined && prev !== value) {
|
|
145
|
+
el.classList.remove("flash-change");
|
|
146
|
+
void el.offsetWidth;
|
|
147
|
+
el.classList.add("flash-change");
|
|
148
|
+
}
|
|
149
|
+
prevStatValues[id] = value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderStats() {
|
|
153
|
+
if (!currentStats) return;
|
|
154
|
+
const d = currentStats;
|
|
155
|
+
flashEl("statTotal", d.totalUsers);
|
|
156
|
+
flashEl("statProcessing", d.processingUsers || 0);
|
|
157
|
+
flashEl("statDone", d.processedUsers);
|
|
158
|
+
flashEl("statPending", d.pendingUsers);
|
|
159
|
+
flashEl("statError", d.errorUsers);
|
|
160
|
+
flashEl("statRestricted", d.restrictedUsers);
|
|
161
|
+
flashEl("statTarget", d.targetUsers, { full: true });
|
|
162
|
+
flashEl("statUserUpdateTasks", d.userUpdateTasks || 0, { full: true });
|
|
163
|
+
flashEl("statRawJobs", d.rawJobs || 0);
|
|
164
|
+
// 同步子页面 stats
|
|
165
|
+
const pendingTotal = document.getElementById("pendingStatTotal");
|
|
166
|
+
if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
|
|
167
|
+
const pendingCount = document.getElementById("pendingStatPending");
|
|
168
|
+
if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
|
|
169
|
+
const pendingUserUpdate = document.getElementById(
|
|
170
|
+
"pendingStatUserUpdateTasks",
|
|
171
|
+
);
|
|
172
|
+
if (pendingUserUpdate)
|
|
173
|
+
pendingUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0, {
|
|
174
|
+
full: true,
|
|
175
|
+
});
|
|
176
|
+
const pendingRawJobs = document.getElementById("pendingStatRawJobs");
|
|
177
|
+
if (pendingRawJobs)
|
|
178
|
+
pendingRawJobs.textContent = formatStatNum(d.rawJobs || 0);
|
|
179
|
+
const userUpdateTotal = document.getElementById("userUpdateStatTotal");
|
|
180
|
+
if (userUpdateTotal)
|
|
181
|
+
userUpdateTotal.textContent = formatStatNum(d.totalUsers);
|
|
182
|
+
const userUpdatePending = document.getElementById("userUpdateStatPending");
|
|
183
|
+
if (userUpdatePending)
|
|
184
|
+
userUpdatePending.textContent = formatStatNum(d.pendingUsers);
|
|
185
|
+
const userUpdateTasks = document.getElementById(
|
|
186
|
+
"userUpdateStatUserUpdateTasks",
|
|
187
|
+
);
|
|
188
|
+
if (userUpdateTasks)
|
|
189
|
+
userUpdateTasks.textContent = formatStatNum(d.userUpdateTasks || 0, {
|
|
190
|
+
full: true,
|
|
191
|
+
});
|
|
192
|
+
const userUpdateRawJobs = document.getElementById("userUpdateStatRawJobs");
|
|
193
|
+
if (userUpdateRawJobs)
|
|
194
|
+
userUpdateRawJobs.textContent = formatStatNum(d.rawJobs || 0);
|
|
195
|
+
const rawPageRawJobs = document.getElementById("rawPageStatRawJobs");
|
|
196
|
+
if (rawPageRawJobs)
|
|
197
|
+
rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
|
|
198
|
+
const rawPagePending = document.getElementById("rawPageStatPending");
|
|
199
|
+
if (rawPagePending)
|
|
200
|
+
rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
|
|
201
|
+
const rawPageUserUpdate = document.getElementById(
|
|
202
|
+
"rawPageStatUserUpdateTasks",
|
|
203
|
+
);
|
|
204
|
+
if (rawPageUserUpdate)
|
|
205
|
+
rawPageUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
|
|
206
|
+
document.getElementById("lastUpdate").textContent =
|
|
207
|
+
"更新于 " + new Date().toLocaleTimeString();
|
|
208
|
+
document.getElementById("fileMeta").textContent =
|
|
209
|
+
formatStatNum(d.processingUsers || 0) +
|
|
210
|
+
" 处理中, " +
|
|
211
|
+
formatStatNum(d.totalUsers) +
|
|
212
|
+
" 个用户, 待处理 " +
|
|
213
|
+
formatStatNum(d.pendingUsers || 0);
|
|
214
|
+
|
|
215
|
+
renderCountryChart(d.countryStats);
|
|
216
|
+
renderSourceChart(d.sourceStats);
|
|
217
|
+
renderTargetLocationFilter(d.targetCountryStats);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderTargetLocationFilter(targetCountryStats) {
|
|
221
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
222
|
+
if (!sel) return;
|
|
223
|
+
const val = sel.value;
|
|
224
|
+
sel.innerHTML =
|
|
225
|
+
'<option value="">全部目标国家</option>' +
|
|
226
|
+
(targetCountryStats || [])
|
|
227
|
+
.map(
|
|
228
|
+
(c) =>
|
|
229
|
+
`<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
|
|
230
|
+
)
|
|
231
|
+
.join("");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderCountryChart(countries) {
|
|
235
|
+
const el = document.getElementById("countryChart");
|
|
236
|
+
const filtered = countries.filter((c) => c.country !== "未知");
|
|
237
|
+
if (!filtered.length) {
|
|
238
|
+
el.innerHTML = '<span style="color:#666;font-size:12px">暂无数据</span>';
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const max = filtered[0].count;
|
|
242
|
+
const top = filtered.slice(0, 15);
|
|
243
|
+
const targetLocations = currentStats?.targetLocations || [];
|
|
244
|
+
el.innerHTML = top
|
|
245
|
+
.map((c, i) => {
|
|
246
|
+
const isTarget = targetLocations.includes(c.country);
|
|
247
|
+
const targetBadge =
|
|
248
|
+
c.targetCount > 0
|
|
249
|
+
? `<span class="target-badge">🎯 ${c.targetCount}</span>`
|
|
250
|
+
: `<span class="target-badge" style="visibility:hidden"> </span>`;
|
|
251
|
+
return `
|
|
252
|
+
<div class="bar-row${isTarget ? " is-target" : ""}">
|
|
253
|
+
<span class="name">${c.country}</span>
|
|
254
|
+
<div class="bar-bg"><div class="bar-fill" style="width:${(c.count / max) * 100}%;background:${isTarget ? "#a78bfa" : COLORS[i % COLORS.length]}">${c.count}</div></div>
|
|
255
|
+
${targetBadge}
|
|
256
|
+
<span class="count">${currentStats ? ((c.count / currentStats.totalUsers) * 100).toFixed(1) : 0}%</span>
|
|
257
|
+
</div>
|
|
258
|
+
`;
|
|
259
|
+
})
|
|
260
|
+
.join("");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function renderSourceChart(sources) {
|
|
264
|
+
const el = document.getElementById("sourceChart");
|
|
265
|
+
const labels = {
|
|
266
|
+
seed: "种子",
|
|
267
|
+
video: "视频发现",
|
|
268
|
+
comment: "评论发现",
|
|
269
|
+
guess: "猜你喜欢",
|
|
270
|
+
following: "关注",
|
|
271
|
+
follower: "粉丝",
|
|
272
|
+
processed: "已处理",
|
|
273
|
+
restricted: "受限(跳过)",
|
|
274
|
+
error: "错误(待重试)",
|
|
275
|
+
noVideo: "无视频",
|
|
276
|
+
};
|
|
277
|
+
const entries = Object.entries(sources);
|
|
278
|
+
el.innerHTML = entries
|
|
279
|
+
.map(
|
|
280
|
+
([key, val]) => `
|
|
281
|
+
<div class="source-row"><span class="s-name">${labels[key] || key}:</span><span class="s-val">${val}</span></div>
|
|
282
|
+
`,
|
|
283
|
+
)
|
|
284
|
+
.join("");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderTable(users) {
|
|
288
|
+
const el = document.getElementById("userTable");
|
|
289
|
+
|
|
290
|
+
const newUserMap = {};
|
|
291
|
+
for (const u of users) newUserMap[u.uniqueId] = u;
|
|
292
|
+
|
|
293
|
+
el.innerHTML = users
|
|
294
|
+
.map((u) => {
|
|
295
|
+
const wasStatus = prevUserMap[u.uniqueId]?.status;
|
|
296
|
+
const nowStatus = u.status;
|
|
297
|
+
const changed =
|
|
298
|
+
wasStatus !== nowStatus &&
|
|
299
|
+
(nowStatus === "done" ||
|
|
300
|
+
nowStatus === "restricted" ||
|
|
301
|
+
nowStatus === "error");
|
|
302
|
+
const rowClass = changed ? ' class="row-flash"' : "";
|
|
303
|
+
|
|
304
|
+
const statusTags = {
|
|
305
|
+
restricted: '<span class="tag error">受限(跳过)</span>',
|
|
306
|
+
error: '<span class="tag error">错误(待重试)</span>',
|
|
307
|
+
done: '<span class="tag processed">已完成</span>',
|
|
308
|
+
processing: '<span class="tag processing">处理中</span>',
|
|
309
|
+
pending: '<span class="tag pending">待处理</span>',
|
|
310
|
+
};
|
|
311
|
+
const statusTag =
|
|
312
|
+
statusTags[u.status] ||
|
|
313
|
+
'<span class="tag pending">' + (u.status || "未知") + "</span>";
|
|
314
|
+
const sources = (u.sources || []).join(", ");
|
|
315
|
+
const extraTags = [];
|
|
316
|
+
if (u.pinned) extraTags.push('<span class="tag pinned">📌 置顶</span>');
|
|
317
|
+
if (u.ttSeller) extraTags.push('<span class="tag seller">商家</span>');
|
|
318
|
+
if (u.verified) extraTags.push('<span class="tag verified">认证</span>');
|
|
319
|
+
if (u.noVideo) extraTags.push('<span class="tag no-video">无视频</span>');
|
|
320
|
+
if (u.keepFollow)
|
|
321
|
+
extraTags.push('<span class="tag keep-follow">关注已保留</span>');
|
|
322
|
+
if (u.hasFollowData === false)
|
|
323
|
+
extraTags.push('<span class="tag no-follow">关注未获取</span>');
|
|
324
|
+
const nick = (u.nickname || "")
|
|
325
|
+
.replace(/</g, "<")
|
|
326
|
+
.replace(/>/g, ">");
|
|
327
|
+
const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
|
|
328
|
+
const videos = u.videoCount != null ? u.videoCount : "-";
|
|
329
|
+
const loc = u.locationCreated || "-";
|
|
330
|
+
const latestVideo = u.latestVideoTime
|
|
331
|
+
? formatTime(u.latestVideoTime * 1000)
|
|
332
|
+
: "-";
|
|
333
|
+
const guessedLoc = u.guessedLocation || "-";
|
|
334
|
+
const claimer = u.claimedBy || "-";
|
|
335
|
+
const claimTime = u.claimedAt ? formatTime(u.claimedAt) : "-";
|
|
336
|
+
const procTime = u.processedAt ? formatTime(u.processedAt) : "-";
|
|
337
|
+
const statusCodeDisplay =
|
|
338
|
+
u.statusCode != null && u.statusCode !== 0
|
|
339
|
+
? `<span class="tag error" style="font-size:10px">${u.statusCode}</span>`
|
|
340
|
+
: "";
|
|
341
|
+
return `<tr${rowClass}>
|
|
342
|
+
<td class="user-id" data-label="用户名">@${u.uniqueId}</td>
|
|
343
|
+
<td data-label="昵称">${nick}</td>
|
|
344
|
+
<td data-label="粉丝">${fans}</td>
|
|
345
|
+
<td data-label="视频">${videos}</td>
|
|
346
|
+
<td data-label="国家">${loc}</td>
|
|
347
|
+
<td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
|
|
348
|
+
<td data-label="猜测国家">${guessedLoc}</td>
|
|
349
|
+
<td data-label="来源">${sources || "-"}</td>
|
|
350
|
+
<td data-label="状态">${statusTag} ${extraTags.join(" ")}</td>
|
|
351
|
+
<td data-label="StatusCode">${statusCodeDisplay}</td>
|
|
352
|
+
<td data-label="处理端" style="font-size:11px;color:#888">${claimer}</td>
|
|
353
|
+
<td data-label="领取时间" style="font-size:11px;color:#888">${claimTime}</td>
|
|
354
|
+
<td data-label="完成时间" style="font-size:11px;color:#888">${procTime}</td>
|
|
355
|
+
</tr>`;
|
|
356
|
+
})
|
|
357
|
+
.join("");
|
|
358
|
+
|
|
359
|
+
const errorCount = users.filter((u) => u.status === "error").length;
|
|
360
|
+
const countEl = document.getElementById("batchResetCount");
|
|
361
|
+
if (countEl) countEl.textContent = errorCount;
|
|
362
|
+
|
|
363
|
+
prevUserMap = newUserMap;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function formatNum(n) {
|
|
367
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
|
368
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
|
369
|
+
return n;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatTime(ts) {
|
|
373
|
+
const d = new Date(ts);
|
|
374
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
375
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function setFilter(f) {
|
|
379
|
+
currentFilter = f;
|
|
380
|
+
document.querySelectorAll(".controls button").forEach((b) => {
|
|
381
|
+
b.classList.toggle("active", b.dataset.filter === f);
|
|
382
|
+
});
|
|
383
|
+
const btn = document.getElementById("batchResetBtn");
|
|
384
|
+
btn.style.display = f === "error" ? "" : "none";
|
|
385
|
+
const targetLocSel = document.getElementById("targetLocationFilter");
|
|
386
|
+
if (f === "target") {
|
|
387
|
+
targetLocSel.style.display = "";
|
|
388
|
+
} else {
|
|
389
|
+
targetLocSel.style.display = "none";
|
|
390
|
+
currentTargetLocation = "";
|
|
391
|
+
}
|
|
392
|
+
fetchUsers();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderLocationFilter() {
|
|
396
|
+
if (!currentStats || !currentStats.countryStats) return;
|
|
397
|
+
const sel = document.getElementById("locationFilter");
|
|
398
|
+
if (!sel) return;
|
|
399
|
+
const val = sel.value;
|
|
400
|
+
const entries = currentStats.countryStats.sort((a, b) => b.count - a.count);
|
|
401
|
+
sel.innerHTML =
|
|
402
|
+
'<option value="">全部国家</option>' +
|
|
403
|
+
entries
|
|
404
|
+
.map(
|
|
405
|
+
(c) =>
|
|
406
|
+
`<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
|
|
407
|
+
)
|
|
408
|
+
.join("");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function onLocationChange() {
|
|
412
|
+
const sel = document.getElementById("locationFilter");
|
|
413
|
+
currentLocation = sel.value;
|
|
414
|
+
fetchUsers();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function onTargetLocationChange() {
|
|
418
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
419
|
+
currentTargetLocation = sel.value;
|
|
420
|
+
fetchUsers();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let searchTimer = null;
|
|
424
|
+
document.getElementById("searchInput").addEventListener("input", () => {
|
|
425
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
426
|
+
searchTimer = setTimeout(fetchUsers, 300);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let rawSearchTimer = null;
|
|
430
|
+
document.getElementById("rawSearchInput").addEventListener("input", () => {
|
|
431
|
+
if (rawSearchTimer) clearTimeout(rawSearchTimer);
|
|
432
|
+
rawSearchTimer = setTimeout(fetchRawJobs, 300);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
function parseUsernames(raw) {
|
|
436
|
+
return raw
|
|
437
|
+
.split(/[,,\n\r]+/)
|
|
438
|
+
.map((s) => s.replace(/^@/, "").trim())
|
|
439
|
+
.filter(Boolean);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function openAddModal() {
|
|
443
|
+
let overlay = document.getElementById("addModalOverlay");
|
|
444
|
+
if (overlay) return;
|
|
445
|
+
overlay = document.createElement("div");
|
|
446
|
+
overlay.id = "addModalOverlay";
|
|
447
|
+
overlay.className = "modal-overlay";
|
|
448
|
+
overlay.innerHTML = `
|
|
449
|
+
<div class="modal">
|
|
450
|
+
<h3>插入用户到队列</h3>
|
|
451
|
+
<div class="hint">每行一个用户名,或用逗号分隔。支持 @username 或 username 格式。插入到队列最前面优先处理。</div>
|
|
452
|
+
<textarea id="modalUserInput" placeholder="例如: user1 user2 user3 或:user1, user2, user3"></textarea>
|
|
453
|
+
<div class="preview" id="modalPreview"></div>
|
|
454
|
+
<div class="btn-row">
|
|
455
|
+
<button class="btn-cancel" onclick="closeAddModal()">取消</button>
|
|
456
|
+
<button class="btn-submit" onclick="submitAddUsers()">确认插入</button>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
`;
|
|
460
|
+
document.body.appendChild(overlay);
|
|
461
|
+
overlay.addEventListener("click", (e) => {
|
|
462
|
+
if (e.target === overlay) closeAddModal();
|
|
463
|
+
});
|
|
464
|
+
const ta = document.getElementById("modalUserInput");
|
|
465
|
+
ta.focus();
|
|
466
|
+
ta.addEventListener("input", () => {
|
|
467
|
+
const names = parseUsernames(ta.value);
|
|
468
|
+
const preview = document.getElementById("modalPreview");
|
|
469
|
+
preview.textContent = names.length ? `共 ${names.length} 个用户名` : "";
|
|
470
|
+
});
|
|
471
|
+
ta.addEventListener("keydown", (e) => {
|
|
472
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
submitAddUsers();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function closeAddModal() {
|
|
480
|
+
const overlay = document.getElementById("addModalOverlay");
|
|
481
|
+
if (overlay) overlay.remove();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function submitAddUsers() {
|
|
485
|
+
const ta = document.getElementById("modalUserInput");
|
|
486
|
+
const raw = ta.value.trim();
|
|
487
|
+
if (!raw) return;
|
|
488
|
+
|
|
489
|
+
const names = parseUsernames(raw);
|
|
490
|
+
if (names.length === 0) return;
|
|
491
|
+
|
|
492
|
+
showLoading("正在添加用户...");
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetch("/api/users", {
|
|
495
|
+
method: "POST",
|
|
496
|
+
headers: { "Content-Type": "application/json" },
|
|
497
|
+
body: JSON.stringify({ usernames: names }),
|
|
498
|
+
});
|
|
499
|
+
const data = await res.json();
|
|
500
|
+
if (data.error) {
|
|
501
|
+
showToast(data.error, true);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
closeAddModal();
|
|
505
|
+
showToast(data.message || `已插入 ${data.added} 个用户`);
|
|
506
|
+
fetchStats();
|
|
507
|
+
fetchUsers();
|
|
508
|
+
} catch (e) {
|
|
509
|
+
showToast("插入失败: " + e.message, true);
|
|
510
|
+
} finally {
|
|
511
|
+
hideLoading();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function showToast(msg, isError) {
|
|
516
|
+
let toast =
|
|
517
|
+
document.getElementById("globalToast") || document.getElementById("toast");
|
|
518
|
+
if (!toast) {
|
|
519
|
+
toast = document.createElement("div");
|
|
520
|
+
toast.id = "globalToast";
|
|
521
|
+
toast.className = "toast";
|
|
522
|
+
toast.style.display = "none";
|
|
523
|
+
document.body.appendChild(toast);
|
|
524
|
+
}
|
|
525
|
+
toast.textContent = msg;
|
|
526
|
+
toast.className = "toast" + (isError ? " error" : "");
|
|
527
|
+
toast.style.display = "block";
|
|
528
|
+
setTimeout(() => {
|
|
529
|
+
toast.style.display = "none";
|
|
530
|
+
}, 3000);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function showLoading(text) {
|
|
534
|
+
let overlay = document.getElementById("loadingOverlay");
|
|
535
|
+
if (!overlay) {
|
|
536
|
+
overlay = document.createElement("div");
|
|
537
|
+
overlay.id = "loadingOverlay";
|
|
538
|
+
overlay.className = "loading-overlay";
|
|
539
|
+
overlay.innerHTML = `
|
|
540
|
+
<div class="loading-spinner">
|
|
541
|
+
<div class="spinner"></div>
|
|
542
|
+
<div class="loading-text" id="loadingText">处理中...</div>
|
|
543
|
+
</div>
|
|
544
|
+
`;
|
|
545
|
+
document.body.appendChild(overlay);
|
|
546
|
+
}
|
|
547
|
+
const textEl = document.getElementById("loadingText");
|
|
548
|
+
if (textEl) textEl.textContent = text || "处理中...";
|
|
549
|
+
overlay.classList.add("visible");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function hideLoading() {
|
|
553
|
+
const overlay = document.getElementById("loadingOverlay");
|
|
554
|
+
if (overlay) overlay.classList.remove("visible");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function escapeJsString(str) {
|
|
558
|
+
return String(str).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
document.addEventListener("keydown", (e) => {
|
|
562
|
+
if (e.key === "Escape") closeAddModal();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
document.getElementById("userTable").addEventListener("click", (e) => {
|
|
566
|
+
const td = e.target.closest("td.user-id");
|
|
567
|
+
if (!td) return;
|
|
568
|
+
hideContextMenu();
|
|
569
|
+
const username = td.textContent.trim().replace(/^@/, "");
|
|
570
|
+
if (!username) return;
|
|
571
|
+
window.open("https://www.tiktok.com/@" + username, "_blank");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
let contextMenuEl = null;
|
|
575
|
+
let contextMenuUserId = null;
|
|
576
|
+
let contextMenuPinned = false;
|
|
577
|
+
|
|
578
|
+
function showContextMenu(x, y, uniqueId, pinned) {
|
|
579
|
+
hideContextMenu();
|
|
580
|
+
contextMenuUserId = uniqueId;
|
|
581
|
+
contextMenuPinned = !!pinned;
|
|
582
|
+
contextMenuEl = document.createElement("div");
|
|
583
|
+
contextMenuEl.className = "context-menu";
|
|
584
|
+
contextMenuEl.innerHTML = `
|
|
585
|
+
<div class="context-menu-item" data-action="pin">${contextMenuPinned ? "📌 取消置顶" : "📍 置顶优先"}</div>
|
|
586
|
+
<div class="context-menu-item" data-action="reset">↻ 重新处理</div>
|
|
587
|
+
<div class="context-menu-item" data-action="open">🔗 打开主页</div>
|
|
588
|
+
`;
|
|
589
|
+
document.body.appendChild(contextMenuEl);
|
|
590
|
+
contextMenuEl.style.left = Math.min(x, window.innerWidth - 160) + "px";
|
|
591
|
+
contextMenuEl.style.top = Math.min(y, window.innerHeight - 100) + "px";
|
|
592
|
+
|
|
593
|
+
contextMenuEl.addEventListener("click", (e) => {
|
|
594
|
+
const item = e.target.closest(".context-menu-item");
|
|
595
|
+
if (!item) return;
|
|
596
|
+
const action = item.dataset.action;
|
|
597
|
+
if (action === "pin") togglePin(contextMenuUserId);
|
|
598
|
+
if (action === "reset") resetJob(contextMenuUserId);
|
|
599
|
+
if (action === "open")
|
|
600
|
+
window.open("https://www.tiktok.com/@" + contextMenuUserId, "_blank");
|
|
601
|
+
hideContextMenu();
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function hideContextMenu() {
|
|
606
|
+
if (contextMenuEl) {
|
|
607
|
+
contextMenuEl.remove();
|
|
608
|
+
contextMenuEl = null;
|
|
609
|
+
contextMenuUserId = null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
document.getElementById("userTable").addEventListener("contextmenu", (e) => {
|
|
614
|
+
const td = e.target.closest("td");
|
|
615
|
+
if (!td || td.parentElement.tagName !== "TR") return;
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
const tr = td.parentElement;
|
|
618
|
+
const userIdTd = tr.querySelector("td.user-id");
|
|
619
|
+
if (!userIdTd) return;
|
|
620
|
+
const uniqueId = userIdTd.textContent.trim().replace(/^@/, "");
|
|
621
|
+
const pinned = !!tr.querySelector(".tag.pinned");
|
|
622
|
+
showContextMenu(e.clientX, e.clientY, uniqueId, pinned);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
document.addEventListener("click", (e) => {
|
|
626
|
+
if (contextMenuEl && !contextMenuEl.contains(e.target)) {
|
|
627
|
+
hideContextMenu();
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
async function togglePin(uniqueId) {
|
|
632
|
+
showLoading("操作中...");
|
|
633
|
+
try {
|
|
634
|
+
const res = await fetch(
|
|
635
|
+
"/api/job/" + encodeURIComponent(uniqueId) + "/pin",
|
|
636
|
+
{
|
|
637
|
+
method: "POST",
|
|
638
|
+
},
|
|
639
|
+
);
|
|
640
|
+
const data = await res.json();
|
|
641
|
+
if (data.saved) {
|
|
642
|
+
showToast(data.pinned ? "已置顶" : "已取消置顶");
|
|
643
|
+
fetchUsers();
|
|
644
|
+
} else {
|
|
645
|
+
showToast(data.error || "操作失败", true);
|
|
646
|
+
}
|
|
647
|
+
} catch (e) {
|
|
648
|
+
showToast("操作失败: " + e.message, true);
|
|
649
|
+
} finally {
|
|
650
|
+
hideLoading();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function resetJob(uniqueId) {
|
|
655
|
+
showLoading("重置任务...");
|
|
656
|
+
try {
|
|
657
|
+
const res = await fetch(
|
|
658
|
+
"/api/job/" + encodeURIComponent(uniqueId) + "/reset",
|
|
659
|
+
{
|
|
660
|
+
method: "POST",
|
|
661
|
+
},
|
|
662
|
+
);
|
|
663
|
+
const data = await res.json();
|
|
664
|
+
if (data.saved) {
|
|
665
|
+
showToast("已重置任务");
|
|
666
|
+
fetchUsers();
|
|
667
|
+
fetchStats();
|
|
668
|
+
} else {
|
|
669
|
+
showToast(data.error || "重置失败", true);
|
|
670
|
+
}
|
|
671
|
+
} catch (e) {
|
|
672
|
+
showToast("重置失败: " + e.message, true);
|
|
673
|
+
} finally {
|
|
674
|
+
hideLoading();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function batchResetErrors() {
|
|
679
|
+
const errorUsers = currentUsers.filter((u) => u.status === "error");
|
|
680
|
+
if (errorUsers.length === 0) {
|
|
681
|
+
showToast("没有需要重置的错误用户", true);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const userIds = errorUsers.map((u) => u.uniqueId);
|
|
685
|
+
showLoading("正在批量重置...");
|
|
686
|
+
try {
|
|
687
|
+
const res = await fetch("/api/jobs/batch-reset", {
|
|
688
|
+
method: "POST",
|
|
689
|
+
headers: { "Content-Type": "application/json" },
|
|
690
|
+
body: JSON.stringify({ userIds }),
|
|
691
|
+
});
|
|
692
|
+
const data = await res.json();
|
|
693
|
+
if (data.error) {
|
|
694
|
+
showToast(data.error, true);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
showToast(`已重置 ${data.reset} / ${data.total} 个用户`);
|
|
698
|
+
fetchUsers();
|
|
699
|
+
fetchStats();
|
|
700
|
+
} catch (e) {
|
|
701
|
+
showToast("批量重置失败: " + e.message, true);
|
|
702
|
+
} finally {
|
|
703
|
+
hideLoading();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
document
|
|
708
|
+
.getElementById("statTargetCard")
|
|
709
|
+
.addEventListener("click", async () => {
|
|
710
|
+
showLoading("正在导出目标用户...");
|
|
711
|
+
try {
|
|
712
|
+
const res = await fetch("/api/target-users", {
|
|
713
|
+
headers: { Accept: "text/csv" },
|
|
714
|
+
});
|
|
715
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
716
|
+
const blob = await res.blob();
|
|
717
|
+
const ext = blob.size < 200 ? "json" : "csv";
|
|
718
|
+
if (ext === "json") {
|
|
719
|
+
const text = await blob.text();
|
|
720
|
+
const data = JSON.parse(text);
|
|
721
|
+
if (!data.users.length) {
|
|
722
|
+
showToast("暂无目标用户", true);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
726
|
+
const ids = data.users.map((u) => "@" + u.uniqueId).join(", ");
|
|
727
|
+
await navigator.clipboard.writeText(ids);
|
|
728
|
+
showToast(data.users.length + " 个目标用户 ID 已复制到剪贴板");
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const url = URL.createObjectURL(blob);
|
|
733
|
+
const a = document.createElement("a");
|
|
734
|
+
a.href = url;
|
|
735
|
+
a.download = "target-users.csv";
|
|
736
|
+
a.click();
|
|
737
|
+
URL.revokeObjectURL(url);
|
|
738
|
+
showToast("CSV 文件已开始下载");
|
|
739
|
+
} catch (e) {
|
|
740
|
+
showToast("获取失败: " + e.message, true);
|
|
741
|
+
} finally {
|
|
742
|
+
hideLoading();
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// Hash 路由
|
|
747
|
+
window.addEventListener("hashchange", handleRoute);
|
|
748
|
+
window.addEventListener("DOMContentLoaded", handleRoute);
|
|
749
|
+
|
|
750
|
+
function handleRoute() {
|
|
751
|
+
const hash = window.location.hash;
|
|
752
|
+
if (hash === "#pending") {
|
|
753
|
+
showPendingPage();
|
|
754
|
+
} else if (hash === "#userUpdate") {
|
|
755
|
+
showUserUpdatePage();
|
|
756
|
+
} else if (hash === "#raw") {
|
|
757
|
+
showRawPage();
|
|
758
|
+
} else {
|
|
759
|
+
showMainPage();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function navigateToPending() {
|
|
764
|
+
window.location.hash = "#pending";
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function navigateToUserUpdate() {
|
|
768
|
+
window.location.hash = "#userUpdate";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function navigateToRaw() {
|
|
772
|
+
window.location.hash = "#raw";
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function navigateToMain() {
|
|
776
|
+
window.location.hash = "";
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function showPendingPage() {
|
|
780
|
+
document.getElementById("mainPage").classList.add("hidden");
|
|
781
|
+
document.getElementById("pendingPage").classList.add("active");
|
|
782
|
+
document.getElementById("userUpdatePage").classList.remove("active");
|
|
783
|
+
document.getElementById("rawPage").classList.remove("active");
|
|
784
|
+
fetchPendingByCountry();
|
|
785
|
+
fetchAttachStuckByCountry();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function showUserUpdatePage() {
|
|
789
|
+
document.getElementById("mainPage").classList.add("hidden");
|
|
790
|
+
document.getElementById("pendingPage").classList.remove("active");
|
|
791
|
+
document.getElementById("userUpdatePage").classList.add("active");
|
|
792
|
+
document.getElementById("rawPage").classList.remove("active");
|
|
793
|
+
fetchUserUpdateByCountry();
|
|
794
|
+
fetchAttachStuckByCountry();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function refreshUserUpdateIfActive() {
|
|
798
|
+
if (document.getElementById("userUpdatePage").classList.contains("active")) {
|
|
799
|
+
fetchUserUpdateByCountry();
|
|
800
|
+
fetchAttachStuckByCountry();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function showRawPage() {
|
|
805
|
+
document.getElementById("mainPage").classList.add("hidden");
|
|
806
|
+
document.getElementById("pendingPage").classList.remove("active");
|
|
807
|
+
document.getElementById("userUpdatePage").classList.remove("active");
|
|
808
|
+
document.getElementById("rawPage").classList.add("active");
|
|
809
|
+
fetchRawByCountry();
|
|
810
|
+
fetchRawJobs();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function showMainPage() {
|
|
814
|
+
document.getElementById("mainPage").classList.remove("hidden");
|
|
815
|
+
document.getElementById("pendingPage").classList.remove("active");
|
|
816
|
+
document.getElementById("userUpdatePage").classList.remove("active");
|
|
817
|
+
document.getElementById("rawPage").classList.remove("active");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function fetchPendingByCountry() {
|
|
821
|
+
try {
|
|
822
|
+
const res = await fetch("/api/pending-by-country");
|
|
823
|
+
const data = await res.json();
|
|
824
|
+
renderPendingCountryGrid(data.countries || []);
|
|
825
|
+
} catch (e) {
|
|
826
|
+
console.error("获取待处理国家分布失败:", e);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function renderPendingCountryGrid(countries) {
|
|
831
|
+
const grid = document.getElementById("pendingCountryGrid");
|
|
832
|
+
if (!countries.length) {
|
|
833
|
+
grid.innerHTML =
|
|
834
|
+
'<span style="color:#666;font-size:12px">暂无待处理任务</span>';
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const total = countries.reduce((sum, c) => sum + c.count, 0);
|
|
838
|
+
grid.innerHTML = countries
|
|
839
|
+
.map((c) => {
|
|
840
|
+
const pct = ((c.count / total) * 100).toFixed(1);
|
|
841
|
+
const isUnknown = c.country === "未知";
|
|
842
|
+
const safeCountry = escapeJsString(c.country);
|
|
843
|
+
return `
|
|
844
|
+
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
845
|
+
onclick="filterByPendingCountry('${safeCountry}')">
|
|
846
|
+
<button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
|
|
847
|
+
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
848
|
+
<div class="country-count">${c.count}</div>
|
|
849
|
+
<div class="country-label">${pct}% 待处理</div>
|
|
850
|
+
</div>
|
|
851
|
+
`;
|
|
852
|
+
})
|
|
853
|
+
.join("");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function filterByPendingCountry(country) {
|
|
857
|
+
window.location.hash = "";
|
|
858
|
+
setTimeout(() => {
|
|
859
|
+
setFilter("pending");
|
|
860
|
+
const locationFilter = document.getElementById("locationFilter");
|
|
861
|
+
if (locationFilter && country && country !== "未知") {
|
|
862
|
+
const options = locationFilter.options;
|
|
863
|
+
for (let i = 0; i < options.length; i++) {
|
|
864
|
+
if (options[i].value === country) {
|
|
865
|
+
locationFilter.value = country;
|
|
866
|
+
onLocationChange();
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}, 100);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function fetchUserUpdateByCountry() {
|
|
875
|
+
try {
|
|
876
|
+
const res = await fetch("/api/user-update-by-country");
|
|
877
|
+
const data = await res.json();
|
|
878
|
+
renderUserUpdateCountryGrid(data.countries || []);
|
|
879
|
+
} catch (e) {
|
|
880
|
+
console.error("获取待补资料国家分布失败:", e);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function renderUserUpdateCountryGrid(countries) {
|
|
885
|
+
const grid = document.getElementById("userUpdateCountryGrid");
|
|
886
|
+
if (!countries.length) {
|
|
887
|
+
grid.innerHTML =
|
|
888
|
+
'<span style="color:#666;font-size:12px">暂无待补资料任务</span>';
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const total = countries.reduce((sum, c) => sum + c.count, 0);
|
|
892
|
+
grid.innerHTML = countries
|
|
893
|
+
.map((c) => {
|
|
894
|
+
const pct = ((c.count / total) * 100).toFixed(1);
|
|
895
|
+
const isUnknown = c.country === "未知";
|
|
896
|
+
const safeCountry = escapeJsString(c.country);
|
|
897
|
+
return `
|
|
898
|
+
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
899
|
+
onclick="filterByUserUpdateCountry('${safeCountry}')">
|
|
900
|
+
<button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
|
|
901
|
+
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
902
|
+
<div class="country-count">${c.count}</div>
|
|
903
|
+
<div class="country-label">${pct}% 待补资料</div>
|
|
904
|
+
</div>
|
|
905
|
+
`;
|
|
906
|
+
})
|
|
907
|
+
.join("");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function fetchAttachStuckByCountry() {
|
|
911
|
+
try {
|
|
912
|
+
const res = await fetch("/api/attach-stuck-by-country");
|
|
913
|
+
const data = await res.json();
|
|
914
|
+
renderAttachStuckGrid("pendingAttachStuckGrid", data.countries || []);
|
|
915
|
+
renderAttachStuckGrid("userUpdateAttachStuckGrid", data.countries || []);
|
|
916
|
+
} catch (e) {
|
|
917
|
+
console.error("获取 attach 未成功国家分布失败:", e);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function renderAttachStuckGrid(gridId, countries) {
|
|
922
|
+
const grid = document.getElementById(gridId);
|
|
923
|
+
if (!grid) return;
|
|
924
|
+
if (!countries.length) {
|
|
925
|
+
grid.innerHTML =
|
|
926
|
+
'<span style="color:#666;font-size:12px">暂无 attach 未成功任务</span>';
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const total = countries.reduce((sum, c) => sum + c.count, 0);
|
|
930
|
+
grid.innerHTML = countries
|
|
931
|
+
.map((c) => {
|
|
932
|
+
const pct = ((c.count / total) * 100).toFixed(1);
|
|
933
|
+
const isUnknown = c.country === "未知";
|
|
934
|
+
const safeCountry = escapeJsString(c.country);
|
|
935
|
+
return `
|
|
936
|
+
<div class="pending-country-item${isUnknown ? "" : " has-target"}">
|
|
937
|
+
<button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
938
|
+
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
939
|
+
<div class="country-count">${c.count}</div>
|
|
940
|
+
<div class="country-label">${pct}% attach 未成功</div>
|
|
941
|
+
</div>
|
|
942
|
+
`;
|
|
943
|
+
})
|
|
944
|
+
.join("");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function restoreAttachStuckByCountry(country, count) {
|
|
948
|
+
const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : "";
|
|
949
|
+
if (
|
|
950
|
+
!window.confirm(
|
|
951
|
+
`确认将 ${country} 下 attach 未成功的任务恢复为待补资料吗?${countText}`,
|
|
952
|
+
)
|
|
953
|
+
) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
showLoading("正在恢复任务...");
|
|
957
|
+
try {
|
|
958
|
+
const res = await fetch("/api/attach-stuck/restore", {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: { "Content-Type": "application/json" },
|
|
961
|
+
body: JSON.stringify({ country }),
|
|
962
|
+
});
|
|
963
|
+
const data = await res.json();
|
|
964
|
+
if (!res.ok || data.error) {
|
|
965
|
+
showToast(data.error || "恢复 attach 任务失败", true);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
showToast(`${country} 的 attach 未成功任务已恢复,共 ${data.restored} 条`);
|
|
969
|
+
await fetchStats();
|
|
970
|
+
await fetchPendingByCountry();
|
|
971
|
+
await fetchUserUpdateByCountry();
|
|
972
|
+
await fetchAttachStuckByCountry();
|
|
973
|
+
if (!document.getElementById("rawPage").classList.contains("active")) {
|
|
974
|
+
fetchUsers();
|
|
975
|
+
}
|
|
976
|
+
} catch (e) {
|
|
977
|
+
showToast("恢复 attach 任务失败: " + e.message, true);
|
|
978
|
+
} finally {
|
|
979
|
+
hideLoading();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function moveCountryJobsToRaw(scope, country, count) {
|
|
984
|
+
const scopeLabel = scope === "pending" ? "待处理任务" : "待补资料任务";
|
|
985
|
+
const countText = count != null ? `将移动 ${formatStatNum(count)} 条。` : "";
|
|
986
|
+
if (
|
|
987
|
+
!window.confirm(
|
|
988
|
+
`确认将 ${country} 的${scopeLabel}移到毛料库吗?${countText} 这些任务会先暂存,不再进入当前处理队列。`,
|
|
989
|
+
)
|
|
990
|
+
) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
showLoading("正在移到毛料库...");
|
|
994
|
+
try {
|
|
995
|
+
const res = await fetch("/api/jobs/move-to-raw", {
|
|
996
|
+
method: "POST",
|
|
997
|
+
headers: { "Content-Type": "application/json" },
|
|
998
|
+
body: JSON.stringify({ scope, country }),
|
|
999
|
+
});
|
|
1000
|
+
const data = await res.json();
|
|
1001
|
+
if (!res.ok || data.error) {
|
|
1002
|
+
showToast(data.error || "移到毛料库失败", true);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
showToast(`${country} 已移到毛料库,共 ${data.moved} 条`);
|
|
1006
|
+
await fetchStats();
|
|
1007
|
+
if (scope === "pending") {
|
|
1008
|
+
await fetchPendingByCountry();
|
|
1009
|
+
} else {
|
|
1010
|
+
await fetchUserUpdateByCountry();
|
|
1011
|
+
}
|
|
1012
|
+
if (!document.getElementById("mainPage").classList.contains("hidden")) {
|
|
1013
|
+
fetchUsers();
|
|
1014
|
+
}
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
showToast("移到毛料库失败: " + e.message, true);
|
|
1017
|
+
} finally {
|
|
1018
|
+
hideLoading();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function fetchRawByCountry() {
|
|
1023
|
+
try {
|
|
1024
|
+
const res = await fetch("/api/raw-by-country");
|
|
1025
|
+
const data = await res.json();
|
|
1026
|
+
renderRawCountryGrid(data.countries || []);
|
|
1027
|
+
renderRawLocationFilter(data.countries || []);
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
console.error("获取毛料库国家分布失败:", e);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function renderRawCountryGrid(countries) {
|
|
1034
|
+
const grid = document.getElementById("rawCountryGrid");
|
|
1035
|
+
if (!countries.length) {
|
|
1036
|
+
grid.innerHTML =
|
|
1037
|
+
'<span style="color:#666;font-size:12px">毛料库暂无数据</span>';
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const total = countries.reduce((sum, c) => sum + c.count, 0);
|
|
1041
|
+
grid.innerHTML = countries
|
|
1042
|
+
.map((c) => {
|
|
1043
|
+
const pct = ((c.count / total) * 100).toFixed(1);
|
|
1044
|
+
const isUnknown = c.country === "未知";
|
|
1045
|
+
const safeCountry = escapeJsString(c.country);
|
|
1046
|
+
return `
|
|
1047
|
+
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
1048
|
+
onclick="filterRawByCountry('${safeCountry}')">
|
|
1049
|
+
<button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1050
|
+
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
1051
|
+
<div class="country-count">${c.count}</div>
|
|
1052
|
+
<div class="country-label">${pct}% 毛料库</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
`;
|
|
1055
|
+
})
|
|
1056
|
+
.join("");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function fetchRawJobs() {
|
|
1060
|
+
try {
|
|
1061
|
+
const params = new URLSearchParams();
|
|
1062
|
+
const search = document.getElementById("rawSearchInput").value.trim();
|
|
1063
|
+
if (search) params.set("search", search);
|
|
1064
|
+
if (currentRawLocation) params.set("location", currentRawLocation);
|
|
1065
|
+
params.set("limit", "200");
|
|
1066
|
+
const res = await fetch("/api/raw-jobs?" + params.toString());
|
|
1067
|
+
const data = await res.json();
|
|
1068
|
+
renderRawJobsTable(data.users || []);
|
|
1069
|
+
} catch (e) {
|
|
1070
|
+
console.error("获取毛料库列表失败:", e);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function renderRawJobsTable(users) {
|
|
1075
|
+
const el = document.getElementById("rawTable");
|
|
1076
|
+
if (!users.length) {
|
|
1077
|
+
el.innerHTML =
|
|
1078
|
+
'<tr><td colspan="10" style="color:#666;text-align:center;padding:24px">暂无毛料库任务</td></tr>';
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
el.innerHTML = users
|
|
1082
|
+
.map((u) => {
|
|
1083
|
+
const nick = (u.nickname || "")
|
|
1084
|
+
.replace(/</g, "<")
|
|
1085
|
+
.replace(/>/g, ">");
|
|
1086
|
+
const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
|
|
1087
|
+
const videos = u.videoCount != null ? u.videoCount : "-";
|
|
1088
|
+
const loc = u.locationCreated || "-";
|
|
1089
|
+
const guessedLoc = u.guessedLocation || "-";
|
|
1090
|
+
const sources = (u.sources || []).join(", ");
|
|
1091
|
+
const created = u.createdAt ? formatTime(u.createdAt) : "-";
|
|
1092
|
+
const statusTag = u.status || "-";
|
|
1093
|
+
return `<tr>
|
|
1094
|
+
<td class="user-id" data-label="用户名">@${u.uniqueId}</td>
|
|
1095
|
+
<td data-label="昵称">${nick}</td>
|
|
1096
|
+
<td data-label="粉丝">${fans}</td>
|
|
1097
|
+
<td data-label="视频">${videos}</td>
|
|
1098
|
+
<td data-label="国家">${loc}</td>
|
|
1099
|
+
<td data-label="猜测国家">${guessedLoc}</td>
|
|
1100
|
+
<td data-label="来源">${sources || "-"}</td>
|
|
1101
|
+
<td data-label="状态">${statusTag}</td>
|
|
1102
|
+
<td data-label="创建时间" style="font-size:11px;color:#888">${created}</td>
|
|
1103
|
+
<td data-label="操作" style="text-align:center"><button onclick="restoreRawJob('${u.uniqueId}')" style="background:#22c55e;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;">恢复</button></td>
|
|
1104
|
+
</tr>`;
|
|
1105
|
+
})
|
|
1106
|
+
.join("");
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function renderRawLocationFilter(countries) {
|
|
1110
|
+
const sel = document.getElementById("rawLocationFilter");
|
|
1111
|
+
if (!sel) return;
|
|
1112
|
+
const val = currentRawLocation;
|
|
1113
|
+
sel.innerHTML =
|
|
1114
|
+
'<option value="">全部国家</option>' +
|
|
1115
|
+
countries
|
|
1116
|
+
.map(
|
|
1117
|
+
(c) =>
|
|
1118
|
+
`<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
|
|
1119
|
+
)
|
|
1120
|
+
.join("");
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function filterRawByCountry(country) {
|
|
1124
|
+
currentRawLocation = country;
|
|
1125
|
+
const sel = document.getElementById("rawLocationFilter");
|
|
1126
|
+
if (sel) sel.value = country;
|
|
1127
|
+
fetchRawJobs();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function onRawLocationChange() {
|
|
1131
|
+
const sel = document.getElementById("rawLocationFilter");
|
|
1132
|
+
currentRawLocation = sel.value;
|
|
1133
|
+
fetchRawJobs();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function clearRawFilters() {
|
|
1137
|
+
currentRawLocation = "";
|
|
1138
|
+
const rawSearchInput = document.getElementById("rawSearchInput");
|
|
1139
|
+
const rawLocationFilter = document.getElementById("rawLocationFilter");
|
|
1140
|
+
if (rawSearchInput) rawSearchInput.value = "";
|
|
1141
|
+
if (rawLocationFilter) rawLocationFilter.value = "";
|
|
1142
|
+
fetchRawJobs();
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function restoreRawJob(uniqueId) {
|
|
1146
|
+
if (!window.confirm(`确认将 @${uniqueId} 从毛料库恢复到 jobs 队列吗?`)) {
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
showLoading("正在恢复...");
|
|
1150
|
+
try {
|
|
1151
|
+
const res = await fetch("/api/raw-jobs/restore", {
|
|
1152
|
+
method: "POST",
|
|
1153
|
+
headers: { "Content-Type": "application/json" },
|
|
1154
|
+
body: JSON.stringify({ uniqueId }),
|
|
1155
|
+
});
|
|
1156
|
+
const data = await res.json();
|
|
1157
|
+
if (!res.ok || data.error) {
|
|
1158
|
+
showToast(data.error || "恢复失败", true);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
showToast(`@${uniqueId} 已恢复到队列`);
|
|
1162
|
+
await fetchStats();
|
|
1163
|
+
await fetchRawByCountry();
|
|
1164
|
+
await fetchRawJobs();
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
showToast("恢复失败: " + e.message, true);
|
|
1167
|
+
} finally {
|
|
1168
|
+
hideLoading();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async function restoreFilteredRawJobs() {
|
|
1173
|
+
const search = document.getElementById("rawSearchInput").value.trim();
|
|
1174
|
+
const location = currentRawLocation;
|
|
1175
|
+
let desc = "当前筛选条件";
|
|
1176
|
+
if (search && location) desc = `搜索="${search}" + 国家=${location}`;
|
|
1177
|
+
else if (search) desc = `搜索="${search}"`;
|
|
1178
|
+
else if (location) desc = `国家=${location}`;
|
|
1179
|
+
if (
|
|
1180
|
+
!window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)
|
|
1181
|
+
) {
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
showLoading("正在恢复筛选任务...");
|
|
1185
|
+
try {
|
|
1186
|
+
const body = {};
|
|
1187
|
+
if (search) body.search = search;
|
|
1188
|
+
if (location) body.location = location;
|
|
1189
|
+
const res = await fetch("/api/raw-jobs/restore", {
|
|
1190
|
+
method: "POST",
|
|
1191
|
+
headers: { "Content-Type": "application/json" },
|
|
1192
|
+
body: JSON.stringify(body),
|
|
1193
|
+
});
|
|
1194
|
+
const data = await res.json();
|
|
1195
|
+
if (!res.ok || data.error) {
|
|
1196
|
+
showToast(data.error || "恢复失败", true);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
showToast(`已恢复 ${data.restored} 条任务到队列`);
|
|
1200
|
+
await fetchStats();
|
|
1201
|
+
await fetchRawByCountry();
|
|
1202
|
+
await fetchRawJobs();
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
showToast("恢复失败: " + e.message, true);
|
|
1205
|
+
} finally {
|
|
1206
|
+
hideLoading();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function restoreRawJobsByCountry(country, count) {
|
|
1211
|
+
const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : "";
|
|
1212
|
+
if (
|
|
1213
|
+
!window.confirm(
|
|
1214
|
+
`确认将 ${country} 从毛料库恢复到 jobs 队列吗?${countText}`,
|
|
1215
|
+
)
|
|
1216
|
+
) {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
showLoading("正在恢复...");
|
|
1220
|
+
try {
|
|
1221
|
+
const res = await fetch("/api/raw-jobs/restore", {
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
headers: { "Content-Type": "application/json" },
|
|
1224
|
+
body: JSON.stringify({ country }),
|
|
1225
|
+
});
|
|
1226
|
+
const data = await res.json();
|
|
1227
|
+
if (!res.ok || data.error) {
|
|
1228
|
+
showToast(data.error || "恢复失败", true);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
showToast(`${country} 已恢复到队列,共 ${data.restored} 条`);
|
|
1232
|
+
await fetchStats();
|
|
1233
|
+
await fetchRawByCountry();
|
|
1234
|
+
await fetchRawJobs();
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
showToast("恢复失败: " + e.message, true);
|
|
1237
|
+
} finally {
|
|
1238
|
+
hideLoading();
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function filterByUserUpdateCountry(country) {
|
|
1243
|
+
window.location.hash = "";
|
|
1244
|
+
setTimeout(() => {
|
|
1245
|
+
const locationFilter = document.getElementById("locationFilter");
|
|
1246
|
+
if (locationFilter && country && country !== "未知") {
|
|
1247
|
+
const options = locationFilter.options;
|
|
1248
|
+
for (let i = 0; i < options.length; i++) {
|
|
1249
|
+
if (options[i].value === country) {
|
|
1250
|
+
locationFilter.value = country;
|
|
1251
|
+
onLocationChange();
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}, 100);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// 初始化
|
|
1260
|
+
fetchStats();
|
|
1261
|
+
fetchUsers();
|
|
1262
|
+
fetchClientErrors();
|
|
1263
|
+
setInterval(fetchStats, 10000);
|
|
1264
|
+
setInterval(fetchUsers, 10000);
|
|
1265
|
+
setInterval(fetchClientErrors, 10000);
|
|
1266
|
+
setInterval(refreshUserUpdateIfActive, 10000);
|