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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.72",
3
+ "version": "1.3.73",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
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
- if (task && task.info && task.info.error) {
292
+ const info = task?.info;
293
+ const sellerTag = info?.ttSeller ? " [商家ttSeller=true]" : "";
294
+ if (info && info.error) {
293
295
  attachLog(
294
- ` @${r.uniqueId} 已记录 (statusCode=${task.info.statusCode})`,
296
+ ` ⚠${sellerTag} @${r.uniqueId} 已记录 (statusCode=${info.statusCode})`,
295
297
  );
296
298
  } else {
297
- attachLog(` @${r.uniqueId} 已提交更新`);
299
+ attachLog(` ✓${sellerTag} @${r.uniqueId} 已提交更新`);
298
300
  }
299
301
  } else {
300
302
  failCount++;
@@ -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,
@@ -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 total = getJobsCount();
554
- const statusCounts = {
555
- pending: 0,
556
- processing: 0,
557
- done: 0,
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 status, COUNT(*) as count
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
- .all();
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(...targetLocations);
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: getPendingJobsCount(),
660
- processedUsers: statusCounts.done,
661
- pendingUsers: statusCounts.pending,
662
- processingUsers: statusCounts.processing,
663
- restrictedUsers: statusCounts.restricted,
664
- errorUsers: statusCounts.error,
665
- targetUsers,
666
- userUpdateTasks: getPendingJobsUserUpdateCount(),
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: sourceRow.seed || 0,
671
- video: sourceRow.video || 0,
672
- comment: sourceRow.comment || 0,
673
- guess: sourceRow.guess || 0,
674
- following: sourceRow.following || 0,
675
- follower: sourceRow.follower || 0,
676
- processed: sourceRow.processed || 0,
677
- restricted: sourceRow.restricted || 0,
678
- error: sourceRow.error || 0,
679
- noVideo: sourceRow.noVideo || 0,
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
- SELECT *
1340
- FROM jobs
1341
- ${whereSql}
1342
- ORDER BY
1343
- pinned DESC,
1344
- CASE status
1345
- WHEN 'processing' THEN 0
1346
- WHEN 'pending' THEN 1
1347
- WHEN 'done' THEN 2
1348
- WHEN 'error' THEN 3
1349
- WHEN 'restricted' THEN 4
1350
- ELSE 9
1351
- END ASC,
1352
- CASE
1353
- WHEN status = 'done' THEN -COALESCE(processed_at, 0)
1354
- ELSE 0
1355
- END ASC,
1356
- CASE
1357
- WHEN status = 'pending' AND tt_seller = 1 AND verified = 0 THEN 0
1358
- ELSE 1
1359
- END ASC,
1360
- CASE
1361
- WHEN status = 'pending' AND UPPER(COALESCE(guessed_location, '')) IN ('PL', 'NL', 'BE', 'AT') THEN 0
1362
- WHEN status = 'pending' AND UPPER(COALESCE(guessed_location, '')) IN ('DE', 'FR', 'IT', 'IE', 'ES') THEN 1
1363
- ELSE 2
1364
- END ASC,
1365
- COALESCE(follower_count, 0) DESC,
1366
- COALESCE(processed_at, 0) DESC,
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
- SELECT
1391
- unique_id,
1392
- nickname,
1393
- follower_count,
1394
- tt_seller,
1395
- verified,
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
- const column = camelToSnake(key);
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 = result.userInfo?.signature ?? 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 txn = db.transaction((items) =>
3250
- items.map((item) => {
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
- return updateJobInfo(
3256
- item.uniqueId,
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
- return updateJobInfo(item.uniqueId, info, true);
3262
- }),
3263
- );
3264
- return txn(updates).map((result, index) =>
3265
- result.error
3266
- ? { uniqueId: updates[index].uniqueId, error: result.error }
3267
- : {
3268
- uniqueId: updates[index].uniqueId,
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: result.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 results = [];
3466
+ const memResults = [];
3276
3467
  for (const item of updates) {
3277
3468
  const user = getUser(item.uniqueId);
3278
3469
  if (!user) {
3279
- results.push({ uniqueId: item.uniqueId, error: "user not found" });
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
- results.push({
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 results;
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(200, parseInt(limit) || 50));
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) {
@@ -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", "200");
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 sel = document.getElementById("locationFilter");
458
- currentLocation = sel.value;
459
- fetchUsers();
460
- }
461
-
462
- function onTargetLocationChange() {
463
- const sel = document.getElementById("targetLocationFilter");
464
- currentTargetLocation = sel.value;
465
- fetchUsers();
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 currentTargetData = null;
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
- currentTargetData = data;
967
+ currentTargetSummary = data;
980
968
  renderTargetCountryGrid(data.countries || []);
981
- renderTargetLocationFilter(data.countries || []);
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 pct = ((c.count / total) * 100).toFixed(1);
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">${c.count}</div>
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 renderTargetLocationFilter(countries) {
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 renderTargetTable(countries) {
1035
- const el = document.getElementById("targetTable");
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().toLowerCase()
1031
+ ? document.getElementById("targetSearchInput").value.trim()
1038
1032
  : "";
1039
- const location = currentTargetLocation;
1033
+ const offset = append ? currentTargetUsers.length : 0;
1040
1034
 
1041
- let allUsers = [];
1042
- for (const c of countries) {
1043
- if (location && c.country !== location) continue;
1044
- allUsers = allUsers.concat(c.users);
1035
+ // 非追加模式(重置加载)时显示 loading,除非外层已经显示了
1036
+ if (!append && !skipLoading) {
1037
+ showLoading("正在加载数据...");
1045
1038
  }
1046
1039
 
1047
- if (search) {
1048
- allUsers = allUsers.filter(
1049
- (u) =>
1050
- (u.uniqueId || "").toLowerCase().includes(search) ||
1051
- (u.nickname || "").toLowerCase().includes(search),
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
- if (!allUsers.length) {
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="8" style="color:#666;text-align:center;padding:24px">暂无目标商家</td></tr>';
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 = allUsers
1062
- .map((u) => {
1096
+ el.innerHTML = currentTargetUsers
1097
+ .map((u, i) => {
1063
1098
  const nick = (u.nickname || "")
1064
1099
  .replace(/</g, "&lt;")
1065
1100
  .replace(/>/g, "&gt;");
1066
1101
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
1067
1102
  const videos = u.videoCount != null ? u.videoCount : "-";
1068
- const location = u.locationCreated || "-";
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}','${location}')" title="点击修改国家(已修改: ${formatTime(u.modifiedAt)})">${location}${editIcon}</td>`
1084
- : `<td data-label="国家" class="location-cell" onclick="openLocationModal('${u.uniqueId}','${location}')" title="点击修改国家">${location}${editIcon}</td>`;
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 (currentTargetData) {
1120
- renderTargetCountryGrid(currentTargetData.countries || []);
1121
- renderTargetTable(currentTargetData.countries || []);
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 (currentTargetData) {
1143
- renderTargetCountryGrid(currentTargetData.countries || []);
1144
- renderTargetTable(currentTargetData.countries || []);
1185
+ if (currentTargetSummary) {
1186
+ renderTargetCountryGrid(currentTargetSummary.countries || []);
1187
+ loadTargetUsersPage();
1145
1188
  }
1146
1189
  }
1147
1190
 
1148
1191
  async function reprocessTargetUsers() {
1149
- if (!currentTargetLocation || !currentTargetData) return;
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().toLowerCase()
1195
+ ? document.getElementById("targetSearchInput").value.trim()
1155
1196
  : "";
1156
- const filtered = search
1157
- ? users.filter(
1158
- (u) =>
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
- const res = await fetch("/api/jobs/batch-reset", {
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 data = await res.json();
1183
- if (data.error) {
1184
- showToast(data.error, true);
1226
+ const resetData = await resetRes.json();
1227
+ if (resetData.error) {
1228
+ showToast(resetData.error, true);
1185
1229
  return;
1186
1230
  }
1187
- showToast(`已重置 ${data.reset} / ${data.total} 个用户`);
1188
- fetchTargetData();
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 (currentTargetData) {
1202
- renderTargetTable(currentTargetData.countries || []);
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>
@@ -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
- return st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
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
- const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
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
- } else {
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