tt-help-cli-ycl 1.3.72 → 1.3.73
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/attach.js +5 -3
- package/src/lib/parse-ssr.mjs +1 -0
- package/src/watch/data-store.js +346 -155
- package/src/watch/public/app.js +194 -126
- package/src/watch/public/index.html +7 -0
- package/src/watch/server.js +50 -3
package/package.json
CHANGED
package/src/cli/attach.js
CHANGED
|
@@ -289,12 +289,14 @@ export async function handleAttach(options) {
|
|
|
289
289
|
const task = successTasks.find(
|
|
290
290
|
(t) => t.uniqueId === r.uniqueId,
|
|
291
291
|
);
|
|
292
|
-
|
|
292
|
+
const info = task?.info;
|
|
293
|
+
const sellerTag = info?.ttSeller ? " [商家ttSeller=true]" : "";
|
|
294
|
+
if (info && info.error) {
|
|
293
295
|
attachLog(
|
|
294
|
-
`
|
|
296
|
+
` ⚠${sellerTag} @${r.uniqueId} 已记录 (statusCode=${info.statusCode})`,
|
|
295
297
|
);
|
|
296
298
|
} else {
|
|
297
|
-
attachLog(`
|
|
299
|
+
attachLog(` ✓${sellerTag} @${r.uniqueId} 已提交更新`);
|
|
298
300
|
}
|
|
299
301
|
} else {
|
|
300
302
|
failCount++;
|
package/src/lib/parse-ssr.mjs
CHANGED
|
@@ -70,6 +70,7 @@ export function parseUserInfo(rawHtml) {
|
|
|
70
70
|
privateAccount: u.privateAccount,
|
|
71
71
|
language: u.language,
|
|
72
72
|
bio: u.signature || "",
|
|
73
|
+
bioLink: u.bioLink?.link || u.bioLink?.url || u.bioLink || null,
|
|
73
74
|
avatar: u.avatarLarger || u.avatarMedium || u.avatarThumb || "",
|
|
74
75
|
followerCount: s.followerCount,
|
|
75
76
|
followingCount: s.followingCount,
|
package/src/watch/data-store.js
CHANGED
|
@@ -140,6 +140,9 @@ function initUserDb(filePath) {
|
|
|
140
140
|
if (!existingJobColumns.has("modified_at")) {
|
|
141
141
|
db.exec(`ALTER TABLE jobs ADD COLUMN modified_at INTEGER`);
|
|
142
142
|
}
|
|
143
|
+
if (!existingJobColumns.has("bio_link")) {
|
|
144
|
+
db.exec(`ALTER TABLE jobs ADD COLUMN bio_link TEXT`);
|
|
145
|
+
}
|
|
143
146
|
db.exec(`
|
|
144
147
|
CREATE TABLE IF NOT EXISTS raw_jobs (
|
|
145
148
|
unique_id TEXT PRIMARY KEY,
|
|
@@ -196,6 +199,9 @@ function initUserDb(filePath) {
|
|
|
196
199
|
if (!existingRawJobColumns.has("modified_at")) {
|
|
197
200
|
db.exec(`ALTER TABLE raw_jobs ADD COLUMN modified_at INTEGER`);
|
|
198
201
|
}
|
|
202
|
+
if (!existingRawJobColumns.has("bio_link")) {
|
|
203
|
+
db.exec(`ALTER TABLE raw_jobs ADD COLUMN bio_link TEXT`);
|
|
204
|
+
}
|
|
199
205
|
db.exec(`
|
|
200
206
|
CREATE TABLE IF NOT EXISTS videos (
|
|
201
207
|
id TEXT PRIMARY KEY,
|
|
@@ -461,9 +467,10 @@ function addJobToDb(user) {
|
|
|
461
467
|
updated_at,
|
|
462
468
|
region,
|
|
463
469
|
signature,
|
|
470
|
+
bio_link,
|
|
464
471
|
sec_uid
|
|
465
472
|
)
|
|
466
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
473
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
467
474
|
`,
|
|
468
475
|
).run(
|
|
469
476
|
user.uniqueId,
|
|
@@ -507,6 +514,7 @@ function addJobToDb(user) {
|
|
|
507
514
|
user.updatedAt || now,
|
|
508
515
|
user.region || null,
|
|
509
516
|
user.signature || null,
|
|
517
|
+
user.bioLink?.link || user.bioLink?.url || user.bioLink || null,
|
|
510
518
|
user.secUid || null,
|
|
511
519
|
);
|
|
512
520
|
}
|
|
@@ -550,44 +558,46 @@ function getRawJobsCount() {
|
|
|
550
558
|
function getDashboardStatsFromDb(targetLocations = []) {
|
|
551
559
|
if (!db) return null;
|
|
552
560
|
|
|
553
|
-
const
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
error: 0,
|
|
559
|
-
restricted: 0,
|
|
560
|
-
};
|
|
561
|
-
const rows = db
|
|
561
|
+
const targetPlaceholders = targetLocations.map(() => "?").join(", ");
|
|
562
|
+
const targetParams = targetLocations.length ? targetLocations : [];
|
|
563
|
+
|
|
564
|
+
// 合并所有 jobs 表的聚合统计为单次扫描
|
|
565
|
+
const aggregateRow = db
|
|
562
566
|
.prepare(
|
|
563
567
|
`
|
|
564
|
-
SELECT
|
|
568
|
+
SELECT
|
|
569
|
+
COUNT(*) as total,
|
|
570
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
571
|
+
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
|
|
572
|
+
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
|
|
573
|
+
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
|
|
574
|
+
SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
|
|
575
|
+
SUM(CASE WHEN tt_seller = 1 AND verified = 0 ${
|
|
576
|
+
targetLocations.length
|
|
577
|
+
? `AND location_created IN (${targetPlaceholders})`
|
|
578
|
+
: "AND 1 = 0"
|
|
579
|
+
} THEN 1 ELSE 0 END) as targetUsers,
|
|
580
|
+
SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo,
|
|
581
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"video"') > 0 THEN 1 ELSE 0 END) as video,
|
|
582
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"comment"') > 0 THEN 1 ELSE 0 END) as comment,
|
|
583
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"guess"') > 0 THEN 1 ELSE 0 END) as guess,
|
|
584
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"following"') > 0 THEN 1 ELSE 0 END) as following,
|
|
585
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"follower"') > 0 THEN 1 ELSE 0 END) as follower,
|
|
586
|
+
SUM(CASE
|
|
587
|
+
WHEN status != 'done'
|
|
588
|
+
AND instr(COALESCE(sources, ''), '"video"') = 0
|
|
589
|
+
AND instr(COALESCE(sources, ''), '"comment"') = 0
|
|
590
|
+
AND instr(COALESCE(sources, ''), '"guess"') = 0
|
|
591
|
+
AND instr(COALESCE(sources, ''), '"following"') = 0
|
|
592
|
+
AND instr(COALESCE(sources, ''), '"follower"') = 0
|
|
593
|
+
THEN 1 ELSE 0 END) as seed,
|
|
594
|
+
SUM(CASE WHEN COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0 THEN 1 ELSE 0 END) as userUpdateTasks
|
|
565
595
|
FROM jobs
|
|
566
|
-
GROUP BY status
|
|
567
596
|
`,
|
|
568
597
|
)
|
|
569
|
-
.
|
|
570
|
-
for (const row of rows) {
|
|
571
|
-
if (row.status && statusCounts[row.status] !== undefined) {
|
|
572
|
-
statusCounts[row.status] = row.count;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const targetPlaceholders = targetLocations.map(() => "?").join(", ");
|
|
577
|
-
const targetUsers = targetLocations.length
|
|
578
|
-
? db
|
|
579
|
-
.prepare(
|
|
580
|
-
`
|
|
581
|
-
SELECT COUNT(*) as c
|
|
582
|
-
FROM jobs
|
|
583
|
-
WHERE tt_seller = 1
|
|
584
|
-
AND verified = 0
|
|
585
|
-
AND location_created IN (${targetPlaceholders})
|
|
586
|
-
`,
|
|
587
|
-
)
|
|
588
|
-
.get(...targetLocations).c
|
|
589
|
-
: 0;
|
|
598
|
+
.get(...targetParams);
|
|
590
599
|
|
|
600
|
+
// countryStats 和 targetCountryStats 需要 GROUP BY,保留为独立查询
|
|
591
601
|
const countryStats = db
|
|
592
602
|
.prepare(
|
|
593
603
|
`
|
|
@@ -607,7 +617,7 @@ function getDashboardStatsFromDb(targetLocations = []) {
|
|
|
607
617
|
ORDER BY count DESC
|
|
608
618
|
`,
|
|
609
619
|
)
|
|
610
|
-
.all(...
|
|
620
|
+
.all(...targetParams);
|
|
611
621
|
|
|
612
622
|
const targetCountryStats = targetLocations.length
|
|
613
623
|
? db
|
|
@@ -625,58 +635,32 @@ function getDashboardStatsFromDb(targetLocations = []) {
|
|
|
625
635
|
.all(...targetLocations)
|
|
626
636
|
: [];
|
|
627
637
|
|
|
628
|
-
const sourceRow = db
|
|
629
|
-
.prepare(
|
|
630
|
-
`
|
|
631
|
-
SELECT
|
|
632
|
-
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as processed,
|
|
633
|
-
SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
|
|
634
|
-
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
|
|
635
|
-
SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo,
|
|
636
|
-
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"video"') > 0 THEN 1 ELSE 0 END) as video,
|
|
637
|
-
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"comment"') > 0 THEN 1 ELSE 0 END) as comment,
|
|
638
|
-
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"guess"') > 0 THEN 1 ELSE 0 END) as guess,
|
|
639
|
-
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"following"') > 0 THEN 1 ELSE 0 END) as following,
|
|
640
|
-
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"follower"') > 0 THEN 1 ELSE 0 END) as follower,
|
|
641
|
-
SUM(CASE
|
|
642
|
-
WHEN status != 'done'
|
|
643
|
-
AND instr(COALESCE(sources, ''), '"video"') = 0
|
|
644
|
-
AND instr(COALESCE(sources, ''), '"comment"') = 0
|
|
645
|
-
AND instr(COALESCE(sources, ''), '"guess"') = 0
|
|
646
|
-
AND instr(COALESCE(sources, ''), '"following"') = 0
|
|
647
|
-
AND instr(COALESCE(sources, ''), '"follower"') = 0
|
|
648
|
-
THEN 1 ELSE 0 END) as seed
|
|
649
|
-
FROM jobs
|
|
650
|
-
`,
|
|
651
|
-
)
|
|
652
|
-
.get();
|
|
653
|
-
|
|
654
638
|
return {
|
|
655
|
-
totalUsers: total,
|
|
639
|
+
totalUsers: aggregateRow.total,
|
|
656
640
|
rawJobs: getRawJobsCount(),
|
|
657
641
|
dbTotalUsers: getUserDbCount(),
|
|
658
|
-
jobsTotal: total,
|
|
659
|
-
jobsPending:
|
|
660
|
-
processedUsers:
|
|
661
|
-
pendingUsers:
|
|
662
|
-
processingUsers:
|
|
663
|
-
restrictedUsers:
|
|
664
|
-
errorUsers:
|
|
665
|
-
targetUsers,
|
|
666
|
-
userUpdateTasks:
|
|
642
|
+
jobsTotal: aggregateRow.total,
|
|
643
|
+
jobsPending: aggregateRow.pending,
|
|
644
|
+
processedUsers: aggregateRow.done,
|
|
645
|
+
pendingUsers: aggregateRow.pending,
|
|
646
|
+
processingUsers: aggregateRow.processing,
|
|
647
|
+
restrictedUsers: aggregateRow.restricted,
|
|
648
|
+
errorUsers: aggregateRow.error,
|
|
649
|
+
targetUsers: aggregateRow.targetUsers,
|
|
650
|
+
userUpdateTasks: aggregateRow.userUpdateTasks,
|
|
667
651
|
targetCountryStats,
|
|
668
652
|
countryStats,
|
|
669
653
|
sourceStats: {
|
|
670
|
-
seed:
|
|
671
|
-
video:
|
|
672
|
-
comment:
|
|
673
|
-
guess:
|
|
674
|
-
following:
|
|
675
|
-
follower:
|
|
676
|
-
processed:
|
|
677
|
-
restricted:
|
|
678
|
-
error:
|
|
679
|
-
noVideo:
|
|
654
|
+
seed: aggregateRow.seed || 0,
|
|
655
|
+
video: aggregateRow.video || 0,
|
|
656
|
+
comment: aggregateRow.comment || 0,
|
|
657
|
+
guess: aggregateRow.guess || 0,
|
|
658
|
+
following: aggregateRow.following || 0,
|
|
659
|
+
follower: aggregateRow.follower || 0,
|
|
660
|
+
processed: aggregateRow.done,
|
|
661
|
+
restricted: aggregateRow.restricted,
|
|
662
|
+
error: aggregateRow.error,
|
|
663
|
+
noVideo: aggregateRow.noVideo || 0,
|
|
680
664
|
},
|
|
681
665
|
};
|
|
682
666
|
}
|
|
@@ -873,7 +857,7 @@ function moveJobsToRawByCountry(scope, country) {
|
|
|
873
857
|
|
|
874
858
|
let scopeWhere = "";
|
|
875
859
|
if (normalizedScope === "pending") {
|
|
876
|
-
scopeWhere = `status = 'pending'`;
|
|
860
|
+
scopeWhere = `status = 'pending' AND COALESCE(user_update_count, 0) >= 2`;
|
|
877
861
|
} else if (normalizedScope === "userUpdate") {
|
|
878
862
|
scopeWhere = `COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0`;
|
|
879
863
|
} else {
|
|
@@ -1121,14 +1105,14 @@ function restoreRawJobById(uniqueId) {
|
|
|
1121
1105
|
pinned, no_video, restricted, user_update_count, tt_seller, verified,
|
|
1122
1106
|
video_count, comment_count, guessed_location, location_created,
|
|
1123
1107
|
follower_count, following_count, heart_count, refresh_time,
|
|
1124
|
-
processed, processed_at, created_at, updated_at, region, signature, sec_uid
|
|
1108
|
+
processed, processed_at, created_at, updated_at, region, signature, bio_link, sec_uid
|
|
1125
1109
|
)
|
|
1126
1110
|
SELECT
|
|
1127
1111
|
unique_id, nickname, status, sources, claimed_by, claimed_at, error,
|
|
1128
1112
|
pinned, no_video, restricted, user_update_count, tt_seller, verified,
|
|
1129
1113
|
video_count, comment_count, guessed_location, location_created,
|
|
1130
1114
|
follower_count, following_count, heart_count, refresh_time,
|
|
1131
|
-
processed, processed_at, created_at, updated_at, region, signature, sec_uid
|
|
1115
|
+
processed, processed_at, created_at, updated_at, region, signature, bio_link, sec_uid
|
|
1132
1116
|
FROM raw_jobs WHERE unique_id = ?
|
|
1133
1117
|
`,
|
|
1134
1118
|
).run(safeId);
|
|
@@ -1192,14 +1176,14 @@ function restoreRawJobsByFilter({ search, location, hasVideo, hasFollower }) {
|
|
|
1192
1176
|
pinned, no_video, restricted, user_update_count, tt_seller, verified,
|
|
1193
1177
|
video_count, comment_count, guessed_location, location_created,
|
|
1194
1178
|
follower_count, following_count, heart_count, refresh_time,
|
|
1195
|
-
processed, processed_at, created_at, updated_at, region, signature, sec_uid
|
|
1179
|
+
processed, processed_at, created_at, updated_at, region, signature, bio_link, sec_uid
|
|
1196
1180
|
)
|
|
1197
1181
|
SELECT
|
|
1198
1182
|
unique_id, nickname, status, sources, claimed_by, claimed_at, error,
|
|
1199
1183
|
pinned, no_video, restricted, user_update_count, tt_seller, verified,
|
|
1200
1184
|
video_count, comment_count, guessed_location, location_created,
|
|
1201
1185
|
follower_count, following_count, heart_count, refresh_time,
|
|
1202
|
-
processed, processed_at, created_at, updated_at, region, signature, sec_uid
|
|
1186
|
+
processed, processed_at, created_at, updated_at, region, signature, bio_link, sec_uid
|
|
1203
1187
|
FROM raw_jobs WHERE ${whereSql}
|
|
1204
1188
|
`,
|
|
1205
1189
|
).run(...args);
|
|
@@ -1329,46 +1313,59 @@ function getUsersPageFromDb({
|
|
|
1329
1313
|
}
|
|
1330
1314
|
|
|
1331
1315
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
1332
|
-
const total = db
|
|
1333
|
-
.prepare(`SELECT COUNT(*) as c FROM jobs ${whereSql}`)
|
|
1334
|
-
.get(...args).c;
|
|
1335
1316
|
|
|
1317
|
+
// COUNT 缓存:134 万条数据全表扫描慢,5 秒内返回缓存值
|
|
1318
|
+
const cacheKey = whereSql + "|" + args.join(",");
|
|
1319
|
+
if (!getUsersPageFromDb._countCache)
|
|
1320
|
+
getUsersPageFromDb._countCache = new Map();
|
|
1321
|
+
const cachedCount = getUsersPageFromDb._countCache.get(cacheKey);
|
|
1322
|
+
let total;
|
|
1323
|
+
if (cachedCount && Date.now() - cachedCount.time < 5000) {
|
|
1324
|
+
total = cachedCount.c;
|
|
1325
|
+
} else {
|
|
1326
|
+
total = db
|
|
1327
|
+
.prepare(`SELECT COUNT(*) as c FROM jobs ${whereSql}`)
|
|
1328
|
+
.get(...args).c;
|
|
1329
|
+
getUsersPageFromDb._countCache.set(cacheKey, {
|
|
1330
|
+
c: total,
|
|
1331
|
+
time: Date.now(),
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// 只查询前端需要的列,避免 SELECT * 带来的大字段传输和 mapJobRow 开销
|
|
1336
1336
|
const rows = db
|
|
1337
1337
|
.prepare(
|
|
1338
1338
|
`
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
unique_id ASC
|
|
1368
|
-
LIMIT ? OFFSET ?
|
|
1369
|
-
`,
|
|
1339
|
+
SELECT
|
|
1340
|
+
unique_id, nickname, sec_uid, status, sources,
|
|
1341
|
+
tt_seller, verified, follower_count, following_count,
|
|
1342
|
+
location_created, latest_video_time, refresh_time,
|
|
1343
|
+
guessed_location, pinned, processed_at, video_count,
|
|
1344
|
+
no_video, claimed_by, claimed_at, created_at, updated_at
|
|
1345
|
+
FROM jobs
|
|
1346
|
+
${whereSql}
|
|
1347
|
+
ORDER BY
|
|
1348
|
+
pinned DESC,
|
|
1349
|
+
CASE
|
|
1350
|
+
WHEN ? = 'done' THEN COALESCE(processed_at, 0) * -1
|
|
1351
|
+
WHEN ? = '1' THEN COALESCE(refresh_time, 0) * -1
|
|
1352
|
+
ELSE 0
|
|
1353
|
+
END ASC,
|
|
1354
|
+
CASE status
|
|
1355
|
+
WHEN 'processing' THEN 0
|
|
1356
|
+
WHEN 'pending' THEN 1
|
|
1357
|
+
WHEN 'done' THEN 2
|
|
1358
|
+
WHEN 'error' THEN 3
|
|
1359
|
+
WHEN 'restricted' THEN 4
|
|
1360
|
+
ELSE 9
|
|
1361
|
+
END ASC,
|
|
1362
|
+
COALESCE(follower_count, 0) DESC,
|
|
1363
|
+
COALESCE(processed_at, 0) DESC,
|
|
1364
|
+
unique_id ASC
|
|
1365
|
+
LIMIT ? OFFSET ?
|
|
1366
|
+
`,
|
|
1370
1367
|
)
|
|
1371
|
-
.all(...args, safeLimit, safeOffset)
|
|
1368
|
+
.all(...args, status || "", target, safeLimit, safeOffset)
|
|
1372
1369
|
.map(mapJobRow);
|
|
1373
1370
|
|
|
1374
1371
|
return {
|
|
@@ -1387,18 +1384,12 @@ function getTargetUsersFromDb(targetLocations = []) {
|
|
|
1387
1384
|
const rows = db
|
|
1388
1385
|
.prepare(
|
|
1389
1386
|
`
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
location_created,
|
|
1397
|
-
latest_video_time,
|
|
1398
|
-
status,
|
|
1399
|
-
sources
|
|
1400
|
-
FROM jobs
|
|
1401
|
-
WHERE tt_seller = 1
|
|
1387
|
+
SELECT
|
|
1388
|
+
unique_id, nickname, sec_uid, status, sources,
|
|
1389
|
+
tt_seller, verified, follower_count, following_count,
|
|
1390
|
+
location_created, latest_video_time, refresh_time,
|
|
1391
|
+
guessed_location, pinned, processed_at, video_count,
|
|
1392
|
+
no_video, claimed_by, claimed_at, created_at, updated_at
|
|
1402
1393
|
AND verified = 0
|
|
1403
1394
|
AND location_created IN (${placeholders})
|
|
1404
1395
|
ORDER BY COALESCE(follower_count, 0) DESC, unique_id ASC
|
|
@@ -1413,13 +1404,109 @@ function getTargetUsersFromDb(targetLocations = []) {
|
|
|
1413
1404
|
};
|
|
1414
1405
|
}
|
|
1415
1406
|
|
|
1416
|
-
function getTargetUsersByCountryFromDb(targetLocations = []) {
|
|
1407
|
+
function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
1417
1408
|
if (!db) return null;
|
|
1418
1409
|
if (!targetLocations.length) {
|
|
1419
1410
|
return { countries: [] };
|
|
1420
1411
|
}
|
|
1421
1412
|
|
|
1413
|
+
const {
|
|
1414
|
+
summaryOnly = false,
|
|
1415
|
+
country: filterCountry,
|
|
1416
|
+
search,
|
|
1417
|
+
limit,
|
|
1418
|
+
offset,
|
|
1419
|
+
} = options;
|
|
1422
1420
|
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
1421
|
+
const baseParams = [...targetLocations];
|
|
1422
|
+
|
|
1423
|
+
// 摘要模式:只返回各国统计数,不返回用户数据
|
|
1424
|
+
if (summaryOnly) {
|
|
1425
|
+
const statsRows = db
|
|
1426
|
+
.prepare(
|
|
1427
|
+
`
|
|
1428
|
+
SELECT location_created as country, COUNT(*) as count
|
|
1429
|
+
FROM jobs
|
|
1430
|
+
WHERE tt_seller = 1
|
|
1431
|
+
AND verified = 0
|
|
1432
|
+
AND location_created IN (${placeholders})
|
|
1433
|
+
GROUP BY location_created
|
|
1434
|
+
ORDER BY count DESC
|
|
1435
|
+
`,
|
|
1436
|
+
)
|
|
1437
|
+
.all(...targetLocations);
|
|
1438
|
+
|
|
1439
|
+
const countries = statsRows.map((r) => ({
|
|
1440
|
+
country: r.country,
|
|
1441
|
+
count: r.count,
|
|
1442
|
+
users: undefined,
|
|
1443
|
+
}));
|
|
1444
|
+
return {
|
|
1445
|
+
total: statsRows.reduce((s, r) => s + r.count, 0),
|
|
1446
|
+
countries,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// 分页模式:按国家或全局分页查询用户
|
|
1451
|
+
if (limit !== undefined) {
|
|
1452
|
+
let sql = `
|
|
1453
|
+
SELECT
|
|
1454
|
+
unique_id,
|
|
1455
|
+
nickname,
|
|
1456
|
+
follower_count,
|
|
1457
|
+
video_count,
|
|
1458
|
+
tt_seller,
|
|
1459
|
+
verified,
|
|
1460
|
+
location_created,
|
|
1461
|
+
confirmed_location,
|
|
1462
|
+
modified_at,
|
|
1463
|
+
latest_video_time,
|
|
1464
|
+
refresh_time,
|
|
1465
|
+
status,
|
|
1466
|
+
sources
|
|
1467
|
+
FROM jobs
|
|
1468
|
+
WHERE tt_seller = 1
|
|
1469
|
+
AND verified = 0
|
|
1470
|
+
AND location_created IN (${placeholders})
|
|
1471
|
+
`;
|
|
1472
|
+
const params = [...targetLocations];
|
|
1473
|
+
|
|
1474
|
+
if (filterCountry) {
|
|
1475
|
+
sql += ` AND location_created = ?`;
|
|
1476
|
+
params.push(filterCountry);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (search) {
|
|
1480
|
+
sql += ` AND (unique_id LIKE ? OR nickname LIKE ?)`;
|
|
1481
|
+
const likeSearch = `%${search}%`;
|
|
1482
|
+
params.push(likeSearch, likeSearch);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
sql += ` ORDER BY location_created ASC, COALESCE(latest_video_time, 0) DESC`;
|
|
1486
|
+
|
|
1487
|
+
const countSql = sql.replace(
|
|
1488
|
+
/SELECT[^FROM]*FROM/,
|
|
1489
|
+
"SELECT COUNT(*) as cnt FROM",
|
|
1490
|
+
);
|
|
1491
|
+
const total = db.prepare(countSql).get(...params)?.cnt || 0;
|
|
1492
|
+
|
|
1493
|
+
sql += ` LIMIT ? OFFSET ?`;
|
|
1494
|
+
const safeLimit = Math.min(Math.floor(limit), 10000);
|
|
1495
|
+
const safeOffset = Math.max(Math.floor(offset), 0);
|
|
1496
|
+
|
|
1497
|
+
const rows = db
|
|
1498
|
+
.prepare(sql)
|
|
1499
|
+
.all(...params, safeLimit, safeOffset)
|
|
1500
|
+
.map(mapJobRow);
|
|
1501
|
+
|
|
1502
|
+
return {
|
|
1503
|
+
total,
|
|
1504
|
+
limit: safeLimit,
|
|
1505
|
+
offset: safeOffset,
|
|
1506
|
+
users: rows,
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1423
1510
|
const rows = db
|
|
1424
1511
|
.prepare(
|
|
1425
1512
|
`
|
|
@@ -1519,6 +1606,7 @@ const writableJobColumns = new Set([
|
|
|
1519
1606
|
"updated_at",
|
|
1520
1607
|
"region",
|
|
1521
1608
|
"signature",
|
|
1609
|
+
"bio_link",
|
|
1522
1610
|
"sec_uid",
|
|
1523
1611
|
"status_code",
|
|
1524
1612
|
"latest_video_time",
|
|
@@ -1607,7 +1695,9 @@ function updateJobInfo(uniqueId, info, incrementCount = true) {
|
|
|
1607
1695
|
for (const [key, value] of Object.entries(info || {})) {
|
|
1608
1696
|
if (key === "uniqueId" || key === "unique_id") continue;
|
|
1609
1697
|
if (value === undefined || value === "") continue;
|
|
1610
|
-
|
|
1698
|
+
let column = camelToSnake(key);
|
|
1699
|
+
// 字段别名:bio → signature
|
|
1700
|
+
if (column === "bio") column = "signature";
|
|
1611
1701
|
if (!writableJobColumns.has(column)) continue;
|
|
1612
1702
|
nextValues[column] = normalizeJobValue(column, value);
|
|
1613
1703
|
}
|
|
@@ -2774,7 +2864,9 @@ export function createStore(filePath) {
|
|
|
2774
2864
|
user.ttSeller = result.userInfo?.ttSeller ?? user.ttSeller;
|
|
2775
2865
|
user.verified = result.userInfo?.verified ?? user.verified;
|
|
2776
2866
|
user.region = result.userInfo?.region || user.region;
|
|
2777
|
-
user.signature =
|
|
2867
|
+
user.signature =
|
|
2868
|
+
result.userInfo?.signature ?? result.userInfo?.bio ?? user.signature;
|
|
2869
|
+
user.bioLink = result.userInfo?.bioLink ?? user.bioLink;
|
|
2778
2870
|
user.followingCount =
|
|
2779
2871
|
result.userInfo?.followingCount ?? user.followingCount;
|
|
2780
2872
|
user.heartCount = result.userInfo?.heartCount ?? user.heartCount;
|
|
@@ -3244,39 +3336,138 @@ export function createStore(filePath) {
|
|
|
3244
3336
|
return { ok: true, location, modifiedAt: user.modifiedAt };
|
|
3245
3337
|
}
|
|
3246
3338
|
|
|
3339
|
+
// 将单个 job 移动到 raw_jobs 表(完整字段复制 + 删除原记录)
|
|
3340
|
+
function moveJobToRaw(uniqueId) {
|
|
3341
|
+
if (!db) return false;
|
|
3342
|
+
const safeId = String(uniqueId).trim();
|
|
3343
|
+
if (!safeId) return false;
|
|
3344
|
+
|
|
3345
|
+
const moveSingleTxn = db.transaction(() => {
|
|
3346
|
+
db.prepare(
|
|
3347
|
+
`
|
|
3348
|
+
INSERT OR REPLACE INTO raw_jobs (
|
|
3349
|
+
unique_id, nickname, status, sources, claimed_by, claimed_at,
|
|
3350
|
+
error, pinned, no_video, restricted, user_update_count,
|
|
3351
|
+
tt_seller, verified, video_count, comment_count,
|
|
3352
|
+
guessed_location, location_created, confirmed_location, modified_at,
|
|
3353
|
+
follower_count, following_count, heart_count, refresh_time,
|
|
3354
|
+
processed, processed_at, created_at, updated_at,
|
|
3355
|
+
region, signature, bio_link, sec_uid, status_code, latest_video_time
|
|
3356
|
+
)
|
|
3357
|
+
SELECT
|
|
3358
|
+
unique_id, nickname, status, sources, claimed_by, claimed_at,
|
|
3359
|
+
error, pinned, no_video, restricted, user_update_count,
|
|
3360
|
+
tt_seller, verified, video_count, comment_count,
|
|
3361
|
+
guessed_location, location_created, confirmed_location, modified_at,
|
|
3362
|
+
follower_count, following_count, heart_count, refresh_time,
|
|
3363
|
+
processed, processed_at, created_at, updated_at,
|
|
3364
|
+
region, signature, bio_link, sec_uid, status_code, latest_video_time
|
|
3365
|
+
FROM jobs WHERE unique_id = ?
|
|
3366
|
+
`,
|
|
3367
|
+
).run(safeId);
|
|
3368
|
+
|
|
3369
|
+
db.prepare("DELETE FROM jobs WHERE unique_id = ?").run(safeId);
|
|
3370
|
+
});
|
|
3371
|
+
moveSingleTxn();
|
|
3372
|
+
return true;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3247
3375
|
function batchUpdateUserInfo(updates) {
|
|
3248
3376
|
if (db) {
|
|
3249
|
-
const
|
|
3250
|
-
|
|
3377
|
+
const results = [];
|
|
3378
|
+
const moveList = [];
|
|
3379
|
+
|
|
3380
|
+
const txn = db.transaction((items) => {
|
|
3381
|
+
items.forEach((item) => {
|
|
3382
|
+
const uniqueId = item.uniqueId;
|
|
3251
3383
|
// 处理 { error: true, statusCode: xxx } 的情况
|
|
3252
3384
|
const info = item.info;
|
|
3385
|
+
let updateResult;
|
|
3253
3386
|
if (info && info.error && info.statusCode !== undefined) {
|
|
3254
3387
|
// 只更新 status_code,不更新其他字段
|
|
3255
|
-
|
|
3256
|
-
|
|
3388
|
+
updateResult = updateJobInfo(
|
|
3389
|
+
uniqueId,
|
|
3257
3390
|
{ statusCode: info.statusCode },
|
|
3258
3391
|
true,
|
|
3259
3392
|
);
|
|
3393
|
+
} else {
|
|
3394
|
+
updateResult = updateJobInfo(uniqueId, info, true);
|
|
3260
3395
|
}
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3396
|
+
|
|
3397
|
+
if (updateResult.error) {
|
|
3398
|
+
results.push({ uniqueId, error: updateResult.error });
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// 检查 tt_seller:非商家则标记为需要移动到毛料表
|
|
3403
|
+
const row = getJobRow(uniqueId);
|
|
3404
|
+
const ttSeller = row ? row.tt_seller : null;
|
|
3405
|
+
if (ttSeller) {
|
|
3406
|
+
// 商家:保持当前逻辑
|
|
3407
|
+
results.push({
|
|
3408
|
+
uniqueId,
|
|
3269
3409
|
ok: true,
|
|
3270
|
-
userUpdateCount:
|
|
3271
|
-
}
|
|
3272
|
-
|
|
3410
|
+
userUpdateCount: updateResult.userUpdateCount,
|
|
3411
|
+
});
|
|
3412
|
+
} else {
|
|
3413
|
+
// 非商家:标记移动
|
|
3414
|
+
results.push({
|
|
3415
|
+
uniqueId,
|
|
3416
|
+
ok: true,
|
|
3417
|
+
userUpdateCount: updateResult.userUpdateCount,
|
|
3418
|
+
_movedToRaw: true,
|
|
3419
|
+
});
|
|
3420
|
+
moveList.push(uniqueId);
|
|
3421
|
+
}
|
|
3422
|
+
});
|
|
3423
|
+
});
|
|
3424
|
+
txn(updates);
|
|
3425
|
+
|
|
3426
|
+
// 在事务外执行移动操作(避免嵌套 transaction 问题)
|
|
3427
|
+
if (moveList.length > 0) {
|
|
3428
|
+
const moveTxn = db.transaction((ids) => {
|
|
3429
|
+
ids.forEach((uid) => {
|
|
3430
|
+
db.prepare(
|
|
3431
|
+
`
|
|
3432
|
+
INSERT OR REPLACE INTO raw_jobs (
|
|
3433
|
+
unique_id, nickname, status, sources, claimed_by, claimed_at,
|
|
3434
|
+
error, pinned, no_video, restricted, user_update_count,
|
|
3435
|
+
tt_seller, verified, video_count, comment_count,
|
|
3436
|
+
guessed_location, location_created, confirmed_location, modified_at,
|
|
3437
|
+
follower_count, following_count, heart_count, refresh_time,
|
|
3438
|
+
processed, processed_at, created_at, updated_at,
|
|
3439
|
+
region, signature, bio_link, sec_uid, status_code, latest_video_time
|
|
3440
|
+
)
|
|
3441
|
+
SELECT
|
|
3442
|
+
unique_id, nickname, status, sources, claimed_by, claimed_at,
|
|
3443
|
+
error, pinned, no_video, restricted, user_update_count,
|
|
3444
|
+
tt_seller, verified, video_count, comment_count,
|
|
3445
|
+
guessed_location, location_created, confirmed_location, modified_at,
|
|
3446
|
+
follower_count, following_count, heart_count, refresh_time,
|
|
3447
|
+
processed, processed_at, created_at, updated_at,
|
|
3448
|
+
region, signature, bio_link, sec_uid, status_code, latest_video_time
|
|
3449
|
+
FROM jobs WHERE unique_id = ?
|
|
3450
|
+
`,
|
|
3451
|
+
).run(uid);
|
|
3452
|
+
|
|
3453
|
+
db.prepare("DELETE FROM jobs WHERE unique_id = ?").run(uid);
|
|
3454
|
+
});
|
|
3455
|
+
});
|
|
3456
|
+
moveTxn(moveList);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
// 清理内部标记
|
|
3460
|
+
return results.map((r) => {
|
|
3461
|
+
const { _movedToRaw, ...rest } = r;
|
|
3462
|
+
return rest;
|
|
3463
|
+
});
|
|
3273
3464
|
}
|
|
3274
3465
|
|
|
3275
|
-
const
|
|
3466
|
+
const memResults = [];
|
|
3276
3467
|
for (const item of updates) {
|
|
3277
3468
|
const user = getUser(item.uniqueId);
|
|
3278
3469
|
if (!user) {
|
|
3279
|
-
|
|
3470
|
+
memResults.push({ uniqueId: item.uniqueId, error: "user not found" });
|
|
3280
3471
|
continue;
|
|
3281
3472
|
}
|
|
3282
3473
|
const info = item.info;
|
|
@@ -3298,14 +3489,14 @@ export function createStore(filePath) {
|
|
|
3298
3489
|
}
|
|
3299
3490
|
user.userUpdateCount = (user.userUpdateCount || 0) + 1;
|
|
3300
3491
|
user.updatedAt = Date.now();
|
|
3301
|
-
|
|
3492
|
+
memResults.push({
|
|
3302
3493
|
uniqueId: item.uniqueId,
|
|
3303
3494
|
ok: true,
|
|
3304
3495
|
userUpdateCount: user.userUpdateCount,
|
|
3305
3496
|
});
|
|
3306
3497
|
}
|
|
3307
3498
|
save();
|
|
3308
|
-
return
|
|
3499
|
+
return memResults;
|
|
3309
3500
|
}
|
|
3310
3501
|
|
|
3311
3502
|
// 视频登记
|
|
@@ -3393,7 +3584,7 @@ export function createStore(filePath) {
|
|
|
3393
3584
|
}
|
|
3394
3585
|
|
|
3395
3586
|
function getVideosPage(limit, offset) {
|
|
3396
|
-
const safeLimit = Math.max(1, Math.min(
|
|
3587
|
+
const safeLimit = Math.max(1, Math.min(100, parseInt(limit) || 50));
|
|
3397
3588
|
const safeOffset = Math.max(0, parseInt(offset) || 0);
|
|
3398
3589
|
|
|
3399
3590
|
if (db) {
|
package/src/watch/public/app.js
CHANGED
|
@@ -33,7 +33,7 @@ async function fetchStats() {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
async function fetchUsers() {
|
|
36
|
+
async function fetchUsers(showLoad) {
|
|
37
37
|
try {
|
|
38
38
|
const params = new URLSearchParams();
|
|
39
39
|
if (currentFilter === "target") {
|
|
@@ -46,11 +46,13 @@ async function fetchUsers() {
|
|
|
46
46
|
const search = document.getElementById("searchInput").value.trim();
|
|
47
47
|
if (search) params.set("search", search);
|
|
48
48
|
if (currentLocation) params.set("location", currentLocation);
|
|
49
|
-
params.set("limit", "
|
|
49
|
+
params.set("limit", "100");
|
|
50
|
+
if (showLoad) showLoading("加载中...");
|
|
50
51
|
const res = await fetch("/api/users?" + params.toString());
|
|
51
52
|
const data = await res.json();
|
|
52
53
|
currentUsers = data.users || [];
|
|
53
54
|
renderTable(currentUsers);
|
|
55
|
+
if (showLoad) hideLoading();
|
|
54
56
|
} catch (e) {}
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -434,7 +436,7 @@ function setFilter(f) {
|
|
|
434
436
|
targetLocSel.style.display = "none";
|
|
435
437
|
currentTargetLocation = "";
|
|
436
438
|
}
|
|
437
|
-
fetchUsers();
|
|
439
|
+
fetchUsers(true);
|
|
438
440
|
}
|
|
439
441
|
|
|
440
442
|
function renderLocationFilter() {
|
|
@@ -454,15 +456,20 @@ function renderLocationFilter() {
|
|
|
454
456
|
}
|
|
455
457
|
|
|
456
458
|
function onLocationChange() {
|
|
457
|
-
const
|
|
458
|
-
currentLocation =
|
|
459
|
-
fetchUsers();
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
459
|
+
const locationSel = document.getElementById("locationFilter");
|
|
460
|
+
currentLocation = locationSel.value;
|
|
461
|
+
fetchUsers(true);
|
|
462
|
+
const targetSel = document.getElementById("targetLocationFilter");
|
|
463
|
+
currentTargetLocation = targetSel.value;
|
|
464
|
+
// 判断当前在哪个页面
|
|
465
|
+
if (document.getElementById("targetPage").classList.contains("active")) {
|
|
466
|
+
if (currentTargetSummary) {
|
|
467
|
+
renderTargetCountryGrid(currentTargetSummary.countries || []);
|
|
468
|
+
loadTargetUsersPage();
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
fetchUsers();
|
|
472
|
+
}
|
|
466
473
|
}
|
|
467
474
|
|
|
468
475
|
let searchTimer = null;
|
|
@@ -591,40 +598,6 @@ async function selectLocation(uniqueId, location) {
|
|
|
591
598
|
}
|
|
592
599
|
showToast(`@${uniqueId} 国家已更新为 ${location}`);
|
|
593
600
|
|
|
594
|
-
// 同步更新内存数据:将用户从旧国家组移到新国家组
|
|
595
|
-
if (currentTargetData && currentTargetData.countries) {
|
|
596
|
-
let oldCountry = null;
|
|
597
|
-
let userObj = null;
|
|
598
|
-
for (const country of currentTargetData.countries) {
|
|
599
|
-
const idx = country.users.findIndex((u) => u.uniqueId === uniqueId);
|
|
600
|
-
if (idx !== -1) {
|
|
601
|
-
oldCountry = country;
|
|
602
|
-
userObj = country.users.splice(idx, 1)[0];
|
|
603
|
-
break;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (userObj) {
|
|
607
|
-
userObj.locationCreated = location;
|
|
608
|
-
userObj.modifiedAt = Date.now();
|
|
609
|
-
// 找到或创建新国家组
|
|
610
|
-
let newCountry = currentTargetData.countries.find(
|
|
611
|
-
(c) => c.country === location,
|
|
612
|
-
);
|
|
613
|
-
if (!newCountry) {
|
|
614
|
-
newCountry = { country: location, count: 0, users: [] };
|
|
615
|
-
currentTargetData.countries.push(newCountry);
|
|
616
|
-
}
|
|
617
|
-
newCountry.users.push(userObj);
|
|
618
|
-
newCountry.count = newCountry.users.length;
|
|
619
|
-
// 更新旧国家组的 count
|
|
620
|
-
if (oldCountry) oldCountry.count = oldCountry.users.length;
|
|
621
|
-
// 重新渲染左侧国家列表和表格
|
|
622
|
-
renderTargetCountryGrid(currentTargetData.countries);
|
|
623
|
-
renderTargetLocationFilter(currentTargetData.countries);
|
|
624
|
-
renderTargetTable(currentTargetData.countries);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
601
|
// 只更新当前行 DOM
|
|
629
602
|
const tr = document.querySelector(`tr[data-user="${uniqueId}"]`);
|
|
630
603
|
if (tr) {
|
|
@@ -634,8 +607,16 @@ async function selectLocation(uniqueId, location) {
|
|
|
634
607
|
locCell.className = "location-cell modified";
|
|
635
608
|
locCell.innerHTML = `${location}${editIcon}`;
|
|
636
609
|
locCell.title = `点击修改国家(已修改: ${formatTime(Date.now())})`;
|
|
610
|
+
locCell.onclick = () => openLocationModal(uniqueId, location);
|
|
637
611
|
}
|
|
638
612
|
}
|
|
613
|
+
|
|
614
|
+
// 在内存用户列表中也更新
|
|
615
|
+
const userInMem = currentTargetUsers.find((u) => u.uniqueId === uniqueId);
|
|
616
|
+
if (userInMem) {
|
|
617
|
+
userInMem.locationCreated = location;
|
|
618
|
+
userInMem.modifiedAt = Date.now();
|
|
619
|
+
}
|
|
639
620
|
} catch (e) {
|
|
640
621
|
showToast("更新失败: " + e.message, true);
|
|
641
622
|
} finally {
|
|
@@ -960,6 +941,7 @@ function showTargetPage() {
|
|
|
960
941
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
961
942
|
document.getElementById("rawPage").classList.remove("active");
|
|
962
943
|
document.getElementById("targetPage").classList.add("active");
|
|
944
|
+
showLoading("正在加载目标商家数据...");
|
|
963
945
|
fetchTargetByCountry();
|
|
964
946
|
// 同步统计
|
|
965
947
|
if (currentStats) {
|
|
@@ -970,16 +952,21 @@ function showTargetPage() {
|
|
|
970
952
|
}
|
|
971
953
|
}
|
|
972
954
|
|
|
973
|
-
let
|
|
955
|
+
let currentTargetSummary = null;
|
|
956
|
+
let currentTargetUsers = [];
|
|
957
|
+
let currentTargetTotal = 0;
|
|
958
|
+
let currentTargetLoading = false;
|
|
959
|
+
let currentTargetAllLoaded = false;
|
|
960
|
+
let currentTargetSeq = 0;
|
|
961
|
+
const TARGET_PAGE_SIZE = 200;
|
|
974
962
|
|
|
975
963
|
async function fetchTargetByCountry() {
|
|
976
964
|
try {
|
|
977
|
-
const res = await fetch("/api/target-users-by-country");
|
|
965
|
+
const res = await fetch("/api/target-users-by-country?summary=1");
|
|
978
966
|
const data = await res.json();
|
|
979
|
-
|
|
967
|
+
currentTargetSummary = data;
|
|
980
968
|
renderTargetCountryGrid(data.countries || []);
|
|
981
|
-
|
|
982
|
-
renderTargetTable(data.countries || []);
|
|
969
|
+
renderTargetPageLocationFilter(data.countries || []);
|
|
983
970
|
document.getElementById("targetPageStatTotal").textContent = formatStatNum(
|
|
984
971
|
data.total || 0,
|
|
985
972
|
{ full: true },
|
|
@@ -987,8 +974,11 @@ async function fetchTargetByCountry() {
|
|
|
987
974
|
document.getElementById("targetPageStatCountries").textContent = (
|
|
988
975
|
data.countries || []
|
|
989
976
|
).length;
|
|
977
|
+
await loadTargetUsersPage(false, true);
|
|
990
978
|
} catch (e) {
|
|
991
979
|
console.error("获取目标商家数据失败:", e);
|
|
980
|
+
} finally {
|
|
981
|
+
hideLoading();
|
|
992
982
|
}
|
|
993
983
|
}
|
|
994
984
|
|
|
@@ -999,17 +989,18 @@ function renderTargetCountryGrid(countries) {
|
|
|
999
989
|
'<span style="color:#666;font-size:12px">暂无目标商家</span>';
|
|
1000
990
|
return;
|
|
1001
991
|
}
|
|
1002
|
-
const total = countries.reduce((sum, c) => sum + c.count, 0);
|
|
992
|
+
const total = countries.reduce((sum, c) => sum + (c.count || 0), 0);
|
|
1003
993
|
grid.innerHTML = countries
|
|
1004
994
|
.map((c) => {
|
|
1005
|
-
const
|
|
995
|
+
const cnt = c.count || 0;
|
|
996
|
+
const pct = total > 0 ? ((cnt / total) * 100).toFixed(1) : "0.0";
|
|
1006
997
|
const safeCountry = escapeJsString(c.country);
|
|
1007
998
|
const sel = currentTargetLocation === c.country ? " selected" : "";
|
|
1008
999
|
return `
|
|
1009
1000
|
<div class="pending-country-item has-target${sel}"
|
|
1010
1001
|
onclick="filterTargetByCountry('${safeCountry}')">
|
|
1011
1002
|
<div class="country-name">${c.country}</div>
|
|
1012
|
-
<div class="country-count">${
|
|
1003
|
+
<div class="country-count">${cnt}</div>
|
|
1013
1004
|
<div class="country-label">${pct}% 目标商家</div>
|
|
1014
1005
|
</div>
|
|
1015
1006
|
`;
|
|
@@ -1017,7 +1008,7 @@ function renderTargetCountryGrid(countries) {
|
|
|
1017
1008
|
.join("");
|
|
1018
1009
|
}
|
|
1019
1010
|
|
|
1020
|
-
function
|
|
1011
|
+
function renderTargetPageLocationFilter(countries) {
|
|
1021
1012
|
const sel = document.getElementById("targetLocationFilter");
|
|
1022
1013
|
if (!sel) return;
|
|
1023
1014
|
const val = sel.value;
|
|
@@ -1026,46 +1017,90 @@ function renderTargetLocationFilter(countries) {
|
|
|
1026
1017
|
countries
|
|
1027
1018
|
.map(
|
|
1028
1019
|
(c) =>
|
|
1029
|
-
`<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
|
|
1020
|
+
`<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count || 0})</option>`,
|
|
1030
1021
|
)
|
|
1031
1022
|
.join("");
|
|
1032
1023
|
}
|
|
1033
1024
|
|
|
1034
|
-
function
|
|
1035
|
-
|
|
1025
|
+
async function loadTargetUsersPage(append = false, skipLoading = false) {
|
|
1026
|
+
if (currentTargetLoading) return;
|
|
1027
|
+
currentTargetLoading = true;
|
|
1028
|
+
|
|
1029
|
+
const seq = ++currentTargetSeq;
|
|
1036
1030
|
const search = document.getElementById("targetSearchInput")
|
|
1037
|
-
? document.getElementById("targetSearchInput").value.trim()
|
|
1031
|
+
? document.getElementById("targetSearchInput").value.trim()
|
|
1038
1032
|
: "";
|
|
1039
|
-
const
|
|
1033
|
+
const offset = append ? currentTargetUsers.length : 0;
|
|
1040
1034
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
allUsers = allUsers.concat(c.users);
|
|
1035
|
+
// 非追加模式(重置加载)时显示 loading,除非外层已经显示了
|
|
1036
|
+
if (!append && !skipLoading) {
|
|
1037
|
+
showLoading("正在加载数据...");
|
|
1045
1038
|
}
|
|
1046
1039
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1040
|
+
let data = null;
|
|
1041
|
+
let error = null;
|
|
1042
|
+
try {
|
|
1043
|
+
let url = `/api/target-users-by-country?limit=${TARGET_PAGE_SIZE}&offset=${offset}`;
|
|
1044
|
+
if (currentTargetLocation)
|
|
1045
|
+
url += `&country=${encodeURIComponent(currentTargetLocation)}`;
|
|
1046
|
+
if (search) url += `&search=${encodeURIComponent(search)}`;
|
|
1047
|
+
|
|
1048
|
+
const res = await fetch(url);
|
|
1049
|
+
data = await res.json();
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
error = e;
|
|
1052
|
+
console.error("加载目标用户失败:", e);
|
|
1053
|
+
} finally {
|
|
1054
|
+
// 非追加模式时隐藏 loading,除非外层负责隐藏
|
|
1055
|
+
if (!append && !skipLoading) {
|
|
1056
|
+
hideLoading();
|
|
1057
|
+
}
|
|
1058
|
+
currentTargetLoading = false;
|
|
1059
|
+
|
|
1060
|
+
if (error || !data) return;
|
|
1061
|
+
|
|
1062
|
+
// 防止旧请求覆盖新请求的结果
|
|
1063
|
+
if (seq !== currentTargetSeq && !append) return;
|
|
1064
|
+
|
|
1065
|
+
if (!append) {
|
|
1066
|
+
currentTargetUsers = data.users || [];
|
|
1067
|
+
currentTargetTotal = data.total || 0;
|
|
1068
|
+
currentTargetAllLoaded = currentTargetUsers.length >= currentTargetTotal;
|
|
1069
|
+
} else {
|
|
1070
|
+
currentTargetUsers = currentTargetUsers.concat(data.users || []);
|
|
1071
|
+
currentTargetAllLoaded = currentTargetUsers.length >= currentTargetTotal;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// 所有状态更新完成后,再渲染
|
|
1075
|
+
renderTargetTable();
|
|
1053
1076
|
}
|
|
1077
|
+
}
|
|
1054
1078
|
|
|
1055
|
-
|
|
1079
|
+
function updateLoadMoreButton() {
|
|
1080
|
+
// 按钮已移除,保留函数名以兼容旧调用
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function renderTargetTable() {
|
|
1084
|
+
const el = document.getElementById("targetTable");
|
|
1085
|
+
const moreHint = document.getElementById("targetMoreHint");
|
|
1086
|
+
|
|
1087
|
+
if (currentTargetUsers.length === 0) {
|
|
1056
1088
|
el.innerHTML =
|
|
1057
|
-
'<tr><td colspan="
|
|
1089
|
+
'<tr><td colspan="9" style="text-align:center;color:#888;padding:24px">暂无数据</td></tr>';
|
|
1090
|
+
if (moreHint) {
|
|
1091
|
+
moreHint.style.display = "none";
|
|
1092
|
+
}
|
|
1058
1093
|
return;
|
|
1059
1094
|
}
|
|
1060
1095
|
|
|
1061
|
-
el.innerHTML =
|
|
1062
|
-
.map((u) => {
|
|
1096
|
+
el.innerHTML = currentTargetUsers
|
|
1097
|
+
.map((u, i) => {
|
|
1063
1098
|
const nick = (u.nickname || "")
|
|
1064
1099
|
.replace(/</g, "<")
|
|
1065
1100
|
.replace(/>/g, ">");
|
|
1066
1101
|
const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
|
|
1067
1102
|
const videos = u.videoCount != null ? u.videoCount : "-";
|
|
1068
|
-
const
|
|
1103
|
+
const userLocation = u.locationCreated || "-";
|
|
1069
1104
|
const confirmedLocation = u.confirmedLocation
|
|
1070
1105
|
? u.confirmedLocation === u.locationCreated
|
|
1071
1106
|
? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
|
|
@@ -1075,13 +1110,11 @@ function renderTargetTable(countries) {
|
|
|
1075
1110
|
? formatTime(u.latestVideoTime * 1000)
|
|
1076
1111
|
: "-";
|
|
1077
1112
|
const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
|
|
1078
|
-
const sources = (u.sources || []).join(", ");
|
|
1079
1113
|
|
|
1080
|
-
// 修改过的国家用特殊颜色,带编辑图标
|
|
1081
1114
|
const editIcon = ' <span style="font-size:10px;opacity:0.5">✏️</span>';
|
|
1082
1115
|
const locationCell = u.modifiedAt
|
|
1083
|
-
? `<td data-label="国家" class="location-cell modified" onclick="openLocationModal('${u.uniqueId}','${
|
|
1084
|
-
: `<td data-label="国家" class="location-cell" onclick="openLocationModal('${u.uniqueId}','${
|
|
1116
|
+
? `<td data-label="国家" class="location-cell modified" onclick="openLocationModal('${u.uniqueId}','${userLocation}')" title="点击修改国家(已修改: ${formatTime(u.modifiedAt)})">${userLocation}${editIcon}</td>`
|
|
1117
|
+
: `<td data-label="国家" class="location-cell" onclick="openLocationModal('${u.uniqueId}','${userLocation}')" title="点击修改国家">${userLocation}${editIcon}</td>`;
|
|
1085
1118
|
|
|
1086
1119
|
let statusTag = "";
|
|
1087
1120
|
if (u.status === "done")
|
|
@@ -1097,6 +1130,7 @@ function renderTargetTable(countries) {
|
|
|
1097
1130
|
else statusTag = u.status || "-";
|
|
1098
1131
|
|
|
1099
1132
|
return `<tr data-user="${u.uniqueId}">
|
|
1133
|
+
<td style="color:#9ca3af;font-size:12px;text-align:center" data-label="#">${i + 1}</td>
|
|
1100
1134
|
<td class="user-id" data-label="用户名">@${u.uniqueId}</td>
|
|
1101
1135
|
<td data-label="昵称">${nick}</td>
|
|
1102
1136
|
<td data-label="粉丝">${fans}</td>
|
|
@@ -1108,6 +1142,24 @@ function renderTargetTable(countries) {
|
|
|
1108
1142
|
</tr>`;
|
|
1109
1143
|
})
|
|
1110
1144
|
.join("");
|
|
1145
|
+
|
|
1146
|
+
if (moreHint) {
|
|
1147
|
+
moreHint.style.display = "";
|
|
1148
|
+
if (currentTargetAllLoaded) {
|
|
1149
|
+
moreHint.innerHTML = `共 <b>${currentTargetTotal}</b> 条,已全部加载`;
|
|
1150
|
+
moreHint.style.color = "#9ca3af";
|
|
1151
|
+
moreHint.style.cursor = "default";
|
|
1152
|
+
} else if (currentTargetLoading) {
|
|
1153
|
+
moreHint.innerHTML = `已加载 <b>${currentTargetUsers.length}</b> / ${currentTargetTotal} 条,加载中...`;
|
|
1154
|
+
moreHint.style.color = "#9ca3af";
|
|
1155
|
+
moreHint.style.cursor = "wait";
|
|
1156
|
+
} else {
|
|
1157
|
+
moreHint.innerHTML = `已加载 <b>${currentTargetUsers.length}</b> / ${currentTargetTotal} 条,<u style="color:#3b82f6">点击此处加载更多</u> ↓`;
|
|
1158
|
+
moreHint.style.color = "#6b7280";
|
|
1159
|
+
moreHint.style.cursor = "pointer";
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
updateLoadMoreButton();
|
|
1111
1163
|
}
|
|
1112
1164
|
|
|
1113
1165
|
function filterTargetByCountry(country) {
|
|
@@ -1116,18 +1168,9 @@ function filterTargetByCountry(country) {
|
|
|
1116
1168
|
if (sel) sel.value = country;
|
|
1117
1169
|
const btn = document.getElementById("targetReprocessBtn");
|
|
1118
1170
|
if (btn) btn.style.display = "";
|
|
1119
|
-
if (
|
|
1120
|
-
renderTargetCountryGrid(
|
|
1121
|
-
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function onTargetLocationChange() {
|
|
1126
|
-
const sel = document.getElementById("targetLocationFilter");
|
|
1127
|
-
currentTargetLocation = sel.value;
|
|
1128
|
-
if (currentTargetData) {
|
|
1129
|
-
renderTargetCountryGrid(currentTargetData.countries || []);
|
|
1130
|
-
renderTargetTable(currentTargetData.countries || []);
|
|
1171
|
+
if (currentTargetSummary) {
|
|
1172
|
+
renderTargetCountryGrid(currentTargetSummary.countries || []);
|
|
1173
|
+
loadTargetUsersPage();
|
|
1131
1174
|
}
|
|
1132
1175
|
}
|
|
1133
1176
|
|
|
@@ -1139,53 +1182,54 @@ function clearTargetFilters() {
|
|
|
1139
1182
|
if (locationFilter) locationFilter.value = "";
|
|
1140
1183
|
const btn = document.getElementById("targetReprocessBtn");
|
|
1141
1184
|
if (btn) btn.style.display = "none";
|
|
1142
|
-
if (
|
|
1143
|
-
renderTargetCountryGrid(
|
|
1144
|
-
|
|
1185
|
+
if (currentTargetSummary) {
|
|
1186
|
+
renderTargetCountryGrid(currentTargetSummary.countries || []);
|
|
1187
|
+
loadTargetUsersPage();
|
|
1145
1188
|
}
|
|
1146
1189
|
}
|
|
1147
1190
|
|
|
1148
1191
|
async function reprocessTargetUsers() {
|
|
1149
|
-
if (!currentTargetLocation
|
|
1192
|
+
if (!currentTargetLocation) return;
|
|
1150
1193
|
const country = currentTargetLocation;
|
|
1151
|
-
const countryData = currentTargetData.countries.find((c) => c.country === country);
|
|
1152
|
-
const users = countryData ? countryData.users : [];
|
|
1153
1194
|
const search = document.getElementById("targetSearchInput")
|
|
1154
|
-
? document.getElementById("targetSearchInput").value.trim()
|
|
1195
|
+
? document.getElementById("targetSearchInput").value.trim()
|
|
1155
1196
|
: "";
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
(u.uniqueId || "").toLowerCase().includes(search) ||
|
|
1160
|
-
(u.nickname || "").toLowerCase().includes(search),
|
|
1161
|
-
)
|
|
1162
|
-
: users;
|
|
1163
|
-
if (filtered.length === 0) {
|
|
1164
|
-
showToast("当前筛选结果为空", true);
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
1167
|
-
if (
|
|
1168
|
-
!confirm(
|
|
1169
|
-
`确定要重新处理 ${country} 的 ${filtered.length} 个目标商家吗?\n这将重置它们的任务状态,使客户端重新采集。`,
|
|
1170
|
-
)
|
|
1171
|
-
) {
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
const userIds = filtered.map((u) => u.uniqueId);
|
|
1175
|
-
showLoading("正在批量重置...");
|
|
1197
|
+
|
|
1198
|
+
// 加载该国家全量用户用于重置
|
|
1199
|
+
showLoading("正在加载用户列表...");
|
|
1176
1200
|
try {
|
|
1177
|
-
|
|
1201
|
+
let url = `/api/target-users-by-country?limit=99999&offset=0&country=${encodeURIComponent(country)}`;
|
|
1202
|
+
if (search) url += `&search=${encodeURIComponent(search)}`;
|
|
1203
|
+
const res = await fetch(url);
|
|
1204
|
+
const data = await res.json();
|
|
1205
|
+
const users = data.users || [];
|
|
1206
|
+
|
|
1207
|
+
if (users.length === 0) {
|
|
1208
|
+
showToast("当前筛选结果为空", true);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (
|
|
1212
|
+
!confirm(
|
|
1213
|
+
`确定要重新处理 ${country} 的 ${users.length} 个目标商家吗?\n这将重置它们的任务状态,使客户端重新采集。`,
|
|
1214
|
+
)
|
|
1215
|
+
) {
|
|
1216
|
+
hideLoading();
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
showLoading("正在批量重置...");
|
|
1220
|
+
const userIds = users.map((u) => u.uniqueId);
|
|
1221
|
+
const resetRes = await fetch("/api/jobs/batch-reset", {
|
|
1178
1222
|
method: "POST",
|
|
1179
1223
|
headers: { "Content-Type": "application/json" },
|
|
1180
1224
|
body: JSON.stringify({ userIds }),
|
|
1181
1225
|
});
|
|
1182
|
-
const
|
|
1183
|
-
if (
|
|
1184
|
-
showToast(
|
|
1226
|
+
const resetData = await resetRes.json();
|
|
1227
|
+
if (resetData.error) {
|
|
1228
|
+
showToast(resetData.error, true);
|
|
1185
1229
|
return;
|
|
1186
1230
|
}
|
|
1187
|
-
showToast(`已重置 ${
|
|
1188
|
-
|
|
1231
|
+
showToast(`已重置 ${resetData.reset} / ${resetData.total} 个用户`);
|
|
1232
|
+
fetchTargetByCountry();
|
|
1189
1233
|
} catch (e) {
|
|
1190
1234
|
showToast("批量重置失败: " + e.message, true);
|
|
1191
1235
|
} finally {
|
|
@@ -1198,8 +1242,8 @@ let targetSearchTimer = null;
|
|
|
1198
1242
|
document.getElementById("targetSearchInput").addEventListener("input", () => {
|
|
1199
1243
|
if (targetSearchTimer) clearTimeout(targetSearchTimer);
|
|
1200
1244
|
targetSearchTimer = setTimeout(() => {
|
|
1201
|
-
if (
|
|
1202
|
-
|
|
1245
|
+
if (currentTargetSummary) {
|
|
1246
|
+
loadTargetUsersPage();
|
|
1203
1247
|
}
|
|
1204
1248
|
}, 300);
|
|
1205
1249
|
});
|
|
@@ -1228,6 +1272,29 @@ document
|
|
|
1228
1272
|
}
|
|
1229
1273
|
});
|
|
1230
1274
|
|
|
1275
|
+
document
|
|
1276
|
+
.getElementById("refreshTargetBtn")
|
|
1277
|
+
.addEventListener("click", async () => {
|
|
1278
|
+
showLoading("正在刷新...");
|
|
1279
|
+
try {
|
|
1280
|
+
currentTargetLocation = "";
|
|
1281
|
+
const searchInput = document.getElementById("targetSearchInput");
|
|
1282
|
+
const locationFilter = document.getElementById("targetLocationFilter");
|
|
1283
|
+
if (searchInput) searchInput.value = "";
|
|
1284
|
+
if (locationFilter) locationFilter.value = "";
|
|
1285
|
+
const btn = document.getElementById("targetReprocessBtn");
|
|
1286
|
+
if (btn) btn.style.display = "none";
|
|
1287
|
+
await fetchTargetByCountry();
|
|
1288
|
+
showToast("刷新完成");
|
|
1289
|
+
} catch (e) {
|
|
1290
|
+
showToast("刷新失败: " + e.message, true);
|
|
1291
|
+
} finally {
|
|
1292
|
+
hideLoading();
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
// 加载更多按钮由 HTML 中 onclick="loadTargetUsersPage(true)" 直接触发
|
|
1297
|
+
|
|
1231
1298
|
async function fetchPendingByCountry() {
|
|
1232
1299
|
try {
|
|
1233
1300
|
const res = await fetch("/api/pending-by-country");
|
|
@@ -1771,6 +1838,7 @@ fetchStats();
|
|
|
1771
1838
|
fetchUsers();
|
|
1772
1839
|
fetchClientErrors();
|
|
1773
1840
|
initTableSorting();
|
|
1841
|
+
|
|
1774
1842
|
setInterval(fetchStats, 10000);
|
|
1775
1843
|
setInterval(fetchUsers, 10000);
|
|
1776
1844
|
setInterval(fetchClientErrors, 10000);
|
|
@@ -296,6 +296,9 @@
|
|
|
296
296
|
<div class="stat-card clickable" id="exportTargetCsvBtn" style="background:rgba(34,197,94,0.12);cursor:pointer">
|
|
297
297
|
<div class="label">导出 CSV</div>
|
|
298
298
|
</div>
|
|
299
|
+
<div class="stat-card clickable" id="refreshTargetBtn" style="background:rgba(59,130,246,0.12);cursor:pointer">
|
|
300
|
+
<div class="label">🔄 刷新</div>
|
|
301
|
+
</div>
|
|
299
302
|
</div>
|
|
300
303
|
<div class="target-page-layout">
|
|
301
304
|
<div class="target-side-card">
|
|
@@ -316,6 +319,7 @@
|
|
|
316
319
|
<table>
|
|
317
320
|
<thead>
|
|
318
321
|
<tr>
|
|
322
|
+
<th style="width:48px">#</th>
|
|
319
323
|
<th>用户名</th>
|
|
320
324
|
<th>昵称</th>
|
|
321
325
|
<th>粉丝</th>
|
|
@@ -328,6 +332,9 @@
|
|
|
328
332
|
</thead>
|
|
329
333
|
<tbody id="targetTable"></tbody>
|
|
330
334
|
</table>
|
|
335
|
+
<div id="targetMoreHint" onclick="loadTargetUsersPage(true)"
|
|
336
|
+
style="text-align:center;padding:10px 8px;font-size:13px;color:#6b7280;cursor:default;user-select:none;transition:color 0.2s">
|
|
337
|
+
</div>
|
|
331
338
|
</div>
|
|
332
339
|
</div>
|
|
333
340
|
</div>
|
package/src/watch/server.js
CHANGED
|
@@ -24,8 +24,24 @@ function getLocalIP() {
|
|
|
24
24
|
const __dirname = dirname(__filename);
|
|
25
25
|
const publicDir = join(__dirname, "public");
|
|
26
26
|
|
|
27
|
+
// Stats 缓存:避免每次请求都扫描 100 万+ 条 jobs 数据
|
|
28
|
+
let statsCache = null;
|
|
29
|
+
let statsCacheTime = 0;
|
|
30
|
+
const STATS_CACHE_TTL = 10000; // 10 秒缓存
|
|
31
|
+
|
|
32
|
+
function invalidateStatsCache() {
|
|
33
|
+
statsCache = null;
|
|
34
|
+
statsCacheTime = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
function computeStatsIncremental(st) {
|
|
28
|
-
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
|
|
40
|
+
return statsCache;
|
|
41
|
+
}
|
|
42
|
+
statsCache = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
|
|
43
|
+
statsCacheTime = now;
|
|
44
|
+
return statsCache;
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
function readBody(req) {
|
|
@@ -553,8 +569,21 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
553
569
|
req.method === "GET" &&
|
|
554
570
|
routePath === "/api/target-users-by-country"
|
|
555
571
|
) {
|
|
556
|
-
|
|
572
|
+
// 摘要模式:只返回各国统计数
|
|
573
|
+
if (params.summary === "1") {
|
|
574
|
+
const result = store.getTargetUsersByCountry(
|
|
575
|
+
DEFAULT_TARGET_LOCATIONS,
|
|
576
|
+
{ summaryOnly: true },
|
|
577
|
+
);
|
|
578
|
+
sendJSON(res, 200, result);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// CSV 导出:全量数据
|
|
557
583
|
if (req.headers["accept"]?.includes("text/csv")) {
|
|
584
|
+
const result = store.getTargetUsersByCountry(
|
|
585
|
+
DEFAULT_TARGET_LOCATIONS,
|
|
586
|
+
);
|
|
558
587
|
const columns = [
|
|
559
588
|
"uniqueId",
|
|
560
589
|
"nickname",
|
|
@@ -603,9 +632,27 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
603
632
|
columns.map((c) => csvEscape(r[c])).join(","),
|
|
604
633
|
);
|
|
605
634
|
res.end(BOM + [header, ...lines].join("\r\n"));
|
|
606
|
-
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 分页模式:按国家+搜索+分页查询用户
|
|
639
|
+
if (params.limit) {
|
|
640
|
+
const result = store.getTargetUsersByCountry(
|
|
641
|
+
DEFAULT_TARGET_LOCATIONS,
|
|
642
|
+
{
|
|
643
|
+
country: params.country || undefined,
|
|
644
|
+
search: params.search || undefined,
|
|
645
|
+
limit: parseInt(params.limit, 10),
|
|
646
|
+
offset: parseInt(params.offset || "0", 10),
|
|
647
|
+
},
|
|
648
|
+
);
|
|
607
649
|
sendJSON(res, 200, result);
|
|
650
|
+
return;
|
|
608
651
|
}
|
|
652
|
+
|
|
653
|
+
// 默认:全量(兼容旧调用)
|
|
654
|
+
const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
|
|
655
|
+
sendJSON(res, 200, result);
|
|
609
656
|
return;
|
|
610
657
|
}
|
|
611
658
|
|