tt-help-cli-ycl 1.3.48 → 1.3.50

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.
Files changed (64) hide show
  1. package/README.md +33 -33
  2. package/cli.js +9 -9
  3. package/package.json +52 -52
  4. package/scripts/run-explore copy.bat +101 -101
  5. package/scripts/run-explore.bat +134 -134
  6. package/scripts/run-explore.ps1 +159 -159
  7. package/scripts/run-explore.sh +121 -121
  8. package/scripts/test-captcha-lib.mjs +68 -0
  9. package/scripts/test-captcha.mjs +81 -0
  10. package/scripts/test-incognito-lib.mjs +36 -0
  11. package/scripts/test-login-state.mjs +128 -0
  12. package/scripts/test-safe-click.mjs +45 -0
  13. package/scripts/test-watch-db-smoke.mjs +246 -0
  14. package/src/cli/attach.js +331 -331
  15. package/src/cli/auto.js +265 -265
  16. package/src/cli/comments.js +620 -620
  17. package/src/cli/config.js +170 -170
  18. package/src/cli/db-import.js +51 -51
  19. package/src/cli/explore.js +555 -555
  20. package/src/cli/open.js +109 -111
  21. package/src/cli/progress.js +111 -111
  22. package/src/cli/refresh.js +288 -288
  23. package/src/cli/scrape.js +47 -47
  24. package/src/cli/utils.js +18 -18
  25. package/src/cli/videos.js +41 -41
  26. package/src/cli/videostats.js +196 -196
  27. package/src/cli/watch.js +30 -30
  28. package/src/lib/api-interceptor.js +161 -161
  29. package/src/lib/args.js +809 -809
  30. package/src/lib/browser/anti-detect.js +23 -23
  31. package/src/lib/browser/cdp.js +261 -261
  32. package/src/lib/browser/health-checker.js +114 -114
  33. package/src/lib/browser/launch.js +43 -43
  34. package/src/lib/browser/page.js +184 -184
  35. package/src/lib/constants.js +297 -297
  36. package/src/lib/delay.js +54 -54
  37. package/src/lib/explore-fetch.js +118 -118
  38. package/src/lib/fetcher.js +45 -45
  39. package/src/lib/filter.js +66 -66
  40. package/src/lib/io.js +54 -54
  41. package/src/lib/output.js +80 -80
  42. package/src/lib/page-error-detector.js +109 -109
  43. package/src/lib/parse-ssr.mjs +69 -69
  44. package/src/lib/parser.js +47 -47
  45. package/src/lib/retry.js +45 -45
  46. package/src/lib/scrape.js +90 -90
  47. package/src/lib/target-locations.js +61 -61
  48. package/src/lib/tiktok-scraper.mjs +98 -61
  49. package/src/lib/url.js +52 -52
  50. package/src/main.js +73 -73
  51. package/src/npm-main.js +70 -70
  52. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  53. package/src/scraper/auto-core.js +203 -203
  54. package/src/scraper/core.js +255 -255
  55. package/src/scraper/explore-core.js +208 -208
  56. package/src/scraper/modules/captcha-handler.js +114 -114
  57. package/src/scraper/modules/follow-extractor.js +250 -250
  58. package/src/scraper/modules/guess-extractor.js +51 -51
  59. package/src/scraper/modules/page-helpers.js +48 -48
  60. package/src/scraper/refresh-core.js +213 -213
  61. package/src/videos/core.js +143 -143
  62. package/src/watch/data-store.js +2980 -2980
  63. package/src/watch/public/index.html +2355 -2355
  64. package/src/watch/server.js +727 -727
@@ -1,2356 +1,2356 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
-
4
- <head>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>TikTok 采集监控</title>
8
- <style>
9
- * {
10
- margin: 0;
11
- padding: 0;
12
- box-sizing: border-box;
13
- }
14
-
15
- body {
16
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
17
- background: #0f0f13;
18
- color: #e0e0e0;
19
- padding: 16px;
20
- }
21
-
22
- .header {
23
- display: flex;
24
- align-items: center;
25
- justify-content: space-between;
26
- padding: 12px 16px;
27
- background: #1a1a24;
28
- border-radius: 8px;
29
- margin-bottom: 16px;
30
- }
31
-
32
- .header h1 {
33
- font-size: 18px;
34
- color: #fe2c55;
35
- }
36
-
37
- .header .meta {
38
- font-size: 12px;
39
- color: #888;
40
- }
41
-
42
- .header .status {
43
- font-size: 12px;
44
- color: #4ade80;
45
- }
46
-
47
- .script-link {
48
- font-size: 12px;
49
- color: #60a5fa;
50
- text-decoration: none;
51
- padding: 2px 8px;
52
- border: 1px solid #60a5fa;
53
- border-radius: 4px;
54
- }
55
-
56
- .script-link:hover {
57
- background: #60a5fa;
58
- color: #fff;
59
- }
60
-
61
- .stats {
62
- display: grid;
63
- grid-template-columns: repeat(7, 1fr);
64
- gap: 12px;
65
- margin-bottom: 16px;
66
- }
67
-
68
- .stat-card {
69
- background: #1a1a24;
70
- border-radius: 8px;
71
- padding: 16px;
72
- text-align: center;
73
- }
74
-
75
- .stat-card .label {
76
- font-size: 12px;
77
- color: #888;
78
- margin-bottom: 8px;
79
- }
80
-
81
- .stat-card .value {
82
- font-size: 28px;
83
- font-weight: 700;
84
- }
85
-
86
- .stat-card .value.total {
87
- color: #60a5fa;
88
- }
89
-
90
- .stat-card .value.done {
91
- color: #4ade80;
92
- }
93
-
94
- .stat-card .value.pending {
95
- color: #facc15;
96
- }
97
-
98
- .stat-card .value.error {
99
- color: #f87171;
100
- }
101
-
102
- .stat-card .value.target {
103
- color: #a78bfa;
104
- }
105
-
106
- .stat-card .value-sub {
107
- font-size: 12px;
108
- font-weight: 400;
109
- margin-top: 2px;
110
- }
111
-
112
- .stat-card.clickable {
113
- cursor: pointer;
114
- }
115
-
116
- .stat-card.clickable:hover {
117
- background: #25253a;
118
- }
119
-
120
- .stat-card.clickable.pending-card:hover {
121
- background: rgba(250, 204, 21, 0.15);
122
- }
123
-
124
- .stat-card.clickable.pending-card:hover,
125
- .stat-card.clickable#statUserUpdateCard:hover {
126
- background: rgba(167, 139, 250, 0.15);
127
- }
128
-
129
- .charts {
130
- display: grid;
131
- grid-template-columns: 1fr 1fr;
132
- gap: 12px;
133
- margin-bottom: 16px;
134
- }
135
-
136
- .chart-box {
137
- background: #1a1a24;
138
- border-radius: 8px;
139
- padding: 16px;
140
- }
141
-
142
- .chart-box h3 {
143
- font-size: 14px;
144
- color: #888;
145
- margin-bottom: 12px;
146
- }
147
-
148
- .bar-row {
149
- display: flex;
150
- align-items: center;
151
- margin-bottom: 8px;
152
- font-size: 13px;
153
- }
154
-
155
- .bar-row .name {
156
- width: 50px;
157
- color: #ccc;
158
- flex-shrink: 0;
159
- }
160
-
161
- .bar-row .bar-bg {
162
- flex: 1;
163
- background: #2a2a3a;
164
- border-radius: 4px;
165
- height: 20px;
166
- overflow: hidden;
167
- margin: 0 8px;
168
- }
169
-
170
- .bar-row .bar-fill {
171
- height: 100%;
172
- border-radius: 4px;
173
- transition: width 0.3s;
174
- display: flex;
175
- align-items: center;
176
- padding-left: 6px;
177
- font-size: 11px;
178
- color: #fff;
179
- white-space: nowrap;
180
- }
181
-
182
- .bar-row .target-badge {
183
- width: 60px;
184
- text-align: center;
185
- color: #4ade80;
186
- font-weight: 600;
187
- font-size: 12px;
188
- flex-shrink: 0;
189
- margin: 0 6px;
190
- }
191
-
192
- .bar-row .count {
193
- width: 55px;
194
- text-align: right;
195
- color: #888;
196
- flex-shrink: 0;
197
- }
198
-
199
- .bar-row.is-target {
200
- background: rgba(167, 139, 250, 0.08);
201
- border-radius: 4px;
202
- padding: 2px 0;
203
- }
204
-
205
- .bar-row.is-target .name {
206
- color: #a78bfa;
207
- font-weight: 600;
208
- }
209
-
210
- .source-row {
211
- display: flex;
212
- align-items: center;
213
- margin-bottom: 6px;
214
- font-size: 13px;
215
- }
216
-
217
- .source-row .s-name {
218
- width: 80px;
219
- color: #ccc;
220
- flex-shrink: 0;
221
- }
222
-
223
- .source-row .s-val {
224
- color: #888;
225
- }
226
-
227
- .table-wrap {
228
- background: #1a1a24;
229
- border-radius: 8px;
230
- padding: 16px;
231
- }
232
-
233
- .table-wrap h3 {
234
- font-size: 14px;
235
- color: #888;
236
- margin-bottom: 12px;
237
- }
238
-
239
- .controls {
240
- display: flex;
241
- gap: 8px;
242
- margin-bottom: 12px;
243
- flex-wrap: wrap;
244
- }
245
-
246
- .controls input {
247
- flex: 1;
248
- min-width: 150px;
249
- padding: 6px 12px;
250
- border: 1px solid #333;
251
- border-radius: 6px;
252
- background: #0f0f13;
253
- color: #e0e0e0;
254
- font-size: 13px;
255
- outline: none;
256
- }
257
-
258
- .controls input:focus {
259
- border-color: #fe2c55;
260
- }
261
-
262
- .controls button {
263
- padding: 6px 14px;
264
- border: 1px solid #333;
265
- border-radius: 6px;
266
- background: #2a2a3a;
267
- color: #ccc;
268
- font-size: 12px;
269
- cursor: pointer;
270
- transition: all 0.2s;
271
- }
272
-
273
- .controls button:hover {
274
- border-color: #fe2c55;
275
- color: #fff;
276
- }
277
-
278
- .controls button.active {
279
- background: #fe2c55;
280
- color: #fff;
281
- border-color: #fe2c55;
282
- }
283
-
284
- .add-users {
285
- display: flex;
286
- gap: 8px;
287
- margin-bottom: 12px;
288
- align-items: center;
289
- }
290
-
291
- .add-users button {
292
- padding: 6px 16px;
293
- border: none;
294
- border-radius: 6px;
295
- background: #fe2c55;
296
- color: #fff;
297
- font-size: 13px;
298
- cursor: pointer;
299
- font-weight: 600;
300
- transition: all 0.2s;
301
- }
302
-
303
- .add-users button:hover {
304
- background: #e61944;
305
- }
306
-
307
- .modal-overlay {
308
- position: fixed;
309
- inset: 0;
310
- background: rgba(0, 0, 0, 0.65);
311
- z-index: 1000;
312
- display: flex;
313
- align-items: center;
314
- justify-content: center;
315
- }
316
-
317
- .modal {
318
- background: #1a1a24;
319
- border-radius: 12px;
320
- padding: 24px;
321
- width: 520px;
322
- max-width: 90vw;
323
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
324
- }
325
-
326
- .modal h3 {
327
- font-size: 16px;
328
- color: #e0e0e0;
329
- margin-bottom: 6px;
330
- }
331
-
332
- .modal .hint {
333
- font-size: 12px;
334
- color: #888;
335
- margin-bottom: 16px;
336
- }
337
-
338
- .modal textarea {
339
- width: 100%;
340
- height: 180px;
341
- padding: 10px 14px;
342
- border: 1px solid #333;
343
- border-radius: 8px;
344
- background: #0f0f13;
345
- color: #e0e0e0;
346
- font-size: 13px;
347
- font-family: inherit;
348
- outline: none;
349
- resize: vertical;
350
- line-height: 1.6;
351
- }
352
-
353
- .modal textarea:focus {
354
- border-color: #fe2c55;
355
- }
356
-
357
- .modal textarea::placeholder {
358
- color: #555;
359
- }
360
-
361
- .modal .preview {
362
- margin-top: 8px;
363
- font-size: 12px;
364
- color: #60a5fa;
365
- min-height: 20px;
366
- }
367
-
368
- .modal .btn-row {
369
- display: flex;
370
- gap: 8px;
371
- margin-top: 16px;
372
- justify-content: flex-end;
373
- }
374
-
375
- .modal .btn-row button {
376
- padding: 8px 20px;
377
- border: none;
378
- border-radius: 6px;
379
- font-size: 13px;
380
- cursor: pointer;
381
- font-weight: 600;
382
- transition: all 0.2s;
383
- }
384
-
385
- .modal .btn-cancel {
386
- background: #2a2a3a;
387
- color: #ccc;
388
- }
389
-
390
- .modal .btn-cancel:hover {
391
- background: #333;
392
- }
393
-
394
- .modal .btn-submit {
395
- background: #fe2c55;
396
- color: #fff;
397
- }
398
-
399
- .modal .btn-submit:hover {
400
- background: #e61944;
401
- }
402
-
403
- .toast {
404
- position: fixed;
405
- top: 16px;
406
- right: 16px;
407
- padding: 10px 20px;
408
- border-radius: 6px;
409
- font-size: 13px;
410
- z-index: 999;
411
- transition: opacity 0.3s;
412
- }
413
-
414
- .toast.success {
415
- background: #166534;
416
- color: #fff;
417
- }
418
-
419
- .toast.error {
420
- background: #991b1b;
421
- color: #fff;
422
- }
423
-
424
- @keyframes flashChange {
425
- 0% {
426
- filter: brightness(1.8);
427
- box-shadow: 0 0 12px rgba(254, 44, 85, 0.6);
428
- }
429
-
430
- 100% {
431
- filter: brightness(1);
432
- box-shadow: none;
433
- }
434
- }
435
-
436
- .flash-change {
437
- animation: flashChange 0.6s ease-out;
438
- }
439
-
440
- @keyframes rowFlash {
441
- 0% {
442
- background: rgba(254, 44, 85, 0.25);
443
- }
444
-
445
- 100% {
446
- background: transparent;
447
- }
448
- }
449
-
450
- tr.row-flash {
451
- animation: rowFlash 0.8s ease-out;
452
- }
453
-
454
- @keyframes barFlash {
455
- 0% {
456
- filter: brightness(1.6);
457
- }
458
-
459
- 100% {
460
- filter: brightness(1);
461
- }
462
- }
463
-
464
- .bar-fill.bar-flash {
465
- animation: barFlash 0.5s ease-out;
466
- }
467
-
468
- .table-scroll {
469
- max-height: 500px;
470
- overflow-y: auto;
471
- }
472
-
473
- table {
474
- width: 100%;
475
- border-collapse: collapse;
476
- font-size: 12px;
477
- }
478
-
479
- th {
480
- position: sticky;
481
- top: 0;
482
- background: #22222e;
483
- padding: 8px 10px;
484
- text-align: left;
485
- color: #888;
486
- font-weight: 600;
487
- border-bottom: 1px solid #333;
488
- white-space: nowrap;
489
- }
490
-
491
- td {
492
- padding: 6px 10px;
493
- border-bottom: 1px solid #1f1f2a;
494
- white-space: nowrap;
495
- }
496
-
497
- tr:hover {
498
- background: #1f1f2a;
499
- }
500
-
501
- td.user-id {
502
- cursor: pointer;
503
- color: #60a5fa;
504
- }
505
-
506
- td.user-id:hover {
507
- color: #fe2c55;
508
- }
509
-
510
- .tag {
511
- display: inline-block;
512
- padding: 1px 6px;
513
- border-radius: 3px;
514
- font-size: 10px;
515
- }
516
-
517
- .tag.seller {
518
- background: #dc2626;
519
- color: #fff;
520
- }
521
-
522
- .tag.verified {
523
- background: #2563eb;
524
- color: #fff;
525
- }
526
-
527
- .tag.pending {
528
- background: #ca8a04;
529
- color: #000;
530
- }
531
-
532
- .tag.processing {
533
- background: #0ea5e9;
534
- color: #fff;
535
- }
536
-
537
- .tag.error {
538
- background: #991b1b;
539
- color: #fff;
540
- }
541
-
542
- .tag.unknown-country {
543
- background: #4b5563;
544
- color: #fff;
545
- }
546
-
547
- #pendingPage {
548
- display: none;
549
- }
550
-
551
- #pendingPage.active {
552
- display: block;
553
- }
554
-
555
- #userUpdatePage {
556
- display: none;
557
- }
558
-
559
- #userUpdatePage.active {
560
- display: block;
561
- }
562
-
563
- #rawPage {
564
- display: none;
565
- }
566
-
567
- #rawPage.active {
568
- display: block;
569
- }
570
-
571
- #mainPage.hidden {
572
- display: none;
573
- }
574
-
575
- .pending-country-card {
576
- background: #1a1a24;
577
- border-radius: 8px;
578
- padding: 20px;
579
- margin-bottom: 16px;
580
- }
581
-
582
- .pending-country-card h3 {
583
- font-size: 16px;
584
- color: #facc15;
585
- margin-bottom: 16px;
586
- display: flex;
587
- align-items: center;
588
- gap: 8px;
589
- }
590
-
591
- .pending-country-grid {
592
- display: grid;
593
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
594
- gap: 12px;
595
- }
596
-
597
- .pending-country-item {
598
- background: #2a2a3a;
599
- border-radius: 8px;
600
- padding: 16px;
601
- cursor: pointer;
602
- transition: all 0.2s;
603
- border: 1px solid transparent;
604
- position: relative;
605
- }
606
-
607
- .country-action-btn {
608
- position: absolute;
609
- top: 10px;
610
- right: 10px;
611
- width: 28px;
612
- height: 28px;
613
- border: 1px solid rgba(248, 113, 113, 0.35);
614
- border-radius: 999px;
615
- background: rgba(127, 29, 29, 0.45);
616
- color: #fca5a5;
617
- display: inline-flex;
618
- align-items: center;
619
- justify-content: center;
620
- font-size: 14px;
621
- cursor: pointer;
622
- transition: all 0.2s;
623
- }
624
-
625
- .country-action-btn:hover {
626
- background: rgba(153, 27, 27, 0.85);
627
- color: #fff;
628
- border-color: rgba(248, 113, 113, 0.8);
629
- }
630
-
631
- .country-action-btn.restore {
632
- border-color: rgba(74, 222, 128, 0.35);
633
- background: rgba(20, 83, 45, 0.45);
634
- color: #86efac;
635
- }
636
-
637
- .country-action-btn.restore:hover {
638
- background: rgba(22, 101, 52, 0.9);
639
- border-color: rgba(74, 222, 128, 0.8);
640
- color: #fff;
641
- }
642
-
643
- .raw-page-layout {
644
- display: grid;
645
- grid-template-columns: 320px 1fr;
646
- gap: 16px;
647
- }
648
-
649
- .raw-side-card {
650
- background: #1a1a24;
651
- border-radius: 8px;
652
- padding: 16px;
653
- }
654
-
655
- .raw-side-card h3 {
656
- font-size: 14px;
657
- color: #f59e0b;
658
- margin-bottom: 12px;
659
- }
660
-
661
- .raw-table-card {
662
- background: #1a1a24;
663
- border-radius: 8px;
664
- padding: 16px;
665
- }
666
-
667
- .raw-table-card h3 {
668
- font-size: 14px;
669
- color: #888;
670
- margin-bottom: 12px;
671
- }
672
-
673
- .muted-tip {
674
- font-size: 12px;
675
- color: #777;
676
- margin-top: 8px;
677
- line-height: 1.5;
678
- }
679
-
680
- .pending-country-item:hover {
681
- border-color: #facc15;
682
- background: #33334a;
683
- }
684
-
685
- .pending-country-item .country-name {
686
- font-size: 18px;
687
- font-weight: 700;
688
- color: #facc15;
689
- margin-bottom: 4px;
690
- }
691
-
692
- .pending-country-item .country-count {
693
- font-size: 28px;
694
- font-weight: 700;
695
- color: #fff;
696
- }
697
-
698
- .pending-country-item .country-label {
699
- font-size: 12px;
700
- color: #888;
701
- margin-top: 2px;
702
- }
703
-
704
- .pending-country-item.has-target {
705
- border-color: #a78bfa;
706
- }
707
-
708
- .pending-country-item.has-target .country-name {
709
- color: #a78bfa;
710
- }
711
-
712
- .back-btn {
713
- padding: 6px 14px;
714
- border: 1px solid #333;
715
- border-radius: 6px;
716
- background: #2a2a3a;
717
- color: #ccc;
718
- font-size: 13px;
719
- cursor: pointer;
720
- transition: all 0.2s;
721
- margin-bottom: 16px;
722
- }
723
-
724
- .back-btn:hover {
725
- border-color: #fe2c55;
726
- color: #fff;
727
- }
728
-
729
- .tag.processed {
730
- background: #166534;
731
- color: #fff;
732
- }
733
-
734
- .tag.no-video {
735
- background: #7c3aed;
736
- color: #fff;
737
- }
738
-
739
- .tag.no-follow {
740
- background: #b45309;
741
- color: #fff;
742
- }
743
-
744
- .tag.keep-follow {
745
- background: #059669;
746
- color: #fff;
747
- }
748
-
749
- .tag.pinned {
750
- background: #f59e0b;
751
- color: #000;
752
- }
753
-
754
- .context-menu {
755
- position: fixed;
756
- background: #1e1e2e;
757
- border: 1px solid #333;
758
- border-radius: 6px;
759
- padding: 4px 0;
760
- min-width: 140px;
761
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
762
- z-index: 1000;
763
- }
764
-
765
- .context-menu-item {
766
- padding: 8px 16px;
767
- font-size: 13px;
768
- color: #ccc;
769
- cursor: pointer;
770
- display: flex;
771
- align-items: center;
772
- gap: 8px;
773
- }
774
-
775
- .context-menu-item:hover {
776
- background: #fe2c55;
777
- color: #fff;
778
- }
779
-
780
- .context-menu-item.danger {
781
- color: #f87171;
782
- }
783
-
784
- .context-menu-item.danger:hover {
785
- background: #991b1b;
786
- color: #fff;
787
- }
788
-
789
- ::-webkit-scrollbar {
790
- width: 6px;
791
- }
792
-
793
- ::-webkit-scrollbar-track {
794
- background: #1a1a24;
795
- }
796
-
797
- ::-webkit-scrollbar-thumb {
798
- background: #333;
799
- border-radius: 3px;
800
- }
801
-
802
- .client-errors-section {
803
- margin-bottom: 16px;
804
- }
805
-
806
- .section-header {
807
- display: flex;
808
- align-items: center;
809
- gap: 8px;
810
- margin-bottom: 8px;
811
- }
812
-
813
- .section-header h3 {
814
- font-size: 14px;
815
- color: #e0e0e0;
816
- }
817
-
818
- .error-badge {
819
- background: #991b1b;
820
- color: #fff;
821
- font-size: 11px;
822
- padding: 2px 8px;
823
- border-radius: 10px;
824
- font-weight: 600;
825
- }
826
-
827
- .client-errors-table {
828
- width: 100%;
829
- border-collapse: collapse;
830
- background: #1a1a24;
831
- border-radius: 8px;
832
- overflow: hidden;
833
- font-size: 13px;
834
- }
835
-
836
- .client-errors-table thead tr {
837
- background: #22222e;
838
- }
839
-
840
- .client-errors-table th {
841
- padding: 10px 12px;
842
- text-align: left;
843
- color: #888;
844
- font-weight: 600;
845
- font-size: 12px;
846
- border-bottom: 1px solid #2a2a3a;
847
- }
848
-
849
- .client-errors-table td {
850
- padding: 8px 12px;
851
- border-bottom: 1px solid #1f1f2e;
852
- }
853
-
854
- .client-errors-table tbody tr:last-child td {
855
- border-bottom: none;
856
- }
857
-
858
- .client-errors-table tbody tr:hover {
859
- background: #22222e;
860
- }
861
-
862
- .error-type-captcha {
863
- color: #f59e0b;
864
- }
865
-
866
- .error-type-network {
867
- color: #f87171;
868
- }
869
-
870
- .error-type-other {
871
- color: #a78bfa;
872
- }
873
-
874
- @media (max-width: 768px) {
875
- body {
876
- padding: 8px;
877
- }
878
-
879
- .header {
880
- flex-direction: column;
881
- gap: 6px;
882
- align-items: flex-start;
883
- }
884
-
885
- .header h1 {
886
- font-size: 16px;
887
- }
888
-
889
- .stats {
890
- grid-template-columns: repeat(2, 1fr);
891
- gap: 8px;
892
- }
893
-
894
- .stat-card {
895
- padding: 10px;
896
- }
897
-
898
- .stat-card .label {
899
- font-size: 11px;
900
- }
901
-
902
- .stat-card .value {
903
- font-size: 18px;
904
- }
905
-
906
- .charts {
907
- grid-template-columns: 1fr;
908
- }
909
-
910
- .raw-page-layout {
911
- grid-template-columns: 1fr;
912
- }
913
-
914
- .table-wrap {
915
- padding: 10px;
916
- }
917
-
918
- .controls {
919
- flex-wrap: wrap;
920
- gap: 6px;
921
- }
922
-
923
- .controls input {
924
- flex: 0 0 100%;
925
- width: 100%;
926
- }
927
-
928
- .controls button {
929
- flex: 0 0 calc(33.33% - 4px);
930
- min-width: 0;
931
- text-align: center;
932
- white-space: nowrap;
933
- font-size: 11px;
934
- padding: 8px 4px;
935
- }
936
-
937
- #batchResetBtn {
938
- flex: 0 0 100% !important;
939
- font-size: 12px !important;
940
- padding: 8px 12px !important;
941
- }
942
-
943
- .controls select {
944
- flex: 0 0 100%;
945
- width: 100%;
946
- }
947
-
948
- .table-scroll {
949
- max-height: none;
950
- overflow: visible;
951
- }
952
-
953
- table,
954
- thead,
955
- tbody,
956
- th,
957
- td,
958
- tr {
959
- display: block;
960
- }
961
-
962
- thead {
963
- display: none;
964
- }
965
-
966
- tr {
967
- background: #22222e;
968
- border-radius: 8px;
969
- padding: 10px 12px;
970
- margin-bottom: 8px;
971
- border: 1px solid #2a2a3a;
972
- }
973
-
974
- tr:hover {
975
- background: #2a2a3a;
976
- }
977
-
978
- td {
979
- padding: 4px 0;
980
- border: none;
981
- text-align: left;
982
- position: relative;
983
- padding-left: 40%;
984
- font-size: 13px;
985
- }
986
-
987
- td::before {
988
- content: attr(data-label);
989
- position: absolute;
990
- left: 0;
991
- width: 36%;
992
- text-align: right;
993
- color: #888;
994
- font-size: 12px;
995
- font-weight: 600;
996
- white-space: nowrap;
997
- }
998
-
999
- td.user-id {
1000
- font-size: 15px;
1001
- font-weight: 700;
1002
- color: #60a5fa;
1003
- padding-left: 0;
1004
- border-bottom: 1px solid #2a2a3a;
1005
- margin-bottom: 4px;
1006
- padding-bottom: 6px;
1007
- }
1008
-
1009
- td.user-id::before {
1010
- display: none;
1011
- }
1012
-
1013
- td.user-id:hover {
1014
- color: #fe2c55;
1015
- }
1016
-
1017
- .add-users {
1018
- justify-content: center;
1019
- }
1020
-
1021
- .modal {
1022
- width: 95vw;
1023
- padding: 16px;
1024
- }
1025
-
1026
- .modal textarea {
1027
- height: 140px;
1028
- }
1029
- }
1030
- </style>
1031
- </head>
1032
-
1033
- <body>
1034
- <div class="header">
1035
- <h1>TikTok 采集监控</h1>
1036
- <div class="meta" id="fileMeta">加载中...</div>
1037
- <div style="display:flex;gap:8px;align-items:center">
1038
- <a href="/scripts/run-explore.sh" class="script-link" download="run-explore.sh">mac</a>
1039
- <a href="/scripts/run-explore.bat" class="script-link" download="run-explore.bat">windows</a>
1040
- <span class="status" id="lastUpdate">--</span>
1041
- </div>
1042
- </div>
1043
- <div id="mainPage">
1044
- <div class="stats">
1045
- <div class="stat-card">
1046
- <div class="label">总用户</div>
1047
- <div class="value total" id="statTotal">0</div>
1048
- </div>
1049
- <div class="stat-card">
1050
- <div class="label">处理中</div>
1051
- <div class="value total" id="statProcessing">0</div>
1052
- </div>
1053
- <div class="stat-card">
1054
- <div class="label">已完成</div>
1055
- <div class="value done" id="statDone">0</div>
1056
- </div>
1057
- <div class="stat-card clickable pending-card" id="statPendingCard" onclick="navigateToPending()">
1058
- <div class="label">待处理</div>
1059
- <div class="value pending" id="statPending">0</div>
1060
- </div>
1061
- <div class="stat-card">
1062
- <div class="label">错误</div>
1063
- <div class="value error" id="statError">0</div>
1064
- </div>
1065
- <div class="stat-card">
1066
- <div class="label">受限</div>
1067
- <div class="value error" id="statRestricted">0</div>
1068
- </div>
1069
- <div class="stat-card clickable pending-card" id="statUserUpdateCard" onclick="navigateToUserUpdate()">
1070
- <div class="label">待补资料</div>
1071
- <div class="value target" id="statUserUpdateTasks">0</div>
1072
- </div>
1073
- <div class="stat-card clickable" id="statRawCard" onclick="navigateToRaw()">
1074
- <div class="label">毛料库</div>
1075
- <div class="value target" id="statRawJobs">0</div>
1076
- </div>
1077
- <div class="stat-card clickable" id="statTargetCard">
1078
- <div class="label">目标商家</div>
1079
- <div class="value target" id="statTarget">0</div>
1080
- </div>
1081
- </div>
1082
- <div class="client-errors-section" id="clientErrorsSection" style="display:none">
1083
- <div class="section-header">
1084
- <h3>客户端异常</h3>
1085
- <span class="error-badge" id="clientErrorsBadge">0</span>
1086
- </div>
1087
- <table class="client-errors-table">
1088
- <thead>
1089
- <tr>
1090
- <th>客户端</th>
1091
- <th>错误类型</th>
1092
- <th>错误次数</th>
1093
- <th>验证码次数</th>
1094
- <th>阶段</th>
1095
- <th>错误详情</th>
1096
- <th>当时处理的 TikTok 用户</th>
1097
- <th>时间</th>
1098
- <th>操作</th>
1099
- </tr>
1100
- </thead>
1101
- <tbody id="clientErrorsBody"></tbody>
1102
- </table>
1103
- </div>
1104
- <div class="charts">
1105
- <div class="chart-box">
1106
- <h3>国家统计</h3>
1107
- <div id="countryChart"></div>
1108
- </div>
1109
- <div class="chart-box">
1110
- <h3>来源分布</h3>
1111
- <div id="sourceChart"></div>
1112
- </div>
1113
- </div>
1114
- <div class="table-wrap">
1115
- <h3>用户列表</h3>
1116
- <div class="add-users">
1117
- <button onclick="openAddModal()">+ 插入队列</button>
1118
- </div>
1119
- <div id="toast" class="toast" style="display:none"></div>
1120
- <div class="controls">
1121
- <input type="text" id="searchInput" placeholder="搜索用户名 / 昵称...">
1122
- <button data-filter="all" class="active" onclick="setFilter('all')">全部</button>
1123
- <button data-filter="processing" onclick="setFilter('processing')">处理中</button>
1124
- <button data-filter="pending" onclick="setFilter('pending')">待处理</button>
1125
- <button data-filter="done" onclick="setFilter('done')">已完成</button>
1126
- <button data-filter="error" onclick="setFilter('error')">错误</button>
1127
- <button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
1128
- <button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标商家</button>
1129
- <select id="targetLocationFilter" onchange="onTargetLocationChange()"
1130
- style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;display:none">
1131
- <option value="">全部目标国家</option>
1132
- </select>
1133
- <button id="batchResetBtn" onclick="batchResetErrors()"
1134
- style="display:none;padding:6px 10px;border:1px solid #f87171;border-radius:6px;background:transparent;color:#f87171;font-size:12px;cursor:pointer;font-weight:600;transition:all 0.2s;white-space:nowrap;">&#x21bb;
1135
- 批量重新处理 (<span id="batchResetCount">0</span>)</button>
1136
- <select id="locationFilter" onchange="onLocationChange()"
1137
- style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
1138
- <option value="">全部国家</option>
1139
- </select>
1140
- </div>
1141
- <div class="table-scroll">
1142
- <table>
1143
- <thead>
1144
- <tr>
1145
- <th>用户名</th>
1146
- <th>昵称</th>
1147
- <th>粉丝</th>
1148
- <th>视频</th>
1149
- <th>国家</th>
1150
- <th>猜测国家</th>
1151
- <th>来源</th>
1152
- <th>状态</th>
1153
- <th>处理端</th>
1154
- <th>领取时间</th>
1155
- <th>完成时间</th>
1156
- </tr>
1157
- </thead>
1158
- <tbody id="userTable"></tbody>
1159
- </table>
1160
- </div>
1161
- </div>
1162
- </div>
1163
- <div id="pendingPage">
1164
- <div class="stats" style="margin-bottom:16px">
1165
- <div class="stat-card">
1166
- <div class="label">总用户</div>
1167
- <div class="value total" id="pendingStatTotal">0</div>
1168
- </div>
1169
- <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(96,165,250,0.1)">
1170
- <div class="label">← 返回主页面</div>
1171
- </div>
1172
- <div class="stat-card">
1173
- <div class="label">待处理</div>
1174
- <div class="value pending" id="pendingStatPending">0</div>
1175
- </div>
1176
- <div class="stat-card clickable pending-card" onclick="navigateToUserUpdate()">
1177
- <div class="label">待补资料</div>
1178
- <div class="value target" id="pendingStatUserUpdateTasks">0</div>
1179
- </div>
1180
- <div class="stat-card clickable" onclick="navigateToRaw()">
1181
- <div class="label">毛料库</div>
1182
- <div class="value target" id="pendingStatRawJobs">0</div>
1183
- </div>
1184
- </div>
1185
- <div class="pending-country-card">
1186
- <h3>📊 待处理任务按国家分布</h3>
1187
- <div class="pending-country-grid" id="pendingCountryGrid">
1188
- <span style="color:#666;font-size:12px">加载中...</span>
1189
- </div>
1190
- </div>
1191
- <div class="pending-country-card">
1192
- <h3>🔁 Attach 未成功(状态 1)</h3>
1193
- <div class="pending-country-grid" id="pendingAttachStuckGrid">
1194
- <span style="color:#666;font-size:12px">加载中...</span>
1195
- </div>
1196
- </div>
1197
- </div>
1198
- <div id="userUpdatePage">
1199
- <div class="stats" style="margin-bottom:16px">
1200
- <div class="stat-card">
1201
- <div class="label">总用户</div>
1202
- <div class="value total" id="userUpdateStatTotal">0</div>
1203
- </div>
1204
- <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(167,139,250,0.1)">
1205
- <div class="label">← 返回主页面</div>
1206
- </div>
1207
- <div class="stat-card clickable pending-card" onclick="navigateToPending()">
1208
- <div class="label">待处理</div>
1209
- <div class="value pending" id="userUpdateStatPending">0</div>
1210
- </div>
1211
- <div class="stat-card">
1212
- <div class="label">待补资料</div>
1213
- <div class="value target" id="userUpdateStatUserUpdateTasks">0</div>
1214
- </div>
1215
- <div class="stat-card clickable" onclick="navigateToRaw()">
1216
- <div class="label">毛料库</div>
1217
- <div class="value target" id="userUpdateStatRawJobs">0</div>
1218
- </div>
1219
- </div>
1220
- <div class="pending-country-card">
1221
- <h3>📝 待补资料任务按国家分布</h3>
1222
- <div class="pending-country-grid" id="userUpdateCountryGrid">
1223
- <span style="color:#666;font-size:12px">加载中...</span>
1224
- </div>
1225
- </div>
1226
- <div class="pending-country-card">
1227
- <h3>🔁 Attach 未成功(状态 1)</h3>
1228
- <div class="pending-country-grid" id="userUpdateAttachStuckGrid">
1229
- <span style="color:#666;font-size:12px">加载中...</span>
1230
- </div>
1231
- </div>
1232
- </div>
1233
- <div id="rawPage">
1234
- <div class="stats" style="margin-bottom:16px">
1235
- <div class="stat-card">
1236
- <div class="label">毛料库</div>
1237
- <div class="value target" id="rawPageStatRawJobs">0</div>
1238
- </div>
1239
- <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(245,158,11,0.12)">
1240
- <div class="label">← 返回主页面</div>
1241
- </div>
1242
- <div class="stat-card clickable pending-card" onclick="navigateToPending()">
1243
- <div class="label">待处理</div>
1244
- <div class="value pending" id="rawPageStatPending">0</div>
1245
- </div>
1246
- <div class="stat-card clickable pending-card" onclick="navigateToUserUpdate()">
1247
- <div class="label">待补资料</div>
1248
- <div class="value target" id="rawPageStatUserUpdateTasks">0</div>
1249
- </div>
1250
- </div>
1251
- <div class="raw-page-layout">
1252
- <div class="raw-side-card">
1253
- <h3>🧺 毛料库国家分布</h3>
1254
- <div class="pending-country-grid" id="rawCountryGrid">
1255
- <span style="color:#666;font-size:12px">加载中...</span>
1256
- </div>
1257
- <div class="muted-tip">点击国家卡片可筛选列表;右上角恢复按钮会把该国家任务恢复回 jobs 队列。</div>
1258
- </div>
1259
- <div class="raw-table-card">
1260
- <h3>毛料库列表</h3>
1261
- <div class="controls">
1262
- <input type="text" id="rawSearchInput" placeholder="搜索毛料库用户名 / 昵称...">
1263
- <select id="rawLocationFilter" onchange="onRawLocationChange()"
1264
- style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
1265
- <option value="">全部国家</option>
1266
- </select>
1267
- <button onclick="clearRawFilters()">清空筛选</button>
1268
- <button onclick="restoreFilteredRawJobs()"
1269
- style="background:#22c55e;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;">恢复当前筛选到
1270
- jobs</button>
1271
- </div>
1272
- <div class="table-scroll">
1273
- <table>
1274
- <thead>
1275
- <tr>
1276
- <th>用户名</th>
1277
- <th>昵称</th>
1278
- <th>粉丝</th>
1279
- <th>视频</th>
1280
- <th>国家</th>
1281
- <th>猜测国家</th>
1282
- <th>来源</th>
1283
- <th>状态</th>
1284
- <th>创建时间</th>
1285
- <th>操作</th>
1286
- </tr>
1287
- </thead>
1288
- <tbody id="rawTable"></tbody>
1289
- </table>
1290
- </div>
1291
- </div>
1292
- </div>
1293
- </div>
1294
- <script>
1295
- const COLORS = ['#fe2c55', '#60a5fa', '#4ade80', '#facc15', '#f97316', '#a855f7', '#ec4899', '#14b8a6', '#e11d48', '#0ea5e9', '#8b5cf6', '#84cc16'];
1296
- let currentFilter = 'all';
1297
- let currentStats = null;
1298
- let currentUsers = [];
1299
- let currentLocation = '';
1300
- let currentTargetLocation = '';
1301
- let currentRawLocation = '';
1302
- let prevStatValues = {};
1303
- let prevUserMap = {};
1304
-
1305
- async function fetchStats() {
1306
- try {
1307
- const res = await fetch('/api/stats');
1308
- currentStats = await res.json();
1309
- renderStats();
1310
- renderLocationFilter();
1311
- } catch (e) {
1312
- document.getElementById('lastUpdate').textContent = '\u8fde\u63a5\u5931\u8d25';
1313
- }
1314
- }
1315
-
1316
- async function fetchUsers() {
1317
- try {
1318
- const params = new URLSearchParams();
1319
- if (currentFilter === 'target') {
1320
- params.set('target', '1');
1321
- if (currentTargetLocation) params.set('targetLocation', currentTargetLocation);
1322
- } else if (currentFilter !== 'all') {
1323
- params.set('status', currentFilter);
1324
- }
1325
- const search = document.getElementById('searchInput').value.trim();
1326
- if (search) params.set('search', search);
1327
- if (currentLocation) params.set('location', currentLocation);
1328
- params.set('limit', '200');
1329
- const res = await fetch('/api/users?' + params.toString());
1330
- const data = await res.json();
1331
- currentUsers = data.users || [];
1332
- renderTable(currentUsers);
1333
- } catch (e) { }
1334
- }
1335
-
1336
- function escapeHtml(str) {
1337
- const div = document.createElement('div');
1338
- div.textContent = str;
1339
- return div.innerHTML;
1340
- }
1341
-
1342
- async function fetchClientErrors() {
1343
- try {
1344
- const res = await fetch('/api/client-errors');
1345
- const data = await res.json();
1346
- const clients = data.clients || [];
1347
- const section = document.getElementById('clientErrorsSection');
1348
- const badge = document.getElementById('clientErrorsBadge');
1349
- const tbody = document.getElementById('clientErrorsBody');
1350
- if (clients.length === 0) {
1351
- section.style.display = 'none';
1352
- return;
1353
- }
1354
- section.style.display = '';
1355
- badge.textContent = clients.length;
1356
- const typeMap = { captcha: ['验证码', 'error-type-captcha'], network: ['网络', 'error-type-network'], other: ['其他', 'error-type-other'], '被封': ['被封', 'error-type-captcha'] };
1357
- const stageMap = { 'video-page': '视频页', 'comment': '评论', 'follow': '关注/粉丝', 'scrape': 'scrape', 'process': '处理' };
1358
- tbody.innerHTML = clients.map(c => {
1359
- const [typeText, typeClass] = typeMap[c.errorType] || ['未知', ''];
1360
- const stageText = c.stage ? (stageMap[c.stage] || c.stage) : '';
1361
- const captchaText = c.captchaCount ? `${c.captchaCount}次${c.captchaStage ? ' (' + (stageMap[c.captchaStage] || c.captchaStage) + ')' : ''}` : '-';
1362
- const msgDetail = c.errorMessage ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.errorMessage)}</span>` : '';
1363
- const stackDetail = c.errorStack ? `<br><span style="color:#555;font-size:10px;max-width:250px;display:block;word-break:break-all">${escapeHtml(c.errorStack)}</span>` : '';
1364
- const captchaDetail = c.captchaMessage ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.captchaMessage)}</span>` : '';
1365
- return `<tr>
1366
- <td style="font-family:monospace;font-weight:600;color:#60a5fa">${escapeHtml(c.userId)}</td>
1367
- <td class="${typeClass}">${typeText}</td>
1368
- <td style="color:#f87171;font-weight:600">${c.reportCount || 1}</td>
1369
- <td style="color:#f59e0b;font-weight:600">${captchaText}${captchaDetail}</td>
1370
- <td style="color:#a78bfa;font-size:12px">${stageText}</td>
1371
- <td style="color:#ccc;font-size:12px;max-width:300px;word-break:break-all">${msgDetail}${stackDetail}</td>
1372
- <td style="color:#60a5fa">@${escapeHtml(c.username || '-')}</td>
1373
- <td style="color:#888;font-size:12px">${new Date(c.timestamp).toLocaleTimeString()}</td>
1374
- <td><button class="btn-delete" onclick="deleteClientError('${escapeHtml(c.userId)}')" style="background:#991b1b;color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px">删除</button></td>
1375
- </tr>`;
1376
- }).join('');
1377
- } catch (e) { }
1378
- }
1379
-
1380
- async function deleteClientError(userId) {
1381
- try {
1382
- await fetch(`/api/client-error/${encodeURIComponent(userId)}`, { method: 'DELETE' });
1383
- fetchClientErrors();
1384
- } catch (e) { }
1385
- }
1386
-
1387
- function formatStatNum(value) {
1388
- const num = Number(value) || 0;
1389
- if (Math.abs(num) < 1000) return String(num);
1390
- if (Math.abs(num) < 10000) return num.toLocaleString('zh-CN');
1391
- const wan = num / 10000;
1392
- return wan.toFixed(1).replace(/\.0+$/, '') + '万';
1393
- }
1394
-
1395
- function flashEl(id, value) {
1396
- const el = document.getElementById(id);
1397
- if (!el) return;
1398
- const prev = prevStatValues[id];
1399
- el.textContent = formatStatNum(value);
1400
- if (prev !== undefined && prev !== value) {
1401
- el.classList.remove('flash-change');
1402
- void el.offsetWidth;
1403
- el.classList.add('flash-change');
1404
- }
1405
- prevStatValues[id] = value;
1406
- }
1407
-
1408
- function renderStats() {
1409
- if (!currentStats) return;
1410
- const d = currentStats;
1411
- flashEl('statTotal', d.totalUsers);
1412
- flashEl('statProcessing', d.processingUsers || 0);
1413
- flashEl('statDone', d.processedUsers);
1414
- flashEl('statPending', d.pendingUsers);
1415
- flashEl('statError', d.errorUsers);
1416
- flashEl('statRestricted', d.restrictedUsers);
1417
- flashEl('statTarget', d.targetUsers);
1418
- flashEl('statUserUpdateTasks', d.userUpdateTasks || 0);
1419
- flashEl('statRawJobs', d.rawJobs || 0);
1420
- // 同步子页面 stats
1421
- const pendingTotal = document.getElementById('pendingStatTotal');
1422
- if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
1423
- const pendingCount = document.getElementById('pendingStatPending');
1424
- if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
1425
- const pendingUserUpdate = document.getElementById('pendingStatUserUpdateTasks');
1426
- if (pendingUserUpdate) pendingUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1427
- const pendingRawJobs = document.getElementById('pendingStatRawJobs');
1428
- if (pendingRawJobs) pendingRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1429
- const userUpdateTotal = document.getElementById('userUpdateStatTotal');
1430
- if (userUpdateTotal) userUpdateTotal.textContent = formatStatNum(d.totalUsers);
1431
- const userUpdatePending = document.getElementById('userUpdateStatPending');
1432
- if (userUpdatePending) userUpdatePending.textContent = formatStatNum(d.pendingUsers);
1433
- const userUpdateTasks = document.getElementById('userUpdateStatUserUpdateTasks');
1434
- if (userUpdateTasks) userUpdateTasks.textContent = formatStatNum(d.userUpdateTasks || 0);
1435
- const userUpdateRawJobs = document.getElementById('userUpdateStatRawJobs');
1436
- if (userUpdateRawJobs) userUpdateRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1437
- const rawPageRawJobs = document.getElementById('rawPageStatRawJobs');
1438
- if (rawPageRawJobs) rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1439
- const rawPagePending = document.getElementById('rawPageStatPending');
1440
- if (rawPagePending) rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
1441
- const rawPageUserUpdate = document.getElementById('rawPageStatUserUpdateTasks');
1442
- if (rawPageUserUpdate) rawPageUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1443
- document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
1444
- document.getElementById('fileMeta').textContent =
1445
- formatStatNum(d.processingUsers || 0) +
1446
- ' \u5904\u7406\u4e2d, ' +
1447
- formatStatNum(d.totalUsers) +
1448
- ' \u4e2a\u7528\u6237, \u5f85\u5904\u7406 ' +
1449
- formatStatNum(d.pendingUsers || 0);
1450
-
1451
- renderCountryChart(d.countryStats);
1452
- renderSourceChart(d.sourceStats);
1453
- renderTargetLocationFilter(d.targetCountryStats);
1454
- }
1455
-
1456
- function renderTargetLocationFilter(targetCountryStats) {
1457
- const sel = document.getElementById('targetLocationFilter');
1458
- if (!sel) return;
1459
- const val = sel.value;
1460
- sel.innerHTML = '<option value="">全部目标国家</option>' +
1461
- (targetCountryStats || []).map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
1462
- }
1463
-
1464
- function renderCountryChart(countries) {
1465
- const el = document.getElementById('countryChart');
1466
- const filtered = countries.filter(c => c.country !== '\u672a\u77e5');
1467
- if (!filtered.length) { el.innerHTML = '<span style="color:#666;font-size:12px">\u6682\u65e0\u6570\u636e</span>'; return; }
1468
- const max = filtered[0].count;
1469
- const top = filtered.slice(0, 15);
1470
- const targetLocations = currentStats?.targetLocations || [];
1471
- el.innerHTML = top.map((c, i) => {
1472
- const isTarget = targetLocations.includes(c.country);
1473
- const targetBadge = c.targetCount > 0
1474
- ? `<span class="target-badge">🎯 ${c.targetCount}</span>`
1475
- : `<span class="target-badge" style="visibility:hidden"> </span>`;
1476
- return `
1477
- <div class="bar-row${isTarget ? ' is-target' : ''}">
1478
- <span class="name">${c.country}</span>
1479
- <div class="bar-bg"><div class="bar-fill" style="width:${(c.count / max * 100)}%;background:${isTarget ? '#a78bfa' : COLORS[i % COLORS.length]}">${c.count}</div></div>
1480
- ${targetBadge}
1481
- <span class="count">${(currentStats ? (c.count / currentStats.totalUsers * 100).toFixed(1) : 0)}%</span>
1482
- </div>
1483
- `;
1484
- }).join('');
1485
- }
1486
-
1487
- function renderSourceChart(sources) {
1488
- const el = document.getElementById('sourceChart');
1489
- const labels = { seed: '\u79cd\u5b50', video: '\u89c6\u9891\u53d1\u73b0', comment: '\u8bc4\u8bba\u53d1\u73b0', guess: '\u731c\u4f60\u559c\u6b22', following: '\u5173\u6ce8', follower: '\u7c89\u4e1d', processed: '\u5df2\u5904\u7406', restricted: '\u53d7\u9650(\u8df3\u8fc7)', error: '\u9519\u8bef(\u5f85\u91cd\u8bd5)', noVideo: '\u65e0\u89c6\u9891' };
1490
- const entries = Object.entries(sources);
1491
- el.innerHTML = entries.map(([key, val]) => `
1492
- <div class="source-row"><span class="s-name">${labels[key] || key}:</span><span class="s-val">${val}</span></div>
1493
- `).join('');
1494
- }
1495
-
1496
- function renderTable(users) {
1497
- const el = document.getElementById('userTable');
1498
-
1499
- const newUserMap = {};
1500
- for (const u of users) newUserMap[u.uniqueId] = u;
1501
-
1502
- el.innerHTML = users.map(u => {
1503
- const wasStatus = prevUserMap[u.uniqueId]?.status;
1504
- const nowStatus = u.status;
1505
- const changed = wasStatus !== nowStatus &&
1506
- (nowStatus === 'done' || nowStatus === 'restricted' || nowStatus === 'error');
1507
- const rowClass = changed ? ' class="row-flash"' : '';
1508
-
1509
- const statusTags = {
1510
- restricted: '<span class="tag error">\u53d7\u9650(\u8df3\u8fc7)</span>',
1511
- error: '<span class="tag error">\u9519\u8bef(\u5f85\u91cd\u8bd5)</span>',
1512
- done: '<span class="tag processed">\u5df2\u5b8c\u6210</span>',
1513
- processing: '<span class="tag processing">\u5904\u7406\u4e2d</span>',
1514
- pending: '<span class="tag pending">\u5f85\u5904\u7406</span>',
1515
- };
1516
- const statusTag = statusTags[u.status] || '<span class="tag pending">' + (u.status || '\u672a\u77e5') + '</span>';
1517
- const sources = (u.sources || []).join(', ');
1518
- const extraTags = [];
1519
- if (u.pinned) extraTags.push('<span class="tag pinned">&#x1F4CC; 置顶</span>');
1520
- if (u.ttSeller) extraTags.push('<span class="tag seller">\u5546\u5bb6</span>');
1521
- if (u.verified) extraTags.push('<span class="tag verified">\u8ba4\u8bc1</span>');
1522
- if (u.noVideo) extraTags.push('<span class="tag no-video">\u65e0\u89c6\u9891</span>');
1523
- if (u.keepFollow) extraTags.push('<span class="tag keep-follow">\u5173\u6ce8\u5df2\u4fdd\u7559</span>');
1524
- if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow">\u5173\u6ce8\u672a\u83b7\u53d6</span>');
1525
- const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1526
- const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
1527
- const videos = u.videoCount != null ? u.videoCount : '-';
1528
- const loc = u.locationCreated || '-';
1529
- const guessedLoc = u.guessedLocation || '-';
1530
- const claimer = u.claimedBy || '-';
1531
- const claimTime = u.claimedAt ? formatTime(u.claimedAt) : '-';
1532
- const procTime = u.processedAt ? formatTime(u.processedAt) : '-';
1533
- return `<tr${rowClass}>
1534
- <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
1535
- <td data-label="昵称">${nick}</td>
1536
- <td data-label="粉丝">${fans}</td>
1537
- <td data-label="视频">${videos}</td>
1538
- <td data-label="国家">${loc}</td>
1539
- <td data-label="猜测国家">${guessedLoc}</td>
1540
- <td data-label="来源">${sources || '-'}</td>
1541
- <td data-label="状态">${statusTag} ${extraTags.join(' ')}</td>
1542
- <td data-label="处理端" style="font-size:11px;color:#888">${claimer}</td>
1543
- <td data-label="领取时间" style="font-size:11px;color:#888">${claimTime}</td>
1544
- <td data-label="完成时间" style="font-size:11px;color:#888">${procTime}</td>
1545
- </tr>`;
1546
- }).join('');
1547
-
1548
- const errorCount = users.filter(u => u.status === 'error').length;
1549
- const countEl = document.getElementById('batchResetCount');
1550
- if (countEl) countEl.textContent = errorCount;
1551
-
1552
- prevUserMap = newUserMap;
1553
- }
1554
-
1555
- function formatNum(n) {
1556
- if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1557
- if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1558
- return n;
1559
- }
1560
-
1561
- function formatTime(ts) {
1562
- const d = new Date(ts);
1563
- const pad = n => String(n).padStart(2, '0');
1564
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1565
- }
1566
-
1567
- function setFilter(f) {
1568
- currentFilter = f;
1569
- document.querySelectorAll('.controls button').forEach(b => {
1570
- b.classList.toggle('active', b.dataset.filter === f);
1571
- });
1572
- const btn = document.getElementById('batchResetBtn');
1573
- btn.style.display = f === 'error' ? '' : 'none';
1574
- const targetLocSel = document.getElementById('targetLocationFilter');
1575
- if (f === 'target') {
1576
- targetLocSel.style.display = '';
1577
- } else {
1578
- targetLocSel.style.display = 'none';
1579
- currentTargetLocation = '';
1580
- }
1581
- fetchUsers();
1582
- }
1583
-
1584
- function renderLocationFilter() {
1585
- if (!currentStats || !currentStats.countryStats) return;
1586
- const sel = document.getElementById('locationFilter');
1587
- if (!sel) return;
1588
- const val = sel.value;
1589
- const entries = currentStats.countryStats.sort((a, b) => b.count - a.count);
1590
- sel.innerHTML = '<option value="">全部国家</option>' +
1591
- entries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
1592
- }
1593
-
1594
- function onLocationChange() {
1595
- const sel = document.getElementById('locationFilter');
1596
- currentLocation = sel.value;
1597
- fetchUsers();
1598
- }
1599
-
1600
- function onTargetLocationChange() {
1601
- const sel = document.getElementById('targetLocationFilter');
1602
- currentTargetLocation = sel.value;
1603
- fetchUsers();
1604
- }
1605
-
1606
- let searchTimer = null;
1607
- document.getElementById('searchInput').addEventListener('input', () => {
1608
- if (searchTimer) clearTimeout(searchTimer);
1609
- searchTimer = setTimeout(fetchUsers, 300);
1610
- });
1611
-
1612
- let rawSearchTimer = null;
1613
- document.getElementById('rawSearchInput').addEventListener('input', () => {
1614
- if (rawSearchTimer) clearTimeout(rawSearchTimer);
1615
- rawSearchTimer = setTimeout(fetchRawJobs, 300);
1616
- });
1617
-
1618
- function parseUsernames(raw) {
1619
- return raw.split(/[,,\n\r]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
1620
- }
1621
-
1622
- function openAddModal() {
1623
- let overlay = document.getElementById('addModalOverlay');
1624
- if (overlay) return;
1625
- overlay = document.createElement('div');
1626
- overlay.id = 'addModalOverlay';
1627
- overlay.className = 'modal-overlay';
1628
- overlay.innerHTML = `
1629
- <div class="modal">
1630
- <h3>插入用户到队列</h3>
1631
- <div class="hint">每行一个用户名,或用逗号分隔。支持 @username 或 username 格式。插入到队列最前面优先处理。</div>
1632
- <textarea id="modalUserInput" placeholder="例如:&#10;user1&#10;user2&#10;user3&#10;&#10;或:user1, user2, user3"></textarea>
1633
- <div class="preview" id="modalPreview"></div>
1634
- <div class="btn-row">
1635
- <button class="btn-cancel" onclick="closeAddModal()">取消</button>
1636
- <button class="btn-submit" onclick="submitAddUsers()">确认插入</button>
1637
- </div>
1638
- </div>
1639
- `;
1640
- document.body.appendChild(overlay);
1641
- overlay.addEventListener('click', e => { if (e.target === overlay) closeAddModal(); });
1642
- const ta = document.getElementById('modalUserInput');
1643
- ta.focus();
1644
- ta.addEventListener('input', () => {
1645
- const names = parseUsernames(ta.value);
1646
- const preview = document.getElementById('modalPreview');
1647
- preview.textContent = names.length ? `共 ${names.length} 个用户名` : '';
1648
- });
1649
- ta.addEventListener('keydown', e => {
1650
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1651
- e.preventDefault();
1652
- submitAddUsers();
1653
- }
1654
- });
1655
- }
1656
-
1657
- function closeAddModal() {
1658
- const overlay = document.getElementById('addModalOverlay');
1659
- if (overlay) overlay.remove();
1660
- }
1661
-
1662
- async function submitAddUsers() {
1663
- const ta = document.getElementById('modalUserInput');
1664
- const raw = ta.value.trim();
1665
- if (!raw) return;
1666
-
1667
- const names = parseUsernames(raw);
1668
- if (names.length === 0) return;
1669
-
1670
- try {
1671
- const res = await fetch('/api/users', {
1672
- method: 'POST',
1673
- headers: { 'Content-Type': 'application/json' },
1674
- body: JSON.stringify({ usernames: names })
1675
- });
1676
- const data = await res.json();
1677
- if (data.error) { showToast(data.error, true); return; }
1678
- closeAddModal();
1679
- showToast(data.message || `\u5df2\u63d2\u5165 ${data.added} \u4e2a\u7528\u6237`);
1680
- fetchStats();
1681
- fetchUsers();
1682
- } catch (e) {
1683
- showToast('\u63d2\u5165\u5931\u8d25: ' + e.message, true);
1684
- }
1685
- }
1686
-
1687
- function showToast(msg, isError) {
1688
- let toast = document.getElementById('globalToast') || document.getElementById('toast');
1689
- if (!toast) {
1690
- toast = document.createElement('div');
1691
- toast.id = 'globalToast';
1692
- toast.className = 'toast';
1693
- toast.style.display = 'none';
1694
- document.body.appendChild(toast);
1695
- }
1696
- toast.textContent = msg;
1697
- toast.className = 'toast' + (isError ? ' error' : '');
1698
- toast.style.display = 'block';
1699
- setTimeout(() => { toast.style.display = 'none'; }, 3000);
1700
- }
1701
-
1702
- function escapeJsString(str) {
1703
- return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1704
- }
1705
-
1706
- document.addEventListener('keydown', e => {
1707
- if (e.key === 'Escape') closeAddModal();
1708
- });
1709
-
1710
- document.getElementById('userTable').addEventListener('click', e => {
1711
- const td = e.target.closest('td.user-id');
1712
- if (!td) return;
1713
- hideContextMenu();
1714
- const username = td.textContent.trim().replace(/^@/, '');
1715
- if (!username) return;
1716
- window.open('https://www.tiktok.com/@' + username, '_blank');
1717
- });
1718
-
1719
- let contextMenuEl = null;
1720
- let contextMenuUserId = null;
1721
- let contextMenuPinned = false;
1722
-
1723
- function showContextMenu(x, y, uniqueId, pinned) {
1724
- hideContextMenu();
1725
- contextMenuUserId = uniqueId;
1726
- contextMenuPinned = !!pinned;
1727
- contextMenuEl = document.createElement('div');
1728
- contextMenuEl.className = 'context-menu';
1729
- contextMenuEl.innerHTML = `
1730
- <div class="context-menu-item" data-action="pin">${contextMenuPinned ? '&#x1F4CC; 取消置顶' : '&#x1F4CD; 置顶优先'}</div>
1731
- <div class="context-menu-item" data-action="reset">&#x21bb; 重新处理</div>
1732
- <div class="context-menu-item" data-action="open">&#x1F517; 打开主页</div>
1733
- `;
1734
- document.body.appendChild(contextMenuEl);
1735
- contextMenuEl.style.left = Math.min(x, window.innerWidth - 160) + 'px';
1736
- contextMenuEl.style.top = Math.min(y, window.innerHeight - 100) + 'px';
1737
-
1738
- contextMenuEl.addEventListener('click', e => {
1739
- const item = e.target.closest('.context-menu-item');
1740
- if (!item) return;
1741
- const action = item.dataset.action;
1742
- if (action === 'pin') togglePin(contextMenuUserId);
1743
- if (action === 'reset') resetJob(contextMenuUserId);
1744
- if (action === 'open') window.open('https://www.tiktok.com/@' + contextMenuUserId, '_blank');
1745
- hideContextMenu();
1746
- });
1747
- }
1748
-
1749
- function hideContextMenu() {
1750
- if (contextMenuEl) {
1751
- contextMenuEl.remove();
1752
- contextMenuEl = null;
1753
- contextMenuUserId = null;
1754
- }
1755
- }
1756
-
1757
- document.getElementById('userTable').addEventListener('contextmenu', e => {
1758
- const td = e.target.closest('td');
1759
- if (!td || td.parentElement.tagName !== 'TR') return;
1760
- e.preventDefault();
1761
- const tr = td.parentElement;
1762
- const userIdTd = tr.querySelector('td.user-id');
1763
- if (!userIdTd) return;
1764
- const uniqueId = userIdTd.textContent.trim().replace(/^@/, '');
1765
- const pinned = !!tr.querySelector('.tag.pinned');
1766
- showContextMenu(e.clientX, e.clientY, uniqueId, pinned);
1767
- });
1768
-
1769
- document.addEventListener('click', e => {
1770
- if (contextMenuEl && !contextMenuEl.contains(e.target)) {
1771
- hideContextMenu();
1772
- }
1773
- });
1774
-
1775
- async function togglePin(uniqueId) {
1776
- try {
1777
- const res = await fetch('/api/job/' + encodeURIComponent(uniqueId) + '/pin', {
1778
- method: 'POST',
1779
- });
1780
- const data = await res.json();
1781
- if (data.saved) {
1782
- showToast(data.pinned ? '已置顶' : '已取消置顶');
1783
- fetchUsers();
1784
- } else {
1785
- showToast(data.error || '操作失败', true);
1786
- }
1787
- } catch (e) {
1788
- showToast('操作失败: ' + e.message, true);
1789
- }
1790
- }
1791
-
1792
- async function resetJob(uniqueId) {
1793
- try {
1794
- const res = await fetch('/api/job/' + encodeURIComponent(uniqueId) + '/reset', {
1795
- method: 'POST',
1796
- });
1797
- const data = await res.json();
1798
- if (data.saved) {
1799
- showToast('\u5df2\u91cd\u7f6e\u4efb\u52a1');
1800
- fetchUsers();
1801
- fetchStats();
1802
- } else {
1803
- showToast(data.error || '\u91cd\u7f6e\u5931\u8d25', true);
1804
- }
1805
- } catch (e) {
1806
- showToast('\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
1807
- }
1808
- }
1809
-
1810
- async function batchResetErrors() {
1811
- const btn = document.getElementById('batchResetBtn');
1812
- const errorUsers = currentUsers.filter(u => u.status === 'error');
1813
- if (errorUsers.length === 0) {
1814
- showToast('\u6ca1\u6709\u9700\u8981\u91cd\u7f6e\u7684\u9519\u8bef\u7528\u6237', true);
1815
- return;
1816
- }
1817
- const userIds = errorUsers.map(u => u.uniqueId);
1818
- const origText = btn.innerHTML;
1819
- btn.disabled = true;
1820
- btn.style.opacity = '0.6';
1821
- btn.style.cursor = 'not-allowed';
1822
- btn.innerHTML = '&#x21bb; 处理中...';
1823
- try {
1824
- const res = await fetch('/api/jobs/batch-reset', {
1825
- method: 'POST',
1826
- headers: { 'Content-Type': 'application/json' },
1827
- body: JSON.stringify({ userIds })
1828
- });
1829
- const data = await res.json();
1830
- if (data.error) {
1831
- showToast(data.error, true);
1832
- return;
1833
- }
1834
- showToast(`\u5df2\u91cd\u7f6e ${data.reset} / ${data.total} \u4e2a\u7528\u6237`);
1835
- fetchUsers();
1836
- fetchStats();
1837
- } catch (e) {
1838
- showToast('\u6279\u91cf\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
1839
- } finally {
1840
- btn.disabled = false;
1841
- btn.style.opacity = '1';
1842
- btn.style.cursor = 'pointer';
1843
- btn.innerHTML = origText;
1844
- }
1845
- }
1846
-
1847
- document.getElementById('statTargetCard').addEventListener('click', async () => {
1848
- try {
1849
- const res = await fetch('/api/target-users', {
1850
- headers: { 'Accept': 'text/csv' },
1851
- });
1852
- if (!res.ok) throw new Error('HTTP ' + res.status);
1853
- const blob = await res.blob();
1854
- const ext = blob.size < 200 ? 'json' : 'csv';
1855
- if (ext === 'json') {
1856
- const text = await blob.text();
1857
- const data = JSON.parse(text);
1858
- if (!data.users.length) { showToast('暂无目标用户', true); return; }
1859
- if (navigator.clipboard && navigator.clipboard.writeText) {
1860
- const ids = data.users.map(u => '@' + u.uniqueId).join(', ');
1861
- await navigator.clipboard.writeText(ids);
1862
- showToast(data.users.length + ' 个目标用户 ID 已复制到剪贴板');
1863
- }
1864
- return;
1865
- }
1866
- const url = URL.createObjectURL(blob);
1867
- const a = document.createElement('a');
1868
- a.href = url;
1869
- a.download = 'target-users.csv';
1870
- a.click();
1871
- URL.revokeObjectURL(url);
1872
- showToast('CSV 文件已开始下载');
1873
- } catch (e) {
1874
- showToast('获取失败: ' + e.message, true);
1875
- }
1876
- });
1877
-
1878
- fetchStats();
1879
- fetchUsers();
1880
- fetchClientErrors();
1881
- setInterval(fetchStats, 10000);
1882
- setInterval(fetchUsers, 10000);
1883
- setInterval(fetchClientErrors, 10000);
1884
- setInterval(refreshUserUpdateIfActive, 10000);
1885
-
1886
- // Hash 路由
1887
- window.addEventListener('hashchange', handleRoute);
1888
- window.addEventListener('DOMContentLoaded', handleRoute);
1889
-
1890
- function handleRoute() {
1891
- const hash = window.location.hash;
1892
- if (hash === '#pending') {
1893
- showPendingPage();
1894
- } else if (hash === '#userUpdate') {
1895
- showUserUpdatePage();
1896
- } else if (hash === '#raw') {
1897
- showRawPage();
1898
- } else {
1899
- showMainPage();
1900
- }
1901
- }
1902
-
1903
- function navigateToPending() {
1904
- window.location.hash = '#pending';
1905
- }
1906
-
1907
- function navigateToUserUpdate() {
1908
- window.location.hash = '#userUpdate';
1909
- }
1910
-
1911
- function navigateToRaw() {
1912
- window.location.hash = '#raw';
1913
- }
1914
-
1915
- function navigateToMain() {
1916
- window.location.hash = '';
1917
- }
1918
-
1919
- function showPendingPage() {
1920
- document.getElementById('mainPage').classList.add('hidden');
1921
- document.getElementById('pendingPage').classList.add('active');
1922
- document.getElementById('userUpdatePage').classList.remove('active');
1923
- document.getElementById('rawPage').classList.remove('active');
1924
- fetchPendingByCountry();
1925
- fetchAttachStuckByCountry();
1926
- }
1927
-
1928
- function showUserUpdatePage() {
1929
- document.getElementById('mainPage').classList.add('hidden');
1930
- document.getElementById('pendingPage').classList.remove('active');
1931
- document.getElementById('userUpdatePage').classList.add('active');
1932
- document.getElementById('rawPage').classList.remove('active');
1933
- fetchUserUpdateByCountry();
1934
- fetchAttachStuckByCountry();
1935
- }
1936
-
1937
- function refreshUserUpdateIfActive() {
1938
- if (document.getElementById('userUpdatePage').classList.contains('active')) {
1939
- fetchUserUpdateByCountry();
1940
- fetchAttachStuckByCountry();
1941
- }
1942
- }
1943
-
1944
- function showRawPage() {
1945
- document.getElementById('mainPage').classList.add('hidden');
1946
- document.getElementById('pendingPage').classList.remove('active');
1947
- document.getElementById('userUpdatePage').classList.remove('active');
1948
- document.getElementById('rawPage').classList.add('active');
1949
- fetchRawByCountry();
1950
- fetchRawJobs();
1951
- }
1952
-
1953
- function showMainPage() {
1954
- document.getElementById('mainPage').classList.remove('hidden');
1955
- document.getElementById('pendingPage').classList.remove('active');
1956
- document.getElementById('userUpdatePage').classList.remove('active');
1957
- document.getElementById('rawPage').classList.remove('active');
1958
- }
1959
-
1960
- async function fetchPendingByCountry() {
1961
- try {
1962
- const res = await fetch('/api/pending-by-country');
1963
- const data = await res.json();
1964
- renderPendingCountryGrid(data.countries || []);
1965
- } catch (e) {
1966
- console.error('获取待处理国家分布失败:', e);
1967
- }
1968
- }
1969
-
1970
- function renderPendingCountryGrid(countries) {
1971
- const grid = document.getElementById('pendingCountryGrid');
1972
- if (!countries.length) {
1973
- grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待处理任务</span>';
1974
- return;
1975
- }
1976
- const total = countries.reduce((sum, c) => sum + c.count, 0);
1977
- grid.innerHTML = countries.map(c => {
1978
- const pct = ((c.count / total) * 100).toFixed(1);
1979
- const isUnknown = c.country === '未知';
1980
- const safeCountry = escapeJsString(c.country);
1981
- return `
1982
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
1983
- onclick="filterByPendingCountry('${safeCountry}')">
1984
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1985
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
1986
- <div class="country-count">${c.count}</div>
1987
- <div class="country-label">${pct}% 待处理</div>
1988
- </div>
1989
- `;
1990
- }).join('');
1991
- }
1992
-
1993
- function filterByPendingCountry(country) {
1994
- // 返回主页面并设置国家筛选
1995
- window.location.hash = '';
1996
- setTimeout(() => {
1997
- const searchInput = document.getElementById('searchInput');
1998
- const locationFilter = document.getElementById('locationFilter');
1999
- // 设置状态筛选为 pending
2000
- setFilter('pending');
2001
- // 如果有国家筛选下拉框,设置国家
2002
- if (locationFilter && country && country !== '未知') {
2003
- // 检查选项中是否有该国家
2004
- const options = locationFilter.options;
2005
- for (let i = 0; i < options.length; i++) {
2006
- if (options[i].value === country) {
2007
- locationFilter.value = country;
2008
- onLocationChange();
2009
- break;
2010
- }
2011
- }
2012
- }
2013
- }, 100);
2014
- }
2015
-
2016
- async function fetchUserUpdateByCountry() {
2017
- try {
2018
- const res = await fetch('/api/user-update-by-country');
2019
- const data = await res.json();
2020
- renderUserUpdateCountryGrid(data.countries || []);
2021
- } catch (e) {
2022
- console.error('获取待补资料国家分布失败:', e);
2023
- }
2024
- }
2025
-
2026
- function renderUserUpdateCountryGrid(countries) {
2027
- const grid = document.getElementById('userUpdateCountryGrid');
2028
- if (!countries.length) {
2029
- grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待补资料任务</span>';
2030
- return;
2031
- }
2032
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2033
- grid.innerHTML = countries.map(c => {
2034
- const pct = ((c.count / total) * 100).toFixed(1);
2035
- const isUnknown = c.country === '未知';
2036
- const safeCountry = escapeJsString(c.country);
2037
- return `
2038
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2039
- onclick="filterByUserUpdateCountry('${safeCountry}')">
2040
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
2041
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2042
- <div class="country-count">${c.count}</div>
2043
- <div class="country-label">${pct}% 待补资料</div>
2044
- </div>
2045
- `;
2046
- }).join('');
2047
- }
2048
-
2049
- async function fetchAttachStuckByCountry() {
2050
- try {
2051
- const res = await fetch('/api/attach-stuck-by-country');
2052
- const data = await res.json();
2053
- renderAttachStuckGrid('pendingAttachStuckGrid', data.countries || []);
2054
- renderAttachStuckGrid('userUpdateAttachStuckGrid', data.countries || []);
2055
- } catch (e) {
2056
- console.error('获取 attach 未成功国家分布失败:', e);
2057
- }
2058
- }
2059
-
2060
- function renderAttachStuckGrid(gridId, countries) {
2061
- const grid = document.getElementById(gridId);
2062
- if (!grid) return;
2063
- if (!countries.length) {
2064
- grid.innerHTML = '<span style="color:#666;font-size:12px">暂无 attach 未成功任务</span>';
2065
- return;
2066
- }
2067
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2068
- grid.innerHTML = countries.map(c => {
2069
- const pct = ((c.count / total) * 100).toFixed(1);
2070
- const isUnknown = c.country === '未知';
2071
- const safeCountry = escapeJsString(c.country);
2072
- return `
2073
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}">
2074
- <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
2075
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2076
- <div class="country-count">${c.count}</div>
2077
- <div class="country-label">${pct}% attach 未成功</div>
2078
- </div>
2079
- `;
2080
- }).join('');
2081
- }
2082
-
2083
- async function restoreAttachStuckByCountry(country, count) {
2084
- const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2085
- if (!window.confirm(`确认将 ${country} 下 attach 未成功的任务恢复为待补资料吗?${countText}`)) {
2086
- return;
2087
- }
2088
- try {
2089
- const res = await fetch('/api/attach-stuck/restore', {
2090
- method: 'POST',
2091
- headers: { 'Content-Type': 'application/json' },
2092
- body: JSON.stringify({ country })
2093
- });
2094
- const data = await res.json();
2095
- if (!res.ok || data.error) {
2096
- showToast(data.error || '恢复 attach 任务失败', true);
2097
- return;
2098
- }
2099
- showToast(`${country} 的 attach 未成功任务已恢复,共 ${data.restored} 条`);
2100
- await fetchStats();
2101
- await fetchPendingByCountry();
2102
- await fetchUserUpdateByCountry();
2103
- await fetchAttachStuckByCountry();
2104
- if (!document.getElementById('rawPage').classList.contains('active')) {
2105
- fetchUsers();
2106
- }
2107
- } catch (e) {
2108
- showToast('恢复 attach 任务失败: ' + e.message, true);
2109
- }
2110
- }
2111
-
2112
- async function moveCountryJobsToRaw(scope, country, count) {
2113
- const scopeLabel = scope === 'pending' ? '待处理任务' : '待补资料任务';
2114
- const countText = count != null ? `将移动 ${formatStatNum(count)} 条。` : '';
2115
- if (!window.confirm(`确认将 ${country} 的${scopeLabel}移到毛料库吗?${countText} 这些任务会先暂存,不再进入当前处理队列。`)) {
2116
- return;
2117
- }
2118
- try {
2119
- const res = await fetch('/api/jobs/move-to-raw', {
2120
- method: 'POST',
2121
- headers: { 'Content-Type': 'application/json' },
2122
- body: JSON.stringify({ scope, country })
2123
- });
2124
- const data = await res.json();
2125
- if (!res.ok || data.error) {
2126
- showToast(data.error || '移到毛料库失败', true);
2127
- return;
2128
- }
2129
- showToast(`${country} 已移到毛料库,共 ${data.moved} 条`);
2130
- await fetchStats();
2131
- if (scope === 'pending') {
2132
- await fetchPendingByCountry();
2133
- } else {
2134
- await fetchUserUpdateByCountry();
2135
- }
2136
- if (!document.getElementById('mainPage').classList.contains('hidden')) {
2137
- fetchUsers();
2138
- }
2139
- } catch (e) {
2140
- showToast('移到毛料库失败: ' + e.message, true);
2141
- }
2142
- }
2143
-
2144
- async function fetchRawByCountry() {
2145
- try {
2146
- const res = await fetch('/api/raw-by-country');
2147
- const data = await res.json();
2148
- renderRawCountryGrid(data.countries || []);
2149
- renderRawLocationFilter(data.countries || []);
2150
- } catch (e) {
2151
- console.error('获取毛料库国家分布失败:', e);
2152
- }
2153
- }
2154
-
2155
- function renderRawCountryGrid(countries) {
2156
- const grid = document.getElementById('rawCountryGrid');
2157
- if (!countries.length) {
2158
- grid.innerHTML = '<span style="color:#666;font-size:12px">毛料库暂无数据</span>';
2159
- return;
2160
- }
2161
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2162
- grid.innerHTML = countries.map(c => {
2163
- const pct = ((c.count / total) * 100).toFixed(1);
2164
- const isUnknown = c.country === '未知';
2165
- const safeCountry = escapeJsString(c.country);
2166
- return `
2167
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2168
- onclick="filterRawByCountry('${safeCountry}')">
2169
- <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
2170
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2171
- <div class="country-count">${c.count}</div>
2172
- <div class="country-label">${pct}% 毛料库</div>
2173
- </div>
2174
- `;
2175
- }).join('');
2176
- }
2177
-
2178
- async function fetchRawJobs() {
2179
- try {
2180
- const params = new URLSearchParams();
2181
- const search = document.getElementById('rawSearchInput').value.trim();
2182
- if (search) params.set('search', search);
2183
- if (currentRawLocation) params.set('location', currentRawLocation);
2184
- params.set('limit', '200');
2185
- const res = await fetch('/api/raw-jobs?' + params.toString());
2186
- const data = await res.json();
2187
- renderRawJobsTable(data.users || []);
2188
- } catch (e) {
2189
- console.error('获取毛料库列表失败:', e);
2190
- }
2191
- }
2192
-
2193
- function renderRawJobsTable(users) {
2194
- const el = document.getElementById('rawTable');
2195
- if (!users.length) {
2196
- el.innerHTML = '<tr><td colspan="10" style="color:#666;text-align:center;padding:24px">暂无毛料库任务</td></tr>';
2197
- return;
2198
- }
2199
- el.innerHTML = users.map(u => {
2200
- const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2201
- const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
2202
- const videos = u.videoCount != null ? u.videoCount : '-';
2203
- const loc = u.locationCreated || '-';
2204
- const guessedLoc = u.guessedLocation || '-';
2205
- const sources = (u.sources || []).join(', ');
2206
- const created = u.createdAt ? formatTime(u.createdAt) : '-';
2207
- const statusTag = u.status || '-';
2208
- return `<tr>
2209
- <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
2210
- <td data-label="昵称">${nick}</td>
2211
- <td data-label="粉丝">${fans}</td>
2212
- <td data-label="视频">${videos}</td>
2213
- <td data-label="国家">${loc}</td>
2214
- <td data-label="猜测国家">${guessedLoc}</td>
2215
- <td data-label="来源">${sources || '-'}</td>
2216
- <td data-label="状态">${statusTag}</td>
2217
- <td data-label="创建时间" style="font-size:11px;color:#888">${created}</td>
2218
- <td data-label="操作" style="text-align:center"><button onclick="restoreRawJob('${u.uniqueId}')" style="background:#22c55e;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;">恢复</button></td>
2219
- </tr>`;
2220
- }).join('');
2221
- }
2222
-
2223
- function renderRawLocationFilter(countries) {
2224
- const sel = document.getElementById('rawLocationFilter');
2225
- if (!sel) return;
2226
- const val = currentRawLocation;
2227
- sel.innerHTML = '<option value="">全部国家</option>' +
2228
- countries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
2229
- }
2230
-
2231
- function filterRawByCountry(country) {
2232
- currentRawLocation = country;
2233
- const sel = document.getElementById('rawLocationFilter');
2234
- if (sel) sel.value = country;
2235
- fetchRawJobs();
2236
- }
2237
-
2238
- function onRawLocationChange() {
2239
- const sel = document.getElementById('rawLocationFilter');
2240
- currentRawLocation = sel.value;
2241
- fetchRawJobs();
2242
- }
2243
-
2244
- function clearRawFilters() {
2245
- currentRawLocation = '';
2246
- const rawSearchInput = document.getElementById('rawSearchInput');
2247
- const rawLocationFilter = document.getElementById('rawLocationFilter');
2248
- if (rawSearchInput) rawSearchInput.value = '';
2249
- if (rawLocationFilter) rawLocationFilter.value = '';
2250
- fetchRawJobs();
2251
- }
2252
-
2253
- async function restoreRawJob(uniqueId) {
2254
- if (!window.confirm(`确认将 @${uniqueId} 从毛料库恢复到 jobs 队列吗?`)) {
2255
- return;
2256
- }
2257
- try {
2258
- const res = await fetch('/api/raw-jobs/restore', {
2259
- method: 'POST',
2260
- headers: { 'Content-Type': 'application/json' },
2261
- body: JSON.stringify({ uniqueId })
2262
- });
2263
- const data = await res.json();
2264
- if (!res.ok || data.error) {
2265
- showToast(data.error || '恢复失败', true);
2266
- return;
2267
- }
2268
- showToast(`@${uniqueId} 已恢复到队列`);
2269
- await fetchStats();
2270
- await fetchRawByCountry();
2271
- await fetchRawJobs();
2272
- } catch (e) {
2273
- showToast('恢复失败: ' + e.message, true);
2274
- }
2275
- }
2276
-
2277
- async function restoreFilteredRawJobs() {
2278
- const search = document.getElementById('rawSearchInput').value.trim();
2279
- const location = currentRawLocation;
2280
- let desc = '当前筛选条件';
2281
- if (search && location) desc = `搜索="${search}" + 国家=${location}`;
2282
- else if (search) desc = `搜索="${search}"`;
2283
- else if (location) desc = `国家=${location}`;
2284
- if (!window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)) {
2285
- return;
2286
- }
2287
- try {
2288
- const body = {};
2289
- if (search) body.search = search;
2290
- if (location) body.location = location;
2291
- const res = await fetch('/api/raw-jobs/restore', {
2292
- method: 'POST',
2293
- headers: { 'Content-Type': 'application/json' },
2294
- body: JSON.stringify(body)
2295
- });
2296
- const data = await res.json();
2297
- if (!res.ok || data.error) {
2298
- showToast(data.error || '恢复失败', true);
2299
- return;
2300
- }
2301
- showToast(`已恢复 ${data.restored} 条任务到队列`);
2302
- await fetchStats();
2303
- await fetchRawByCountry();
2304
- await fetchRawJobs();
2305
- } catch (e) {
2306
- showToast('恢复失败: ' + e.message, true);
2307
- }
2308
- }
2309
-
2310
- async function restoreRawJobsByCountry(country, count) {
2311
- const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2312
- if (!window.confirm(`确认将 ${country} 从毛料库恢复到 jobs 队列吗?${countText}`)) {
2313
- return;
2314
- }
2315
- try {
2316
- const res = await fetch('/api/raw-jobs/restore', {
2317
- method: 'POST',
2318
- headers: { 'Content-Type': 'application/json' },
2319
- body: JSON.stringify({ country })
2320
- });
2321
- const data = await res.json();
2322
- if (!res.ok || data.error) {
2323
- showToast(data.error || '恢复失败', true);
2324
- return;
2325
- }
2326
- showToast(`${country} 已恢复到队列,共 ${data.restored} 条`);
2327
- await fetchStats();
2328
- await fetchRawByCountry();
2329
- await fetchRawJobs();
2330
- } catch (e) {
2331
- showToast('恢复失败: ' + e.message, true);
2332
- }
2333
- }
2334
-
2335
- function filterByUserUpdateCountry(country) {
2336
- // 返回主页面,筛选待补资料(通过搜索 tt_seller 为空的用户)
2337
- window.location.hash = '';
2338
- setTimeout(() => {
2339
- const locationFilter = document.getElementById('locationFilter');
2340
- // 待补资料没有独立的状态筛选,这里只设置国家筛选
2341
- if (locationFilter && country && country !== '未知') {
2342
- const options = locationFilter.options;
2343
- for (let i = 0; i < options.length; i++) {
2344
- if (options[i].value === country) {
2345
- locationFilter.value = country;
2346
- onLocationChange();
2347
- break;
2348
- }
2349
- }
2350
- }
2351
- }, 100);
2352
- }
2353
- </script>
2354
- </body>
2355
-
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>TikTok 采集监控</title>
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
17
+ background: #0f0f13;
18
+ color: #e0e0e0;
19
+ padding: 16px;
20
+ }
21
+
22
+ .header {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: space-between;
26
+ padding: 12px 16px;
27
+ background: #1a1a24;
28
+ border-radius: 8px;
29
+ margin-bottom: 16px;
30
+ }
31
+
32
+ .header h1 {
33
+ font-size: 18px;
34
+ color: #fe2c55;
35
+ }
36
+
37
+ .header .meta {
38
+ font-size: 12px;
39
+ color: #888;
40
+ }
41
+
42
+ .header .status {
43
+ font-size: 12px;
44
+ color: #4ade80;
45
+ }
46
+
47
+ .script-link {
48
+ font-size: 12px;
49
+ color: #60a5fa;
50
+ text-decoration: none;
51
+ padding: 2px 8px;
52
+ border: 1px solid #60a5fa;
53
+ border-radius: 4px;
54
+ }
55
+
56
+ .script-link:hover {
57
+ background: #60a5fa;
58
+ color: #fff;
59
+ }
60
+
61
+ .stats {
62
+ display: grid;
63
+ grid-template-columns: repeat(7, 1fr);
64
+ gap: 12px;
65
+ margin-bottom: 16px;
66
+ }
67
+
68
+ .stat-card {
69
+ background: #1a1a24;
70
+ border-radius: 8px;
71
+ padding: 16px;
72
+ text-align: center;
73
+ }
74
+
75
+ .stat-card .label {
76
+ font-size: 12px;
77
+ color: #888;
78
+ margin-bottom: 8px;
79
+ }
80
+
81
+ .stat-card .value {
82
+ font-size: 28px;
83
+ font-weight: 700;
84
+ }
85
+
86
+ .stat-card .value.total {
87
+ color: #60a5fa;
88
+ }
89
+
90
+ .stat-card .value.done {
91
+ color: #4ade80;
92
+ }
93
+
94
+ .stat-card .value.pending {
95
+ color: #facc15;
96
+ }
97
+
98
+ .stat-card .value.error {
99
+ color: #f87171;
100
+ }
101
+
102
+ .stat-card .value.target {
103
+ color: #a78bfa;
104
+ }
105
+
106
+ .stat-card .value-sub {
107
+ font-size: 12px;
108
+ font-weight: 400;
109
+ margin-top: 2px;
110
+ }
111
+
112
+ .stat-card.clickable {
113
+ cursor: pointer;
114
+ }
115
+
116
+ .stat-card.clickable:hover {
117
+ background: #25253a;
118
+ }
119
+
120
+ .stat-card.clickable.pending-card:hover {
121
+ background: rgba(250, 204, 21, 0.15);
122
+ }
123
+
124
+ .stat-card.clickable.pending-card:hover,
125
+ .stat-card.clickable#statUserUpdateCard:hover {
126
+ background: rgba(167, 139, 250, 0.15);
127
+ }
128
+
129
+ .charts {
130
+ display: grid;
131
+ grid-template-columns: 1fr 1fr;
132
+ gap: 12px;
133
+ margin-bottom: 16px;
134
+ }
135
+
136
+ .chart-box {
137
+ background: #1a1a24;
138
+ border-radius: 8px;
139
+ padding: 16px;
140
+ }
141
+
142
+ .chart-box h3 {
143
+ font-size: 14px;
144
+ color: #888;
145
+ margin-bottom: 12px;
146
+ }
147
+
148
+ .bar-row {
149
+ display: flex;
150
+ align-items: center;
151
+ margin-bottom: 8px;
152
+ font-size: 13px;
153
+ }
154
+
155
+ .bar-row .name {
156
+ width: 50px;
157
+ color: #ccc;
158
+ flex-shrink: 0;
159
+ }
160
+
161
+ .bar-row .bar-bg {
162
+ flex: 1;
163
+ background: #2a2a3a;
164
+ border-radius: 4px;
165
+ height: 20px;
166
+ overflow: hidden;
167
+ margin: 0 8px;
168
+ }
169
+
170
+ .bar-row .bar-fill {
171
+ height: 100%;
172
+ border-radius: 4px;
173
+ transition: width 0.3s;
174
+ display: flex;
175
+ align-items: center;
176
+ padding-left: 6px;
177
+ font-size: 11px;
178
+ color: #fff;
179
+ white-space: nowrap;
180
+ }
181
+
182
+ .bar-row .target-badge {
183
+ width: 60px;
184
+ text-align: center;
185
+ color: #4ade80;
186
+ font-weight: 600;
187
+ font-size: 12px;
188
+ flex-shrink: 0;
189
+ margin: 0 6px;
190
+ }
191
+
192
+ .bar-row .count {
193
+ width: 55px;
194
+ text-align: right;
195
+ color: #888;
196
+ flex-shrink: 0;
197
+ }
198
+
199
+ .bar-row.is-target {
200
+ background: rgba(167, 139, 250, 0.08);
201
+ border-radius: 4px;
202
+ padding: 2px 0;
203
+ }
204
+
205
+ .bar-row.is-target .name {
206
+ color: #a78bfa;
207
+ font-weight: 600;
208
+ }
209
+
210
+ .source-row {
211
+ display: flex;
212
+ align-items: center;
213
+ margin-bottom: 6px;
214
+ font-size: 13px;
215
+ }
216
+
217
+ .source-row .s-name {
218
+ width: 80px;
219
+ color: #ccc;
220
+ flex-shrink: 0;
221
+ }
222
+
223
+ .source-row .s-val {
224
+ color: #888;
225
+ }
226
+
227
+ .table-wrap {
228
+ background: #1a1a24;
229
+ border-radius: 8px;
230
+ padding: 16px;
231
+ }
232
+
233
+ .table-wrap h3 {
234
+ font-size: 14px;
235
+ color: #888;
236
+ margin-bottom: 12px;
237
+ }
238
+
239
+ .controls {
240
+ display: flex;
241
+ gap: 8px;
242
+ margin-bottom: 12px;
243
+ flex-wrap: wrap;
244
+ }
245
+
246
+ .controls input {
247
+ flex: 1;
248
+ min-width: 150px;
249
+ padding: 6px 12px;
250
+ border: 1px solid #333;
251
+ border-radius: 6px;
252
+ background: #0f0f13;
253
+ color: #e0e0e0;
254
+ font-size: 13px;
255
+ outline: none;
256
+ }
257
+
258
+ .controls input:focus {
259
+ border-color: #fe2c55;
260
+ }
261
+
262
+ .controls button {
263
+ padding: 6px 14px;
264
+ border: 1px solid #333;
265
+ border-radius: 6px;
266
+ background: #2a2a3a;
267
+ color: #ccc;
268
+ font-size: 12px;
269
+ cursor: pointer;
270
+ transition: all 0.2s;
271
+ }
272
+
273
+ .controls button:hover {
274
+ border-color: #fe2c55;
275
+ color: #fff;
276
+ }
277
+
278
+ .controls button.active {
279
+ background: #fe2c55;
280
+ color: #fff;
281
+ border-color: #fe2c55;
282
+ }
283
+
284
+ .add-users {
285
+ display: flex;
286
+ gap: 8px;
287
+ margin-bottom: 12px;
288
+ align-items: center;
289
+ }
290
+
291
+ .add-users button {
292
+ padding: 6px 16px;
293
+ border: none;
294
+ border-radius: 6px;
295
+ background: #fe2c55;
296
+ color: #fff;
297
+ font-size: 13px;
298
+ cursor: pointer;
299
+ font-weight: 600;
300
+ transition: all 0.2s;
301
+ }
302
+
303
+ .add-users button:hover {
304
+ background: #e61944;
305
+ }
306
+
307
+ .modal-overlay {
308
+ position: fixed;
309
+ inset: 0;
310
+ background: rgba(0, 0, 0, 0.65);
311
+ z-index: 1000;
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ }
316
+
317
+ .modal {
318
+ background: #1a1a24;
319
+ border-radius: 12px;
320
+ padding: 24px;
321
+ width: 520px;
322
+ max-width: 90vw;
323
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
324
+ }
325
+
326
+ .modal h3 {
327
+ font-size: 16px;
328
+ color: #e0e0e0;
329
+ margin-bottom: 6px;
330
+ }
331
+
332
+ .modal .hint {
333
+ font-size: 12px;
334
+ color: #888;
335
+ margin-bottom: 16px;
336
+ }
337
+
338
+ .modal textarea {
339
+ width: 100%;
340
+ height: 180px;
341
+ padding: 10px 14px;
342
+ border: 1px solid #333;
343
+ border-radius: 8px;
344
+ background: #0f0f13;
345
+ color: #e0e0e0;
346
+ font-size: 13px;
347
+ font-family: inherit;
348
+ outline: none;
349
+ resize: vertical;
350
+ line-height: 1.6;
351
+ }
352
+
353
+ .modal textarea:focus {
354
+ border-color: #fe2c55;
355
+ }
356
+
357
+ .modal textarea::placeholder {
358
+ color: #555;
359
+ }
360
+
361
+ .modal .preview {
362
+ margin-top: 8px;
363
+ font-size: 12px;
364
+ color: #60a5fa;
365
+ min-height: 20px;
366
+ }
367
+
368
+ .modal .btn-row {
369
+ display: flex;
370
+ gap: 8px;
371
+ margin-top: 16px;
372
+ justify-content: flex-end;
373
+ }
374
+
375
+ .modal .btn-row button {
376
+ padding: 8px 20px;
377
+ border: none;
378
+ border-radius: 6px;
379
+ font-size: 13px;
380
+ cursor: pointer;
381
+ font-weight: 600;
382
+ transition: all 0.2s;
383
+ }
384
+
385
+ .modal .btn-cancel {
386
+ background: #2a2a3a;
387
+ color: #ccc;
388
+ }
389
+
390
+ .modal .btn-cancel:hover {
391
+ background: #333;
392
+ }
393
+
394
+ .modal .btn-submit {
395
+ background: #fe2c55;
396
+ color: #fff;
397
+ }
398
+
399
+ .modal .btn-submit:hover {
400
+ background: #e61944;
401
+ }
402
+
403
+ .toast {
404
+ position: fixed;
405
+ top: 16px;
406
+ right: 16px;
407
+ padding: 10px 20px;
408
+ border-radius: 6px;
409
+ font-size: 13px;
410
+ z-index: 999;
411
+ transition: opacity 0.3s;
412
+ }
413
+
414
+ .toast.success {
415
+ background: #166534;
416
+ color: #fff;
417
+ }
418
+
419
+ .toast.error {
420
+ background: #991b1b;
421
+ color: #fff;
422
+ }
423
+
424
+ @keyframes flashChange {
425
+ 0% {
426
+ filter: brightness(1.8);
427
+ box-shadow: 0 0 12px rgba(254, 44, 85, 0.6);
428
+ }
429
+
430
+ 100% {
431
+ filter: brightness(1);
432
+ box-shadow: none;
433
+ }
434
+ }
435
+
436
+ .flash-change {
437
+ animation: flashChange 0.6s ease-out;
438
+ }
439
+
440
+ @keyframes rowFlash {
441
+ 0% {
442
+ background: rgba(254, 44, 85, 0.25);
443
+ }
444
+
445
+ 100% {
446
+ background: transparent;
447
+ }
448
+ }
449
+
450
+ tr.row-flash {
451
+ animation: rowFlash 0.8s ease-out;
452
+ }
453
+
454
+ @keyframes barFlash {
455
+ 0% {
456
+ filter: brightness(1.6);
457
+ }
458
+
459
+ 100% {
460
+ filter: brightness(1);
461
+ }
462
+ }
463
+
464
+ .bar-fill.bar-flash {
465
+ animation: barFlash 0.5s ease-out;
466
+ }
467
+
468
+ .table-scroll {
469
+ max-height: 500px;
470
+ overflow-y: auto;
471
+ }
472
+
473
+ table {
474
+ width: 100%;
475
+ border-collapse: collapse;
476
+ font-size: 12px;
477
+ }
478
+
479
+ th {
480
+ position: sticky;
481
+ top: 0;
482
+ background: #22222e;
483
+ padding: 8px 10px;
484
+ text-align: left;
485
+ color: #888;
486
+ font-weight: 600;
487
+ border-bottom: 1px solid #333;
488
+ white-space: nowrap;
489
+ }
490
+
491
+ td {
492
+ padding: 6px 10px;
493
+ border-bottom: 1px solid #1f1f2a;
494
+ white-space: nowrap;
495
+ }
496
+
497
+ tr:hover {
498
+ background: #1f1f2a;
499
+ }
500
+
501
+ td.user-id {
502
+ cursor: pointer;
503
+ color: #60a5fa;
504
+ }
505
+
506
+ td.user-id:hover {
507
+ color: #fe2c55;
508
+ }
509
+
510
+ .tag {
511
+ display: inline-block;
512
+ padding: 1px 6px;
513
+ border-radius: 3px;
514
+ font-size: 10px;
515
+ }
516
+
517
+ .tag.seller {
518
+ background: #dc2626;
519
+ color: #fff;
520
+ }
521
+
522
+ .tag.verified {
523
+ background: #2563eb;
524
+ color: #fff;
525
+ }
526
+
527
+ .tag.pending {
528
+ background: #ca8a04;
529
+ color: #000;
530
+ }
531
+
532
+ .tag.processing {
533
+ background: #0ea5e9;
534
+ color: #fff;
535
+ }
536
+
537
+ .tag.error {
538
+ background: #991b1b;
539
+ color: #fff;
540
+ }
541
+
542
+ .tag.unknown-country {
543
+ background: #4b5563;
544
+ color: #fff;
545
+ }
546
+
547
+ #pendingPage {
548
+ display: none;
549
+ }
550
+
551
+ #pendingPage.active {
552
+ display: block;
553
+ }
554
+
555
+ #userUpdatePage {
556
+ display: none;
557
+ }
558
+
559
+ #userUpdatePage.active {
560
+ display: block;
561
+ }
562
+
563
+ #rawPage {
564
+ display: none;
565
+ }
566
+
567
+ #rawPage.active {
568
+ display: block;
569
+ }
570
+
571
+ #mainPage.hidden {
572
+ display: none;
573
+ }
574
+
575
+ .pending-country-card {
576
+ background: #1a1a24;
577
+ border-radius: 8px;
578
+ padding: 20px;
579
+ margin-bottom: 16px;
580
+ }
581
+
582
+ .pending-country-card h3 {
583
+ font-size: 16px;
584
+ color: #facc15;
585
+ margin-bottom: 16px;
586
+ display: flex;
587
+ align-items: center;
588
+ gap: 8px;
589
+ }
590
+
591
+ .pending-country-grid {
592
+ display: grid;
593
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
594
+ gap: 12px;
595
+ }
596
+
597
+ .pending-country-item {
598
+ background: #2a2a3a;
599
+ border-radius: 8px;
600
+ padding: 16px;
601
+ cursor: pointer;
602
+ transition: all 0.2s;
603
+ border: 1px solid transparent;
604
+ position: relative;
605
+ }
606
+
607
+ .country-action-btn {
608
+ position: absolute;
609
+ top: 10px;
610
+ right: 10px;
611
+ width: 28px;
612
+ height: 28px;
613
+ border: 1px solid rgba(248, 113, 113, 0.35);
614
+ border-radius: 999px;
615
+ background: rgba(127, 29, 29, 0.45);
616
+ color: #fca5a5;
617
+ display: inline-flex;
618
+ align-items: center;
619
+ justify-content: center;
620
+ font-size: 14px;
621
+ cursor: pointer;
622
+ transition: all 0.2s;
623
+ }
624
+
625
+ .country-action-btn:hover {
626
+ background: rgba(153, 27, 27, 0.85);
627
+ color: #fff;
628
+ border-color: rgba(248, 113, 113, 0.8);
629
+ }
630
+
631
+ .country-action-btn.restore {
632
+ border-color: rgba(74, 222, 128, 0.35);
633
+ background: rgba(20, 83, 45, 0.45);
634
+ color: #86efac;
635
+ }
636
+
637
+ .country-action-btn.restore:hover {
638
+ background: rgba(22, 101, 52, 0.9);
639
+ border-color: rgba(74, 222, 128, 0.8);
640
+ color: #fff;
641
+ }
642
+
643
+ .raw-page-layout {
644
+ display: grid;
645
+ grid-template-columns: 320px 1fr;
646
+ gap: 16px;
647
+ }
648
+
649
+ .raw-side-card {
650
+ background: #1a1a24;
651
+ border-radius: 8px;
652
+ padding: 16px;
653
+ }
654
+
655
+ .raw-side-card h3 {
656
+ font-size: 14px;
657
+ color: #f59e0b;
658
+ margin-bottom: 12px;
659
+ }
660
+
661
+ .raw-table-card {
662
+ background: #1a1a24;
663
+ border-radius: 8px;
664
+ padding: 16px;
665
+ }
666
+
667
+ .raw-table-card h3 {
668
+ font-size: 14px;
669
+ color: #888;
670
+ margin-bottom: 12px;
671
+ }
672
+
673
+ .muted-tip {
674
+ font-size: 12px;
675
+ color: #777;
676
+ margin-top: 8px;
677
+ line-height: 1.5;
678
+ }
679
+
680
+ .pending-country-item:hover {
681
+ border-color: #facc15;
682
+ background: #33334a;
683
+ }
684
+
685
+ .pending-country-item .country-name {
686
+ font-size: 18px;
687
+ font-weight: 700;
688
+ color: #facc15;
689
+ margin-bottom: 4px;
690
+ }
691
+
692
+ .pending-country-item .country-count {
693
+ font-size: 28px;
694
+ font-weight: 700;
695
+ color: #fff;
696
+ }
697
+
698
+ .pending-country-item .country-label {
699
+ font-size: 12px;
700
+ color: #888;
701
+ margin-top: 2px;
702
+ }
703
+
704
+ .pending-country-item.has-target {
705
+ border-color: #a78bfa;
706
+ }
707
+
708
+ .pending-country-item.has-target .country-name {
709
+ color: #a78bfa;
710
+ }
711
+
712
+ .back-btn {
713
+ padding: 6px 14px;
714
+ border: 1px solid #333;
715
+ border-radius: 6px;
716
+ background: #2a2a3a;
717
+ color: #ccc;
718
+ font-size: 13px;
719
+ cursor: pointer;
720
+ transition: all 0.2s;
721
+ margin-bottom: 16px;
722
+ }
723
+
724
+ .back-btn:hover {
725
+ border-color: #fe2c55;
726
+ color: #fff;
727
+ }
728
+
729
+ .tag.processed {
730
+ background: #166534;
731
+ color: #fff;
732
+ }
733
+
734
+ .tag.no-video {
735
+ background: #7c3aed;
736
+ color: #fff;
737
+ }
738
+
739
+ .tag.no-follow {
740
+ background: #b45309;
741
+ color: #fff;
742
+ }
743
+
744
+ .tag.keep-follow {
745
+ background: #059669;
746
+ color: #fff;
747
+ }
748
+
749
+ .tag.pinned {
750
+ background: #f59e0b;
751
+ color: #000;
752
+ }
753
+
754
+ .context-menu {
755
+ position: fixed;
756
+ background: #1e1e2e;
757
+ border: 1px solid #333;
758
+ border-radius: 6px;
759
+ padding: 4px 0;
760
+ min-width: 140px;
761
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
762
+ z-index: 1000;
763
+ }
764
+
765
+ .context-menu-item {
766
+ padding: 8px 16px;
767
+ font-size: 13px;
768
+ color: #ccc;
769
+ cursor: pointer;
770
+ display: flex;
771
+ align-items: center;
772
+ gap: 8px;
773
+ }
774
+
775
+ .context-menu-item:hover {
776
+ background: #fe2c55;
777
+ color: #fff;
778
+ }
779
+
780
+ .context-menu-item.danger {
781
+ color: #f87171;
782
+ }
783
+
784
+ .context-menu-item.danger:hover {
785
+ background: #991b1b;
786
+ color: #fff;
787
+ }
788
+
789
+ ::-webkit-scrollbar {
790
+ width: 6px;
791
+ }
792
+
793
+ ::-webkit-scrollbar-track {
794
+ background: #1a1a24;
795
+ }
796
+
797
+ ::-webkit-scrollbar-thumb {
798
+ background: #333;
799
+ border-radius: 3px;
800
+ }
801
+
802
+ .client-errors-section {
803
+ margin-bottom: 16px;
804
+ }
805
+
806
+ .section-header {
807
+ display: flex;
808
+ align-items: center;
809
+ gap: 8px;
810
+ margin-bottom: 8px;
811
+ }
812
+
813
+ .section-header h3 {
814
+ font-size: 14px;
815
+ color: #e0e0e0;
816
+ }
817
+
818
+ .error-badge {
819
+ background: #991b1b;
820
+ color: #fff;
821
+ font-size: 11px;
822
+ padding: 2px 8px;
823
+ border-radius: 10px;
824
+ font-weight: 600;
825
+ }
826
+
827
+ .client-errors-table {
828
+ width: 100%;
829
+ border-collapse: collapse;
830
+ background: #1a1a24;
831
+ border-radius: 8px;
832
+ overflow: hidden;
833
+ font-size: 13px;
834
+ }
835
+
836
+ .client-errors-table thead tr {
837
+ background: #22222e;
838
+ }
839
+
840
+ .client-errors-table th {
841
+ padding: 10px 12px;
842
+ text-align: left;
843
+ color: #888;
844
+ font-weight: 600;
845
+ font-size: 12px;
846
+ border-bottom: 1px solid #2a2a3a;
847
+ }
848
+
849
+ .client-errors-table td {
850
+ padding: 8px 12px;
851
+ border-bottom: 1px solid #1f1f2e;
852
+ }
853
+
854
+ .client-errors-table tbody tr:last-child td {
855
+ border-bottom: none;
856
+ }
857
+
858
+ .client-errors-table tbody tr:hover {
859
+ background: #22222e;
860
+ }
861
+
862
+ .error-type-captcha {
863
+ color: #f59e0b;
864
+ }
865
+
866
+ .error-type-network {
867
+ color: #f87171;
868
+ }
869
+
870
+ .error-type-other {
871
+ color: #a78bfa;
872
+ }
873
+
874
+ @media (max-width: 768px) {
875
+ body {
876
+ padding: 8px;
877
+ }
878
+
879
+ .header {
880
+ flex-direction: column;
881
+ gap: 6px;
882
+ align-items: flex-start;
883
+ }
884
+
885
+ .header h1 {
886
+ font-size: 16px;
887
+ }
888
+
889
+ .stats {
890
+ grid-template-columns: repeat(2, 1fr);
891
+ gap: 8px;
892
+ }
893
+
894
+ .stat-card {
895
+ padding: 10px;
896
+ }
897
+
898
+ .stat-card .label {
899
+ font-size: 11px;
900
+ }
901
+
902
+ .stat-card .value {
903
+ font-size: 18px;
904
+ }
905
+
906
+ .charts {
907
+ grid-template-columns: 1fr;
908
+ }
909
+
910
+ .raw-page-layout {
911
+ grid-template-columns: 1fr;
912
+ }
913
+
914
+ .table-wrap {
915
+ padding: 10px;
916
+ }
917
+
918
+ .controls {
919
+ flex-wrap: wrap;
920
+ gap: 6px;
921
+ }
922
+
923
+ .controls input {
924
+ flex: 0 0 100%;
925
+ width: 100%;
926
+ }
927
+
928
+ .controls button {
929
+ flex: 0 0 calc(33.33% - 4px);
930
+ min-width: 0;
931
+ text-align: center;
932
+ white-space: nowrap;
933
+ font-size: 11px;
934
+ padding: 8px 4px;
935
+ }
936
+
937
+ #batchResetBtn {
938
+ flex: 0 0 100% !important;
939
+ font-size: 12px !important;
940
+ padding: 8px 12px !important;
941
+ }
942
+
943
+ .controls select {
944
+ flex: 0 0 100%;
945
+ width: 100%;
946
+ }
947
+
948
+ .table-scroll {
949
+ max-height: none;
950
+ overflow: visible;
951
+ }
952
+
953
+ table,
954
+ thead,
955
+ tbody,
956
+ th,
957
+ td,
958
+ tr {
959
+ display: block;
960
+ }
961
+
962
+ thead {
963
+ display: none;
964
+ }
965
+
966
+ tr {
967
+ background: #22222e;
968
+ border-radius: 8px;
969
+ padding: 10px 12px;
970
+ margin-bottom: 8px;
971
+ border: 1px solid #2a2a3a;
972
+ }
973
+
974
+ tr:hover {
975
+ background: #2a2a3a;
976
+ }
977
+
978
+ td {
979
+ padding: 4px 0;
980
+ border: none;
981
+ text-align: left;
982
+ position: relative;
983
+ padding-left: 40%;
984
+ font-size: 13px;
985
+ }
986
+
987
+ td::before {
988
+ content: attr(data-label);
989
+ position: absolute;
990
+ left: 0;
991
+ width: 36%;
992
+ text-align: right;
993
+ color: #888;
994
+ font-size: 12px;
995
+ font-weight: 600;
996
+ white-space: nowrap;
997
+ }
998
+
999
+ td.user-id {
1000
+ font-size: 15px;
1001
+ font-weight: 700;
1002
+ color: #60a5fa;
1003
+ padding-left: 0;
1004
+ border-bottom: 1px solid #2a2a3a;
1005
+ margin-bottom: 4px;
1006
+ padding-bottom: 6px;
1007
+ }
1008
+
1009
+ td.user-id::before {
1010
+ display: none;
1011
+ }
1012
+
1013
+ td.user-id:hover {
1014
+ color: #fe2c55;
1015
+ }
1016
+
1017
+ .add-users {
1018
+ justify-content: center;
1019
+ }
1020
+
1021
+ .modal {
1022
+ width: 95vw;
1023
+ padding: 16px;
1024
+ }
1025
+
1026
+ .modal textarea {
1027
+ height: 140px;
1028
+ }
1029
+ }
1030
+ </style>
1031
+ </head>
1032
+
1033
+ <body>
1034
+ <div class="header">
1035
+ <h1>TikTok 采集监控</h1>
1036
+ <div class="meta" id="fileMeta">加载中...</div>
1037
+ <div style="display:flex;gap:8px;align-items:center">
1038
+ <a href="/scripts/run-explore.sh" class="script-link" download="run-explore.sh">mac</a>
1039
+ <a href="/scripts/run-explore.bat" class="script-link" download="run-explore.bat">windows</a>
1040
+ <span class="status" id="lastUpdate">--</span>
1041
+ </div>
1042
+ </div>
1043
+ <div id="mainPage">
1044
+ <div class="stats">
1045
+ <div class="stat-card">
1046
+ <div class="label">总用户</div>
1047
+ <div class="value total" id="statTotal">0</div>
1048
+ </div>
1049
+ <div class="stat-card">
1050
+ <div class="label">处理中</div>
1051
+ <div class="value total" id="statProcessing">0</div>
1052
+ </div>
1053
+ <div class="stat-card">
1054
+ <div class="label">已完成</div>
1055
+ <div class="value done" id="statDone">0</div>
1056
+ </div>
1057
+ <div class="stat-card clickable pending-card" id="statPendingCard" onclick="navigateToPending()">
1058
+ <div class="label">待处理</div>
1059
+ <div class="value pending" id="statPending">0</div>
1060
+ </div>
1061
+ <div class="stat-card">
1062
+ <div class="label">错误</div>
1063
+ <div class="value error" id="statError">0</div>
1064
+ </div>
1065
+ <div class="stat-card">
1066
+ <div class="label">受限</div>
1067
+ <div class="value error" id="statRestricted">0</div>
1068
+ </div>
1069
+ <div class="stat-card clickable pending-card" id="statUserUpdateCard" onclick="navigateToUserUpdate()">
1070
+ <div class="label">待补资料</div>
1071
+ <div class="value target" id="statUserUpdateTasks">0</div>
1072
+ </div>
1073
+ <div class="stat-card clickable" id="statRawCard" onclick="navigateToRaw()">
1074
+ <div class="label">毛料库</div>
1075
+ <div class="value target" id="statRawJobs">0</div>
1076
+ </div>
1077
+ <div class="stat-card clickable" id="statTargetCard">
1078
+ <div class="label">目标商家</div>
1079
+ <div class="value target" id="statTarget">0</div>
1080
+ </div>
1081
+ </div>
1082
+ <div class="client-errors-section" id="clientErrorsSection" style="display:none">
1083
+ <div class="section-header">
1084
+ <h3>客户端异常</h3>
1085
+ <span class="error-badge" id="clientErrorsBadge">0</span>
1086
+ </div>
1087
+ <table class="client-errors-table">
1088
+ <thead>
1089
+ <tr>
1090
+ <th>客户端</th>
1091
+ <th>错误类型</th>
1092
+ <th>错误次数</th>
1093
+ <th>验证码次数</th>
1094
+ <th>阶段</th>
1095
+ <th>错误详情</th>
1096
+ <th>当时处理的 TikTok 用户</th>
1097
+ <th>时间</th>
1098
+ <th>操作</th>
1099
+ </tr>
1100
+ </thead>
1101
+ <tbody id="clientErrorsBody"></tbody>
1102
+ </table>
1103
+ </div>
1104
+ <div class="charts">
1105
+ <div class="chart-box">
1106
+ <h3>国家统计</h3>
1107
+ <div id="countryChart"></div>
1108
+ </div>
1109
+ <div class="chart-box">
1110
+ <h3>来源分布</h3>
1111
+ <div id="sourceChart"></div>
1112
+ </div>
1113
+ </div>
1114
+ <div class="table-wrap">
1115
+ <h3>用户列表</h3>
1116
+ <div class="add-users">
1117
+ <button onclick="openAddModal()">+ 插入队列</button>
1118
+ </div>
1119
+ <div id="toast" class="toast" style="display:none"></div>
1120
+ <div class="controls">
1121
+ <input type="text" id="searchInput" placeholder="搜索用户名 / 昵称...">
1122
+ <button data-filter="all" class="active" onclick="setFilter('all')">全部</button>
1123
+ <button data-filter="processing" onclick="setFilter('processing')">处理中</button>
1124
+ <button data-filter="pending" onclick="setFilter('pending')">待处理</button>
1125
+ <button data-filter="done" onclick="setFilter('done')">已完成</button>
1126
+ <button data-filter="error" onclick="setFilter('error')">错误</button>
1127
+ <button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
1128
+ <button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标商家</button>
1129
+ <select id="targetLocationFilter" onchange="onTargetLocationChange()"
1130
+ style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;display:none">
1131
+ <option value="">全部目标国家</option>
1132
+ </select>
1133
+ <button id="batchResetBtn" onclick="batchResetErrors()"
1134
+ style="display:none;padding:6px 10px;border:1px solid #f87171;border-radius:6px;background:transparent;color:#f87171;font-size:12px;cursor:pointer;font-weight:600;transition:all 0.2s;white-space:nowrap;">&#x21bb;
1135
+ 批量重新处理 (<span id="batchResetCount">0</span>)</button>
1136
+ <select id="locationFilter" onchange="onLocationChange()"
1137
+ style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
1138
+ <option value="">全部国家</option>
1139
+ </select>
1140
+ </div>
1141
+ <div class="table-scroll">
1142
+ <table>
1143
+ <thead>
1144
+ <tr>
1145
+ <th>用户名</th>
1146
+ <th>昵称</th>
1147
+ <th>粉丝</th>
1148
+ <th>视频</th>
1149
+ <th>国家</th>
1150
+ <th>猜测国家</th>
1151
+ <th>来源</th>
1152
+ <th>状态</th>
1153
+ <th>处理端</th>
1154
+ <th>领取时间</th>
1155
+ <th>完成时间</th>
1156
+ </tr>
1157
+ </thead>
1158
+ <tbody id="userTable"></tbody>
1159
+ </table>
1160
+ </div>
1161
+ </div>
1162
+ </div>
1163
+ <div id="pendingPage">
1164
+ <div class="stats" style="margin-bottom:16px">
1165
+ <div class="stat-card">
1166
+ <div class="label">总用户</div>
1167
+ <div class="value total" id="pendingStatTotal">0</div>
1168
+ </div>
1169
+ <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(96,165,250,0.1)">
1170
+ <div class="label">← 返回主页面</div>
1171
+ </div>
1172
+ <div class="stat-card">
1173
+ <div class="label">待处理</div>
1174
+ <div class="value pending" id="pendingStatPending">0</div>
1175
+ </div>
1176
+ <div class="stat-card clickable pending-card" onclick="navigateToUserUpdate()">
1177
+ <div class="label">待补资料</div>
1178
+ <div class="value target" id="pendingStatUserUpdateTasks">0</div>
1179
+ </div>
1180
+ <div class="stat-card clickable" onclick="navigateToRaw()">
1181
+ <div class="label">毛料库</div>
1182
+ <div class="value target" id="pendingStatRawJobs">0</div>
1183
+ </div>
1184
+ </div>
1185
+ <div class="pending-country-card">
1186
+ <h3>📊 待处理任务按国家分布</h3>
1187
+ <div class="pending-country-grid" id="pendingCountryGrid">
1188
+ <span style="color:#666;font-size:12px">加载中...</span>
1189
+ </div>
1190
+ </div>
1191
+ <div class="pending-country-card">
1192
+ <h3>🔁 Attach 未成功(状态 1)</h3>
1193
+ <div class="pending-country-grid" id="pendingAttachStuckGrid">
1194
+ <span style="color:#666;font-size:12px">加载中...</span>
1195
+ </div>
1196
+ </div>
1197
+ </div>
1198
+ <div id="userUpdatePage">
1199
+ <div class="stats" style="margin-bottom:16px">
1200
+ <div class="stat-card">
1201
+ <div class="label">总用户</div>
1202
+ <div class="value total" id="userUpdateStatTotal">0</div>
1203
+ </div>
1204
+ <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(167,139,250,0.1)">
1205
+ <div class="label">← 返回主页面</div>
1206
+ </div>
1207
+ <div class="stat-card clickable pending-card" onclick="navigateToPending()">
1208
+ <div class="label">待处理</div>
1209
+ <div class="value pending" id="userUpdateStatPending">0</div>
1210
+ </div>
1211
+ <div class="stat-card">
1212
+ <div class="label">待补资料</div>
1213
+ <div class="value target" id="userUpdateStatUserUpdateTasks">0</div>
1214
+ </div>
1215
+ <div class="stat-card clickable" onclick="navigateToRaw()">
1216
+ <div class="label">毛料库</div>
1217
+ <div class="value target" id="userUpdateStatRawJobs">0</div>
1218
+ </div>
1219
+ </div>
1220
+ <div class="pending-country-card">
1221
+ <h3>📝 待补资料任务按国家分布</h3>
1222
+ <div class="pending-country-grid" id="userUpdateCountryGrid">
1223
+ <span style="color:#666;font-size:12px">加载中...</span>
1224
+ </div>
1225
+ </div>
1226
+ <div class="pending-country-card">
1227
+ <h3>🔁 Attach 未成功(状态 1)</h3>
1228
+ <div class="pending-country-grid" id="userUpdateAttachStuckGrid">
1229
+ <span style="color:#666;font-size:12px">加载中...</span>
1230
+ </div>
1231
+ </div>
1232
+ </div>
1233
+ <div id="rawPage">
1234
+ <div class="stats" style="margin-bottom:16px">
1235
+ <div class="stat-card">
1236
+ <div class="label">毛料库</div>
1237
+ <div class="value target" id="rawPageStatRawJobs">0</div>
1238
+ </div>
1239
+ <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(245,158,11,0.12)">
1240
+ <div class="label">← 返回主页面</div>
1241
+ </div>
1242
+ <div class="stat-card clickable pending-card" onclick="navigateToPending()">
1243
+ <div class="label">待处理</div>
1244
+ <div class="value pending" id="rawPageStatPending">0</div>
1245
+ </div>
1246
+ <div class="stat-card clickable pending-card" onclick="navigateToUserUpdate()">
1247
+ <div class="label">待补资料</div>
1248
+ <div class="value target" id="rawPageStatUserUpdateTasks">0</div>
1249
+ </div>
1250
+ </div>
1251
+ <div class="raw-page-layout">
1252
+ <div class="raw-side-card">
1253
+ <h3>🧺 毛料库国家分布</h3>
1254
+ <div class="pending-country-grid" id="rawCountryGrid">
1255
+ <span style="color:#666;font-size:12px">加载中...</span>
1256
+ </div>
1257
+ <div class="muted-tip">点击国家卡片可筛选列表;右上角恢复按钮会把该国家任务恢复回 jobs 队列。</div>
1258
+ </div>
1259
+ <div class="raw-table-card">
1260
+ <h3>毛料库列表</h3>
1261
+ <div class="controls">
1262
+ <input type="text" id="rawSearchInput" placeholder="搜索毛料库用户名 / 昵称...">
1263
+ <select id="rawLocationFilter" onchange="onRawLocationChange()"
1264
+ style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
1265
+ <option value="">全部国家</option>
1266
+ </select>
1267
+ <button onclick="clearRawFilters()">清空筛选</button>
1268
+ <button onclick="restoreFilteredRawJobs()"
1269
+ style="background:#22c55e;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;">恢复当前筛选到
1270
+ jobs</button>
1271
+ </div>
1272
+ <div class="table-scroll">
1273
+ <table>
1274
+ <thead>
1275
+ <tr>
1276
+ <th>用户名</th>
1277
+ <th>昵称</th>
1278
+ <th>粉丝</th>
1279
+ <th>视频</th>
1280
+ <th>国家</th>
1281
+ <th>猜测国家</th>
1282
+ <th>来源</th>
1283
+ <th>状态</th>
1284
+ <th>创建时间</th>
1285
+ <th>操作</th>
1286
+ </tr>
1287
+ </thead>
1288
+ <tbody id="rawTable"></tbody>
1289
+ </table>
1290
+ </div>
1291
+ </div>
1292
+ </div>
1293
+ </div>
1294
+ <script>
1295
+ const COLORS = ['#fe2c55', '#60a5fa', '#4ade80', '#facc15', '#f97316', '#a855f7', '#ec4899', '#14b8a6', '#e11d48', '#0ea5e9', '#8b5cf6', '#84cc16'];
1296
+ let currentFilter = 'all';
1297
+ let currentStats = null;
1298
+ let currentUsers = [];
1299
+ let currentLocation = '';
1300
+ let currentTargetLocation = '';
1301
+ let currentRawLocation = '';
1302
+ let prevStatValues = {};
1303
+ let prevUserMap = {};
1304
+
1305
+ async function fetchStats() {
1306
+ try {
1307
+ const res = await fetch('/api/stats');
1308
+ currentStats = await res.json();
1309
+ renderStats();
1310
+ renderLocationFilter();
1311
+ } catch (e) {
1312
+ document.getElementById('lastUpdate').textContent = '\u8fde\u63a5\u5931\u8d25';
1313
+ }
1314
+ }
1315
+
1316
+ async function fetchUsers() {
1317
+ try {
1318
+ const params = new URLSearchParams();
1319
+ if (currentFilter === 'target') {
1320
+ params.set('target', '1');
1321
+ if (currentTargetLocation) params.set('targetLocation', currentTargetLocation);
1322
+ } else if (currentFilter !== 'all') {
1323
+ params.set('status', currentFilter);
1324
+ }
1325
+ const search = document.getElementById('searchInput').value.trim();
1326
+ if (search) params.set('search', search);
1327
+ if (currentLocation) params.set('location', currentLocation);
1328
+ params.set('limit', '200');
1329
+ const res = await fetch('/api/users?' + params.toString());
1330
+ const data = await res.json();
1331
+ currentUsers = data.users || [];
1332
+ renderTable(currentUsers);
1333
+ } catch (e) { }
1334
+ }
1335
+
1336
+ function escapeHtml(str) {
1337
+ const div = document.createElement('div');
1338
+ div.textContent = str;
1339
+ return div.innerHTML;
1340
+ }
1341
+
1342
+ async function fetchClientErrors() {
1343
+ try {
1344
+ const res = await fetch('/api/client-errors');
1345
+ const data = await res.json();
1346
+ const clients = data.clients || [];
1347
+ const section = document.getElementById('clientErrorsSection');
1348
+ const badge = document.getElementById('clientErrorsBadge');
1349
+ const tbody = document.getElementById('clientErrorsBody');
1350
+ if (clients.length === 0) {
1351
+ section.style.display = 'none';
1352
+ return;
1353
+ }
1354
+ section.style.display = '';
1355
+ badge.textContent = clients.length;
1356
+ const typeMap = { captcha: ['验证码', 'error-type-captcha'], network: ['网络', 'error-type-network'], other: ['其他', 'error-type-other'], '被封': ['被封', 'error-type-captcha'] };
1357
+ const stageMap = { 'video-page': '视频页', 'comment': '评论', 'follow': '关注/粉丝', 'scrape': 'scrape', 'process': '处理' };
1358
+ tbody.innerHTML = clients.map(c => {
1359
+ const [typeText, typeClass] = typeMap[c.errorType] || ['未知', ''];
1360
+ const stageText = c.stage ? (stageMap[c.stage] || c.stage) : '';
1361
+ const captchaText = c.captchaCount ? `${c.captchaCount}次${c.captchaStage ? ' (' + (stageMap[c.captchaStage] || c.captchaStage) + ')' : ''}` : '-';
1362
+ const msgDetail = c.errorMessage ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.errorMessage)}</span>` : '';
1363
+ const stackDetail = c.errorStack ? `<br><span style="color:#555;font-size:10px;max-width:250px;display:block;word-break:break-all">${escapeHtml(c.errorStack)}</span>` : '';
1364
+ const captchaDetail = c.captchaMessage ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.captchaMessage)}</span>` : '';
1365
+ return `<tr>
1366
+ <td style="font-family:monospace;font-weight:600;color:#60a5fa">${escapeHtml(c.userId)}</td>
1367
+ <td class="${typeClass}">${typeText}</td>
1368
+ <td style="color:#f87171;font-weight:600">${c.reportCount || 1}</td>
1369
+ <td style="color:#f59e0b;font-weight:600">${captchaText}${captchaDetail}</td>
1370
+ <td style="color:#a78bfa;font-size:12px">${stageText}</td>
1371
+ <td style="color:#ccc;font-size:12px;max-width:300px;word-break:break-all">${msgDetail}${stackDetail}</td>
1372
+ <td style="color:#60a5fa">@${escapeHtml(c.username || '-')}</td>
1373
+ <td style="color:#888;font-size:12px">${new Date(c.timestamp).toLocaleTimeString()}</td>
1374
+ <td><button class="btn-delete" onclick="deleteClientError('${escapeHtml(c.userId)}')" style="background:#991b1b;color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px">删除</button></td>
1375
+ </tr>`;
1376
+ }).join('');
1377
+ } catch (e) { }
1378
+ }
1379
+
1380
+ async function deleteClientError(userId) {
1381
+ try {
1382
+ await fetch(`/api/client-error/${encodeURIComponent(userId)}`, { method: 'DELETE' });
1383
+ fetchClientErrors();
1384
+ } catch (e) { }
1385
+ }
1386
+
1387
+ function formatStatNum(value) {
1388
+ const num = Number(value) || 0;
1389
+ if (Math.abs(num) < 1000) return String(num);
1390
+ if (Math.abs(num) < 10000) return num.toLocaleString('zh-CN');
1391
+ const wan = num / 10000;
1392
+ return wan.toFixed(1).replace(/\.0+$/, '') + '万';
1393
+ }
1394
+
1395
+ function flashEl(id, value) {
1396
+ const el = document.getElementById(id);
1397
+ if (!el) return;
1398
+ const prev = prevStatValues[id];
1399
+ el.textContent = formatStatNum(value);
1400
+ if (prev !== undefined && prev !== value) {
1401
+ el.classList.remove('flash-change');
1402
+ void el.offsetWidth;
1403
+ el.classList.add('flash-change');
1404
+ }
1405
+ prevStatValues[id] = value;
1406
+ }
1407
+
1408
+ function renderStats() {
1409
+ if (!currentStats) return;
1410
+ const d = currentStats;
1411
+ flashEl('statTotal', d.totalUsers);
1412
+ flashEl('statProcessing', d.processingUsers || 0);
1413
+ flashEl('statDone', d.processedUsers);
1414
+ flashEl('statPending', d.pendingUsers);
1415
+ flashEl('statError', d.errorUsers);
1416
+ flashEl('statRestricted', d.restrictedUsers);
1417
+ flashEl('statTarget', d.targetUsers);
1418
+ flashEl('statUserUpdateTasks', d.userUpdateTasks || 0);
1419
+ flashEl('statRawJobs', d.rawJobs || 0);
1420
+ // 同步子页面 stats
1421
+ const pendingTotal = document.getElementById('pendingStatTotal');
1422
+ if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
1423
+ const pendingCount = document.getElementById('pendingStatPending');
1424
+ if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
1425
+ const pendingUserUpdate = document.getElementById('pendingStatUserUpdateTasks');
1426
+ if (pendingUserUpdate) pendingUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1427
+ const pendingRawJobs = document.getElementById('pendingStatRawJobs');
1428
+ if (pendingRawJobs) pendingRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1429
+ const userUpdateTotal = document.getElementById('userUpdateStatTotal');
1430
+ if (userUpdateTotal) userUpdateTotal.textContent = formatStatNum(d.totalUsers);
1431
+ const userUpdatePending = document.getElementById('userUpdateStatPending');
1432
+ if (userUpdatePending) userUpdatePending.textContent = formatStatNum(d.pendingUsers);
1433
+ const userUpdateTasks = document.getElementById('userUpdateStatUserUpdateTasks');
1434
+ if (userUpdateTasks) userUpdateTasks.textContent = formatStatNum(d.userUpdateTasks || 0);
1435
+ const userUpdateRawJobs = document.getElementById('userUpdateStatRawJobs');
1436
+ if (userUpdateRawJobs) userUpdateRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1437
+ const rawPageRawJobs = document.getElementById('rawPageStatRawJobs');
1438
+ if (rawPageRawJobs) rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1439
+ const rawPagePending = document.getElementById('rawPageStatPending');
1440
+ if (rawPagePending) rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
1441
+ const rawPageUserUpdate = document.getElementById('rawPageStatUserUpdateTasks');
1442
+ if (rawPageUserUpdate) rawPageUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1443
+ document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
1444
+ document.getElementById('fileMeta').textContent =
1445
+ formatStatNum(d.processingUsers || 0) +
1446
+ ' \u5904\u7406\u4e2d, ' +
1447
+ formatStatNum(d.totalUsers) +
1448
+ ' \u4e2a\u7528\u6237, \u5f85\u5904\u7406 ' +
1449
+ formatStatNum(d.pendingUsers || 0);
1450
+
1451
+ renderCountryChart(d.countryStats);
1452
+ renderSourceChart(d.sourceStats);
1453
+ renderTargetLocationFilter(d.targetCountryStats);
1454
+ }
1455
+
1456
+ function renderTargetLocationFilter(targetCountryStats) {
1457
+ const sel = document.getElementById('targetLocationFilter');
1458
+ if (!sel) return;
1459
+ const val = sel.value;
1460
+ sel.innerHTML = '<option value="">全部目标国家</option>' +
1461
+ (targetCountryStats || []).map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
1462
+ }
1463
+
1464
+ function renderCountryChart(countries) {
1465
+ const el = document.getElementById('countryChart');
1466
+ const filtered = countries.filter(c => c.country !== '\u672a\u77e5');
1467
+ if (!filtered.length) { el.innerHTML = '<span style="color:#666;font-size:12px">\u6682\u65e0\u6570\u636e</span>'; return; }
1468
+ const max = filtered[0].count;
1469
+ const top = filtered.slice(0, 15);
1470
+ const targetLocations = currentStats?.targetLocations || [];
1471
+ el.innerHTML = top.map((c, i) => {
1472
+ const isTarget = targetLocations.includes(c.country);
1473
+ const targetBadge = c.targetCount > 0
1474
+ ? `<span class="target-badge">🎯 ${c.targetCount}</span>`
1475
+ : `<span class="target-badge" style="visibility:hidden"> </span>`;
1476
+ return `
1477
+ <div class="bar-row${isTarget ? ' is-target' : ''}">
1478
+ <span class="name">${c.country}</span>
1479
+ <div class="bar-bg"><div class="bar-fill" style="width:${(c.count / max * 100)}%;background:${isTarget ? '#a78bfa' : COLORS[i % COLORS.length]}">${c.count}</div></div>
1480
+ ${targetBadge}
1481
+ <span class="count">${(currentStats ? (c.count / currentStats.totalUsers * 100).toFixed(1) : 0)}%</span>
1482
+ </div>
1483
+ `;
1484
+ }).join('');
1485
+ }
1486
+
1487
+ function renderSourceChart(sources) {
1488
+ const el = document.getElementById('sourceChart');
1489
+ const labels = { seed: '\u79cd\u5b50', video: '\u89c6\u9891\u53d1\u73b0', comment: '\u8bc4\u8bba\u53d1\u73b0', guess: '\u731c\u4f60\u559c\u6b22', following: '\u5173\u6ce8', follower: '\u7c89\u4e1d', processed: '\u5df2\u5904\u7406', restricted: '\u53d7\u9650(\u8df3\u8fc7)', error: '\u9519\u8bef(\u5f85\u91cd\u8bd5)', noVideo: '\u65e0\u89c6\u9891' };
1490
+ const entries = Object.entries(sources);
1491
+ el.innerHTML = entries.map(([key, val]) => `
1492
+ <div class="source-row"><span class="s-name">${labels[key] || key}:</span><span class="s-val">${val}</span></div>
1493
+ `).join('');
1494
+ }
1495
+
1496
+ function renderTable(users) {
1497
+ const el = document.getElementById('userTable');
1498
+
1499
+ const newUserMap = {};
1500
+ for (const u of users) newUserMap[u.uniqueId] = u;
1501
+
1502
+ el.innerHTML = users.map(u => {
1503
+ const wasStatus = prevUserMap[u.uniqueId]?.status;
1504
+ const nowStatus = u.status;
1505
+ const changed = wasStatus !== nowStatus &&
1506
+ (nowStatus === 'done' || nowStatus === 'restricted' || nowStatus === 'error');
1507
+ const rowClass = changed ? ' class="row-flash"' : '';
1508
+
1509
+ const statusTags = {
1510
+ restricted: '<span class="tag error">\u53d7\u9650(\u8df3\u8fc7)</span>',
1511
+ error: '<span class="tag error">\u9519\u8bef(\u5f85\u91cd\u8bd5)</span>',
1512
+ done: '<span class="tag processed">\u5df2\u5b8c\u6210</span>',
1513
+ processing: '<span class="tag processing">\u5904\u7406\u4e2d</span>',
1514
+ pending: '<span class="tag pending">\u5f85\u5904\u7406</span>',
1515
+ };
1516
+ const statusTag = statusTags[u.status] || '<span class="tag pending">' + (u.status || '\u672a\u77e5') + '</span>';
1517
+ const sources = (u.sources || []).join(', ');
1518
+ const extraTags = [];
1519
+ if (u.pinned) extraTags.push('<span class="tag pinned">&#x1F4CC; 置顶</span>');
1520
+ if (u.ttSeller) extraTags.push('<span class="tag seller">\u5546\u5bb6</span>');
1521
+ if (u.verified) extraTags.push('<span class="tag verified">\u8ba4\u8bc1</span>');
1522
+ if (u.noVideo) extraTags.push('<span class="tag no-video">\u65e0\u89c6\u9891</span>');
1523
+ if (u.keepFollow) extraTags.push('<span class="tag keep-follow">\u5173\u6ce8\u5df2\u4fdd\u7559</span>');
1524
+ if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow">\u5173\u6ce8\u672a\u83b7\u53d6</span>');
1525
+ const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1526
+ const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
1527
+ const videos = u.videoCount != null ? u.videoCount : '-';
1528
+ const loc = u.locationCreated || '-';
1529
+ const guessedLoc = u.guessedLocation || '-';
1530
+ const claimer = u.claimedBy || '-';
1531
+ const claimTime = u.claimedAt ? formatTime(u.claimedAt) : '-';
1532
+ const procTime = u.processedAt ? formatTime(u.processedAt) : '-';
1533
+ return `<tr${rowClass}>
1534
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
1535
+ <td data-label="昵称">${nick}</td>
1536
+ <td data-label="粉丝">${fans}</td>
1537
+ <td data-label="视频">${videos}</td>
1538
+ <td data-label="国家">${loc}</td>
1539
+ <td data-label="猜测国家">${guessedLoc}</td>
1540
+ <td data-label="来源">${sources || '-'}</td>
1541
+ <td data-label="状态">${statusTag} ${extraTags.join(' ')}</td>
1542
+ <td data-label="处理端" style="font-size:11px;color:#888">${claimer}</td>
1543
+ <td data-label="领取时间" style="font-size:11px;color:#888">${claimTime}</td>
1544
+ <td data-label="完成时间" style="font-size:11px;color:#888">${procTime}</td>
1545
+ </tr>`;
1546
+ }).join('');
1547
+
1548
+ const errorCount = users.filter(u => u.status === 'error').length;
1549
+ const countEl = document.getElementById('batchResetCount');
1550
+ if (countEl) countEl.textContent = errorCount;
1551
+
1552
+ prevUserMap = newUserMap;
1553
+ }
1554
+
1555
+ function formatNum(n) {
1556
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1557
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1558
+ return n;
1559
+ }
1560
+
1561
+ function formatTime(ts) {
1562
+ const d = new Date(ts);
1563
+ const pad = n => String(n).padStart(2, '0');
1564
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1565
+ }
1566
+
1567
+ function setFilter(f) {
1568
+ currentFilter = f;
1569
+ document.querySelectorAll('.controls button').forEach(b => {
1570
+ b.classList.toggle('active', b.dataset.filter === f);
1571
+ });
1572
+ const btn = document.getElementById('batchResetBtn');
1573
+ btn.style.display = f === 'error' ? '' : 'none';
1574
+ const targetLocSel = document.getElementById('targetLocationFilter');
1575
+ if (f === 'target') {
1576
+ targetLocSel.style.display = '';
1577
+ } else {
1578
+ targetLocSel.style.display = 'none';
1579
+ currentTargetLocation = '';
1580
+ }
1581
+ fetchUsers();
1582
+ }
1583
+
1584
+ function renderLocationFilter() {
1585
+ if (!currentStats || !currentStats.countryStats) return;
1586
+ const sel = document.getElementById('locationFilter');
1587
+ if (!sel) return;
1588
+ const val = sel.value;
1589
+ const entries = currentStats.countryStats.sort((a, b) => b.count - a.count);
1590
+ sel.innerHTML = '<option value="">全部国家</option>' +
1591
+ entries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
1592
+ }
1593
+
1594
+ function onLocationChange() {
1595
+ const sel = document.getElementById('locationFilter');
1596
+ currentLocation = sel.value;
1597
+ fetchUsers();
1598
+ }
1599
+
1600
+ function onTargetLocationChange() {
1601
+ const sel = document.getElementById('targetLocationFilter');
1602
+ currentTargetLocation = sel.value;
1603
+ fetchUsers();
1604
+ }
1605
+
1606
+ let searchTimer = null;
1607
+ document.getElementById('searchInput').addEventListener('input', () => {
1608
+ if (searchTimer) clearTimeout(searchTimer);
1609
+ searchTimer = setTimeout(fetchUsers, 300);
1610
+ });
1611
+
1612
+ let rawSearchTimer = null;
1613
+ document.getElementById('rawSearchInput').addEventListener('input', () => {
1614
+ if (rawSearchTimer) clearTimeout(rawSearchTimer);
1615
+ rawSearchTimer = setTimeout(fetchRawJobs, 300);
1616
+ });
1617
+
1618
+ function parseUsernames(raw) {
1619
+ return raw.split(/[,,\n\r]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
1620
+ }
1621
+
1622
+ function openAddModal() {
1623
+ let overlay = document.getElementById('addModalOverlay');
1624
+ if (overlay) return;
1625
+ overlay = document.createElement('div');
1626
+ overlay.id = 'addModalOverlay';
1627
+ overlay.className = 'modal-overlay';
1628
+ overlay.innerHTML = `
1629
+ <div class="modal">
1630
+ <h3>插入用户到队列</h3>
1631
+ <div class="hint">每行一个用户名,或用逗号分隔。支持 @username 或 username 格式。插入到队列最前面优先处理。</div>
1632
+ <textarea id="modalUserInput" placeholder="例如:&#10;user1&#10;user2&#10;user3&#10;&#10;或:user1, user2, user3"></textarea>
1633
+ <div class="preview" id="modalPreview"></div>
1634
+ <div class="btn-row">
1635
+ <button class="btn-cancel" onclick="closeAddModal()">取消</button>
1636
+ <button class="btn-submit" onclick="submitAddUsers()">确认插入</button>
1637
+ </div>
1638
+ </div>
1639
+ `;
1640
+ document.body.appendChild(overlay);
1641
+ overlay.addEventListener('click', e => { if (e.target === overlay) closeAddModal(); });
1642
+ const ta = document.getElementById('modalUserInput');
1643
+ ta.focus();
1644
+ ta.addEventListener('input', () => {
1645
+ const names = parseUsernames(ta.value);
1646
+ const preview = document.getElementById('modalPreview');
1647
+ preview.textContent = names.length ? `共 ${names.length} 个用户名` : '';
1648
+ });
1649
+ ta.addEventListener('keydown', e => {
1650
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1651
+ e.preventDefault();
1652
+ submitAddUsers();
1653
+ }
1654
+ });
1655
+ }
1656
+
1657
+ function closeAddModal() {
1658
+ const overlay = document.getElementById('addModalOverlay');
1659
+ if (overlay) overlay.remove();
1660
+ }
1661
+
1662
+ async function submitAddUsers() {
1663
+ const ta = document.getElementById('modalUserInput');
1664
+ const raw = ta.value.trim();
1665
+ if (!raw) return;
1666
+
1667
+ const names = parseUsernames(raw);
1668
+ if (names.length === 0) return;
1669
+
1670
+ try {
1671
+ const res = await fetch('/api/users', {
1672
+ method: 'POST',
1673
+ headers: { 'Content-Type': 'application/json' },
1674
+ body: JSON.stringify({ usernames: names })
1675
+ });
1676
+ const data = await res.json();
1677
+ if (data.error) { showToast(data.error, true); return; }
1678
+ closeAddModal();
1679
+ showToast(data.message || `\u5df2\u63d2\u5165 ${data.added} \u4e2a\u7528\u6237`);
1680
+ fetchStats();
1681
+ fetchUsers();
1682
+ } catch (e) {
1683
+ showToast('\u63d2\u5165\u5931\u8d25: ' + e.message, true);
1684
+ }
1685
+ }
1686
+
1687
+ function showToast(msg, isError) {
1688
+ let toast = document.getElementById('globalToast') || document.getElementById('toast');
1689
+ if (!toast) {
1690
+ toast = document.createElement('div');
1691
+ toast.id = 'globalToast';
1692
+ toast.className = 'toast';
1693
+ toast.style.display = 'none';
1694
+ document.body.appendChild(toast);
1695
+ }
1696
+ toast.textContent = msg;
1697
+ toast.className = 'toast' + (isError ? ' error' : '');
1698
+ toast.style.display = 'block';
1699
+ setTimeout(() => { toast.style.display = 'none'; }, 3000);
1700
+ }
1701
+
1702
+ function escapeJsString(str) {
1703
+ return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1704
+ }
1705
+
1706
+ document.addEventListener('keydown', e => {
1707
+ if (e.key === 'Escape') closeAddModal();
1708
+ });
1709
+
1710
+ document.getElementById('userTable').addEventListener('click', e => {
1711
+ const td = e.target.closest('td.user-id');
1712
+ if (!td) return;
1713
+ hideContextMenu();
1714
+ const username = td.textContent.trim().replace(/^@/, '');
1715
+ if (!username) return;
1716
+ window.open('https://www.tiktok.com/@' + username, '_blank');
1717
+ });
1718
+
1719
+ let contextMenuEl = null;
1720
+ let contextMenuUserId = null;
1721
+ let contextMenuPinned = false;
1722
+
1723
+ function showContextMenu(x, y, uniqueId, pinned) {
1724
+ hideContextMenu();
1725
+ contextMenuUserId = uniqueId;
1726
+ contextMenuPinned = !!pinned;
1727
+ contextMenuEl = document.createElement('div');
1728
+ contextMenuEl.className = 'context-menu';
1729
+ contextMenuEl.innerHTML = `
1730
+ <div class="context-menu-item" data-action="pin">${contextMenuPinned ? '&#x1F4CC; 取消置顶' : '&#x1F4CD; 置顶优先'}</div>
1731
+ <div class="context-menu-item" data-action="reset">&#x21bb; 重新处理</div>
1732
+ <div class="context-menu-item" data-action="open">&#x1F517; 打开主页</div>
1733
+ `;
1734
+ document.body.appendChild(contextMenuEl);
1735
+ contextMenuEl.style.left = Math.min(x, window.innerWidth - 160) + 'px';
1736
+ contextMenuEl.style.top = Math.min(y, window.innerHeight - 100) + 'px';
1737
+
1738
+ contextMenuEl.addEventListener('click', e => {
1739
+ const item = e.target.closest('.context-menu-item');
1740
+ if (!item) return;
1741
+ const action = item.dataset.action;
1742
+ if (action === 'pin') togglePin(contextMenuUserId);
1743
+ if (action === 'reset') resetJob(contextMenuUserId);
1744
+ if (action === 'open') window.open('https://www.tiktok.com/@' + contextMenuUserId, '_blank');
1745
+ hideContextMenu();
1746
+ });
1747
+ }
1748
+
1749
+ function hideContextMenu() {
1750
+ if (contextMenuEl) {
1751
+ contextMenuEl.remove();
1752
+ contextMenuEl = null;
1753
+ contextMenuUserId = null;
1754
+ }
1755
+ }
1756
+
1757
+ document.getElementById('userTable').addEventListener('contextmenu', e => {
1758
+ const td = e.target.closest('td');
1759
+ if (!td || td.parentElement.tagName !== 'TR') return;
1760
+ e.preventDefault();
1761
+ const tr = td.parentElement;
1762
+ const userIdTd = tr.querySelector('td.user-id');
1763
+ if (!userIdTd) return;
1764
+ const uniqueId = userIdTd.textContent.trim().replace(/^@/, '');
1765
+ const pinned = !!tr.querySelector('.tag.pinned');
1766
+ showContextMenu(e.clientX, e.clientY, uniqueId, pinned);
1767
+ });
1768
+
1769
+ document.addEventListener('click', e => {
1770
+ if (contextMenuEl && !contextMenuEl.contains(e.target)) {
1771
+ hideContextMenu();
1772
+ }
1773
+ });
1774
+
1775
+ async function togglePin(uniqueId) {
1776
+ try {
1777
+ const res = await fetch('/api/job/' + encodeURIComponent(uniqueId) + '/pin', {
1778
+ method: 'POST',
1779
+ });
1780
+ const data = await res.json();
1781
+ if (data.saved) {
1782
+ showToast(data.pinned ? '已置顶' : '已取消置顶');
1783
+ fetchUsers();
1784
+ } else {
1785
+ showToast(data.error || '操作失败', true);
1786
+ }
1787
+ } catch (e) {
1788
+ showToast('操作失败: ' + e.message, true);
1789
+ }
1790
+ }
1791
+
1792
+ async function resetJob(uniqueId) {
1793
+ try {
1794
+ const res = await fetch('/api/job/' + encodeURIComponent(uniqueId) + '/reset', {
1795
+ method: 'POST',
1796
+ });
1797
+ const data = await res.json();
1798
+ if (data.saved) {
1799
+ showToast('\u5df2\u91cd\u7f6e\u4efb\u52a1');
1800
+ fetchUsers();
1801
+ fetchStats();
1802
+ } else {
1803
+ showToast(data.error || '\u91cd\u7f6e\u5931\u8d25', true);
1804
+ }
1805
+ } catch (e) {
1806
+ showToast('\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
1807
+ }
1808
+ }
1809
+
1810
+ async function batchResetErrors() {
1811
+ const btn = document.getElementById('batchResetBtn');
1812
+ const errorUsers = currentUsers.filter(u => u.status === 'error');
1813
+ if (errorUsers.length === 0) {
1814
+ showToast('\u6ca1\u6709\u9700\u8981\u91cd\u7f6e\u7684\u9519\u8bef\u7528\u6237', true);
1815
+ return;
1816
+ }
1817
+ const userIds = errorUsers.map(u => u.uniqueId);
1818
+ const origText = btn.innerHTML;
1819
+ btn.disabled = true;
1820
+ btn.style.opacity = '0.6';
1821
+ btn.style.cursor = 'not-allowed';
1822
+ btn.innerHTML = '&#x21bb; 处理中...';
1823
+ try {
1824
+ const res = await fetch('/api/jobs/batch-reset', {
1825
+ method: 'POST',
1826
+ headers: { 'Content-Type': 'application/json' },
1827
+ body: JSON.stringify({ userIds })
1828
+ });
1829
+ const data = await res.json();
1830
+ if (data.error) {
1831
+ showToast(data.error, true);
1832
+ return;
1833
+ }
1834
+ showToast(`\u5df2\u91cd\u7f6e ${data.reset} / ${data.total} \u4e2a\u7528\u6237`);
1835
+ fetchUsers();
1836
+ fetchStats();
1837
+ } catch (e) {
1838
+ showToast('\u6279\u91cf\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
1839
+ } finally {
1840
+ btn.disabled = false;
1841
+ btn.style.opacity = '1';
1842
+ btn.style.cursor = 'pointer';
1843
+ btn.innerHTML = origText;
1844
+ }
1845
+ }
1846
+
1847
+ document.getElementById('statTargetCard').addEventListener('click', async () => {
1848
+ try {
1849
+ const res = await fetch('/api/target-users', {
1850
+ headers: { 'Accept': 'text/csv' },
1851
+ });
1852
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1853
+ const blob = await res.blob();
1854
+ const ext = blob.size < 200 ? 'json' : 'csv';
1855
+ if (ext === 'json') {
1856
+ const text = await blob.text();
1857
+ const data = JSON.parse(text);
1858
+ if (!data.users.length) { showToast('暂无目标用户', true); return; }
1859
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1860
+ const ids = data.users.map(u => '@' + u.uniqueId).join(', ');
1861
+ await navigator.clipboard.writeText(ids);
1862
+ showToast(data.users.length + ' 个目标用户 ID 已复制到剪贴板');
1863
+ }
1864
+ return;
1865
+ }
1866
+ const url = URL.createObjectURL(blob);
1867
+ const a = document.createElement('a');
1868
+ a.href = url;
1869
+ a.download = 'target-users.csv';
1870
+ a.click();
1871
+ URL.revokeObjectURL(url);
1872
+ showToast('CSV 文件已开始下载');
1873
+ } catch (e) {
1874
+ showToast('获取失败: ' + e.message, true);
1875
+ }
1876
+ });
1877
+
1878
+ fetchStats();
1879
+ fetchUsers();
1880
+ fetchClientErrors();
1881
+ setInterval(fetchStats, 10000);
1882
+ setInterval(fetchUsers, 10000);
1883
+ setInterval(fetchClientErrors, 10000);
1884
+ setInterval(refreshUserUpdateIfActive, 10000);
1885
+
1886
+ // Hash 路由
1887
+ window.addEventListener('hashchange', handleRoute);
1888
+ window.addEventListener('DOMContentLoaded', handleRoute);
1889
+
1890
+ function handleRoute() {
1891
+ const hash = window.location.hash;
1892
+ if (hash === '#pending') {
1893
+ showPendingPage();
1894
+ } else if (hash === '#userUpdate') {
1895
+ showUserUpdatePage();
1896
+ } else if (hash === '#raw') {
1897
+ showRawPage();
1898
+ } else {
1899
+ showMainPage();
1900
+ }
1901
+ }
1902
+
1903
+ function navigateToPending() {
1904
+ window.location.hash = '#pending';
1905
+ }
1906
+
1907
+ function navigateToUserUpdate() {
1908
+ window.location.hash = '#userUpdate';
1909
+ }
1910
+
1911
+ function navigateToRaw() {
1912
+ window.location.hash = '#raw';
1913
+ }
1914
+
1915
+ function navigateToMain() {
1916
+ window.location.hash = '';
1917
+ }
1918
+
1919
+ function showPendingPage() {
1920
+ document.getElementById('mainPage').classList.add('hidden');
1921
+ document.getElementById('pendingPage').classList.add('active');
1922
+ document.getElementById('userUpdatePage').classList.remove('active');
1923
+ document.getElementById('rawPage').classList.remove('active');
1924
+ fetchPendingByCountry();
1925
+ fetchAttachStuckByCountry();
1926
+ }
1927
+
1928
+ function showUserUpdatePage() {
1929
+ document.getElementById('mainPage').classList.add('hidden');
1930
+ document.getElementById('pendingPage').classList.remove('active');
1931
+ document.getElementById('userUpdatePage').classList.add('active');
1932
+ document.getElementById('rawPage').classList.remove('active');
1933
+ fetchUserUpdateByCountry();
1934
+ fetchAttachStuckByCountry();
1935
+ }
1936
+
1937
+ function refreshUserUpdateIfActive() {
1938
+ if (document.getElementById('userUpdatePage').classList.contains('active')) {
1939
+ fetchUserUpdateByCountry();
1940
+ fetchAttachStuckByCountry();
1941
+ }
1942
+ }
1943
+
1944
+ function showRawPage() {
1945
+ document.getElementById('mainPage').classList.add('hidden');
1946
+ document.getElementById('pendingPage').classList.remove('active');
1947
+ document.getElementById('userUpdatePage').classList.remove('active');
1948
+ document.getElementById('rawPage').classList.add('active');
1949
+ fetchRawByCountry();
1950
+ fetchRawJobs();
1951
+ }
1952
+
1953
+ function showMainPage() {
1954
+ document.getElementById('mainPage').classList.remove('hidden');
1955
+ document.getElementById('pendingPage').classList.remove('active');
1956
+ document.getElementById('userUpdatePage').classList.remove('active');
1957
+ document.getElementById('rawPage').classList.remove('active');
1958
+ }
1959
+
1960
+ async function fetchPendingByCountry() {
1961
+ try {
1962
+ const res = await fetch('/api/pending-by-country');
1963
+ const data = await res.json();
1964
+ renderPendingCountryGrid(data.countries || []);
1965
+ } catch (e) {
1966
+ console.error('获取待处理国家分布失败:', e);
1967
+ }
1968
+ }
1969
+
1970
+ function renderPendingCountryGrid(countries) {
1971
+ const grid = document.getElementById('pendingCountryGrid');
1972
+ if (!countries.length) {
1973
+ grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待处理任务</span>';
1974
+ return;
1975
+ }
1976
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
1977
+ grid.innerHTML = countries.map(c => {
1978
+ const pct = ((c.count / total) * 100).toFixed(1);
1979
+ const isUnknown = c.country === '未知';
1980
+ const safeCountry = escapeJsString(c.country);
1981
+ return `
1982
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
1983
+ onclick="filterByPendingCountry('${safeCountry}')">
1984
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1985
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
1986
+ <div class="country-count">${c.count}</div>
1987
+ <div class="country-label">${pct}% 待处理</div>
1988
+ </div>
1989
+ `;
1990
+ }).join('');
1991
+ }
1992
+
1993
+ function filterByPendingCountry(country) {
1994
+ // 返回主页面并设置国家筛选
1995
+ window.location.hash = '';
1996
+ setTimeout(() => {
1997
+ const searchInput = document.getElementById('searchInput');
1998
+ const locationFilter = document.getElementById('locationFilter');
1999
+ // 设置状态筛选为 pending
2000
+ setFilter('pending');
2001
+ // 如果有国家筛选下拉框,设置国家
2002
+ if (locationFilter && country && country !== '未知') {
2003
+ // 检查选项中是否有该国家
2004
+ const options = locationFilter.options;
2005
+ for (let i = 0; i < options.length; i++) {
2006
+ if (options[i].value === country) {
2007
+ locationFilter.value = country;
2008
+ onLocationChange();
2009
+ break;
2010
+ }
2011
+ }
2012
+ }
2013
+ }, 100);
2014
+ }
2015
+
2016
+ async function fetchUserUpdateByCountry() {
2017
+ try {
2018
+ const res = await fetch('/api/user-update-by-country');
2019
+ const data = await res.json();
2020
+ renderUserUpdateCountryGrid(data.countries || []);
2021
+ } catch (e) {
2022
+ console.error('获取待补资料国家分布失败:', e);
2023
+ }
2024
+ }
2025
+
2026
+ function renderUserUpdateCountryGrid(countries) {
2027
+ const grid = document.getElementById('userUpdateCountryGrid');
2028
+ if (!countries.length) {
2029
+ grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待补资料任务</span>';
2030
+ return;
2031
+ }
2032
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
2033
+ grid.innerHTML = countries.map(c => {
2034
+ const pct = ((c.count / total) * 100).toFixed(1);
2035
+ const isUnknown = c.country === '未知';
2036
+ const safeCountry = escapeJsString(c.country);
2037
+ return `
2038
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2039
+ onclick="filterByUserUpdateCountry('${safeCountry}')">
2040
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
2041
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2042
+ <div class="country-count">${c.count}</div>
2043
+ <div class="country-label">${pct}% 待补资料</div>
2044
+ </div>
2045
+ `;
2046
+ }).join('');
2047
+ }
2048
+
2049
+ async function fetchAttachStuckByCountry() {
2050
+ try {
2051
+ const res = await fetch('/api/attach-stuck-by-country');
2052
+ const data = await res.json();
2053
+ renderAttachStuckGrid('pendingAttachStuckGrid', data.countries || []);
2054
+ renderAttachStuckGrid('userUpdateAttachStuckGrid', data.countries || []);
2055
+ } catch (e) {
2056
+ console.error('获取 attach 未成功国家分布失败:', e);
2057
+ }
2058
+ }
2059
+
2060
+ function renderAttachStuckGrid(gridId, countries) {
2061
+ const grid = document.getElementById(gridId);
2062
+ if (!grid) return;
2063
+ if (!countries.length) {
2064
+ grid.innerHTML = '<span style="color:#666;font-size:12px">暂无 attach 未成功任务</span>';
2065
+ return;
2066
+ }
2067
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
2068
+ grid.innerHTML = countries.map(c => {
2069
+ const pct = ((c.count / total) * 100).toFixed(1);
2070
+ const isUnknown = c.country === '未知';
2071
+ const safeCountry = escapeJsString(c.country);
2072
+ return `
2073
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}">
2074
+ <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
2075
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2076
+ <div class="country-count">${c.count}</div>
2077
+ <div class="country-label">${pct}% attach 未成功</div>
2078
+ </div>
2079
+ `;
2080
+ }).join('');
2081
+ }
2082
+
2083
+ async function restoreAttachStuckByCountry(country, count) {
2084
+ const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2085
+ if (!window.confirm(`确认将 ${country} 下 attach 未成功的任务恢复为待补资料吗?${countText}`)) {
2086
+ return;
2087
+ }
2088
+ try {
2089
+ const res = await fetch('/api/attach-stuck/restore', {
2090
+ method: 'POST',
2091
+ headers: { 'Content-Type': 'application/json' },
2092
+ body: JSON.stringify({ country })
2093
+ });
2094
+ const data = await res.json();
2095
+ if (!res.ok || data.error) {
2096
+ showToast(data.error || '恢复 attach 任务失败', true);
2097
+ return;
2098
+ }
2099
+ showToast(`${country} 的 attach 未成功任务已恢复,共 ${data.restored} 条`);
2100
+ await fetchStats();
2101
+ await fetchPendingByCountry();
2102
+ await fetchUserUpdateByCountry();
2103
+ await fetchAttachStuckByCountry();
2104
+ if (!document.getElementById('rawPage').classList.contains('active')) {
2105
+ fetchUsers();
2106
+ }
2107
+ } catch (e) {
2108
+ showToast('恢复 attach 任务失败: ' + e.message, true);
2109
+ }
2110
+ }
2111
+
2112
+ async function moveCountryJobsToRaw(scope, country, count) {
2113
+ const scopeLabel = scope === 'pending' ? '待处理任务' : '待补资料任务';
2114
+ const countText = count != null ? `将移动 ${formatStatNum(count)} 条。` : '';
2115
+ if (!window.confirm(`确认将 ${country} 的${scopeLabel}移到毛料库吗?${countText} 这些任务会先暂存,不再进入当前处理队列。`)) {
2116
+ return;
2117
+ }
2118
+ try {
2119
+ const res = await fetch('/api/jobs/move-to-raw', {
2120
+ method: 'POST',
2121
+ headers: { 'Content-Type': 'application/json' },
2122
+ body: JSON.stringify({ scope, country })
2123
+ });
2124
+ const data = await res.json();
2125
+ if (!res.ok || data.error) {
2126
+ showToast(data.error || '移到毛料库失败', true);
2127
+ return;
2128
+ }
2129
+ showToast(`${country} 已移到毛料库,共 ${data.moved} 条`);
2130
+ await fetchStats();
2131
+ if (scope === 'pending') {
2132
+ await fetchPendingByCountry();
2133
+ } else {
2134
+ await fetchUserUpdateByCountry();
2135
+ }
2136
+ if (!document.getElementById('mainPage').classList.contains('hidden')) {
2137
+ fetchUsers();
2138
+ }
2139
+ } catch (e) {
2140
+ showToast('移到毛料库失败: ' + e.message, true);
2141
+ }
2142
+ }
2143
+
2144
+ async function fetchRawByCountry() {
2145
+ try {
2146
+ const res = await fetch('/api/raw-by-country');
2147
+ const data = await res.json();
2148
+ renderRawCountryGrid(data.countries || []);
2149
+ renderRawLocationFilter(data.countries || []);
2150
+ } catch (e) {
2151
+ console.error('获取毛料库国家分布失败:', e);
2152
+ }
2153
+ }
2154
+
2155
+ function renderRawCountryGrid(countries) {
2156
+ const grid = document.getElementById('rawCountryGrid');
2157
+ if (!countries.length) {
2158
+ grid.innerHTML = '<span style="color:#666;font-size:12px">毛料库暂无数据</span>';
2159
+ return;
2160
+ }
2161
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
2162
+ grid.innerHTML = countries.map(c => {
2163
+ const pct = ((c.count / total) * 100).toFixed(1);
2164
+ const isUnknown = c.country === '未知';
2165
+ const safeCountry = escapeJsString(c.country);
2166
+ return `
2167
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2168
+ onclick="filterRawByCountry('${safeCountry}')">
2169
+ <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
2170
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2171
+ <div class="country-count">${c.count}</div>
2172
+ <div class="country-label">${pct}% 毛料库</div>
2173
+ </div>
2174
+ `;
2175
+ }).join('');
2176
+ }
2177
+
2178
+ async function fetchRawJobs() {
2179
+ try {
2180
+ const params = new URLSearchParams();
2181
+ const search = document.getElementById('rawSearchInput').value.trim();
2182
+ if (search) params.set('search', search);
2183
+ if (currentRawLocation) params.set('location', currentRawLocation);
2184
+ params.set('limit', '200');
2185
+ const res = await fetch('/api/raw-jobs?' + params.toString());
2186
+ const data = await res.json();
2187
+ renderRawJobsTable(data.users || []);
2188
+ } catch (e) {
2189
+ console.error('获取毛料库列表失败:', e);
2190
+ }
2191
+ }
2192
+
2193
+ function renderRawJobsTable(users) {
2194
+ const el = document.getElementById('rawTable');
2195
+ if (!users.length) {
2196
+ el.innerHTML = '<tr><td colspan="10" style="color:#666;text-align:center;padding:24px">暂无毛料库任务</td></tr>';
2197
+ return;
2198
+ }
2199
+ el.innerHTML = users.map(u => {
2200
+ const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2201
+ const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
2202
+ const videos = u.videoCount != null ? u.videoCount : '-';
2203
+ const loc = u.locationCreated || '-';
2204
+ const guessedLoc = u.guessedLocation || '-';
2205
+ const sources = (u.sources || []).join(', ');
2206
+ const created = u.createdAt ? formatTime(u.createdAt) : '-';
2207
+ const statusTag = u.status || '-';
2208
+ return `<tr>
2209
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
2210
+ <td data-label="昵称">${nick}</td>
2211
+ <td data-label="粉丝">${fans}</td>
2212
+ <td data-label="视频">${videos}</td>
2213
+ <td data-label="国家">${loc}</td>
2214
+ <td data-label="猜测国家">${guessedLoc}</td>
2215
+ <td data-label="来源">${sources || '-'}</td>
2216
+ <td data-label="状态">${statusTag}</td>
2217
+ <td data-label="创建时间" style="font-size:11px;color:#888">${created}</td>
2218
+ <td data-label="操作" style="text-align:center"><button onclick="restoreRawJob('${u.uniqueId}')" style="background:#22c55e;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;">恢复</button></td>
2219
+ </tr>`;
2220
+ }).join('');
2221
+ }
2222
+
2223
+ function renderRawLocationFilter(countries) {
2224
+ const sel = document.getElementById('rawLocationFilter');
2225
+ if (!sel) return;
2226
+ const val = currentRawLocation;
2227
+ sel.innerHTML = '<option value="">全部国家</option>' +
2228
+ countries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
2229
+ }
2230
+
2231
+ function filterRawByCountry(country) {
2232
+ currentRawLocation = country;
2233
+ const sel = document.getElementById('rawLocationFilter');
2234
+ if (sel) sel.value = country;
2235
+ fetchRawJobs();
2236
+ }
2237
+
2238
+ function onRawLocationChange() {
2239
+ const sel = document.getElementById('rawLocationFilter');
2240
+ currentRawLocation = sel.value;
2241
+ fetchRawJobs();
2242
+ }
2243
+
2244
+ function clearRawFilters() {
2245
+ currentRawLocation = '';
2246
+ const rawSearchInput = document.getElementById('rawSearchInput');
2247
+ const rawLocationFilter = document.getElementById('rawLocationFilter');
2248
+ if (rawSearchInput) rawSearchInput.value = '';
2249
+ if (rawLocationFilter) rawLocationFilter.value = '';
2250
+ fetchRawJobs();
2251
+ }
2252
+
2253
+ async function restoreRawJob(uniqueId) {
2254
+ if (!window.confirm(`确认将 @${uniqueId} 从毛料库恢复到 jobs 队列吗?`)) {
2255
+ return;
2256
+ }
2257
+ try {
2258
+ const res = await fetch('/api/raw-jobs/restore', {
2259
+ method: 'POST',
2260
+ headers: { 'Content-Type': 'application/json' },
2261
+ body: JSON.stringify({ uniqueId })
2262
+ });
2263
+ const data = await res.json();
2264
+ if (!res.ok || data.error) {
2265
+ showToast(data.error || '恢复失败', true);
2266
+ return;
2267
+ }
2268
+ showToast(`@${uniqueId} 已恢复到队列`);
2269
+ await fetchStats();
2270
+ await fetchRawByCountry();
2271
+ await fetchRawJobs();
2272
+ } catch (e) {
2273
+ showToast('恢复失败: ' + e.message, true);
2274
+ }
2275
+ }
2276
+
2277
+ async function restoreFilteredRawJobs() {
2278
+ const search = document.getElementById('rawSearchInput').value.trim();
2279
+ const location = currentRawLocation;
2280
+ let desc = '当前筛选条件';
2281
+ if (search && location) desc = `搜索="${search}" + 国家=${location}`;
2282
+ else if (search) desc = `搜索="${search}"`;
2283
+ else if (location) desc = `国家=${location}`;
2284
+ if (!window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)) {
2285
+ return;
2286
+ }
2287
+ try {
2288
+ const body = {};
2289
+ if (search) body.search = search;
2290
+ if (location) body.location = location;
2291
+ const res = await fetch('/api/raw-jobs/restore', {
2292
+ method: 'POST',
2293
+ headers: { 'Content-Type': 'application/json' },
2294
+ body: JSON.stringify(body)
2295
+ });
2296
+ const data = await res.json();
2297
+ if (!res.ok || data.error) {
2298
+ showToast(data.error || '恢复失败', true);
2299
+ return;
2300
+ }
2301
+ showToast(`已恢复 ${data.restored} 条任务到队列`);
2302
+ await fetchStats();
2303
+ await fetchRawByCountry();
2304
+ await fetchRawJobs();
2305
+ } catch (e) {
2306
+ showToast('恢复失败: ' + e.message, true);
2307
+ }
2308
+ }
2309
+
2310
+ async function restoreRawJobsByCountry(country, count) {
2311
+ const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2312
+ if (!window.confirm(`确认将 ${country} 从毛料库恢复到 jobs 队列吗?${countText}`)) {
2313
+ return;
2314
+ }
2315
+ try {
2316
+ const res = await fetch('/api/raw-jobs/restore', {
2317
+ method: 'POST',
2318
+ headers: { 'Content-Type': 'application/json' },
2319
+ body: JSON.stringify({ country })
2320
+ });
2321
+ const data = await res.json();
2322
+ if (!res.ok || data.error) {
2323
+ showToast(data.error || '恢复失败', true);
2324
+ return;
2325
+ }
2326
+ showToast(`${country} 已恢复到队列,共 ${data.restored} 条`);
2327
+ await fetchStats();
2328
+ await fetchRawByCountry();
2329
+ await fetchRawJobs();
2330
+ } catch (e) {
2331
+ showToast('恢复失败: ' + e.message, true);
2332
+ }
2333
+ }
2334
+
2335
+ function filterByUserUpdateCountry(country) {
2336
+ // 返回主页面,筛选待补资料(通过搜索 tt_seller 为空的用户)
2337
+ window.location.hash = '';
2338
+ setTimeout(() => {
2339
+ const locationFilter = document.getElementById('locationFilter');
2340
+ // 待补资料没有独立的状态筛选,这里只设置国家筛选
2341
+ if (locationFilter && country && country !== '未知') {
2342
+ const options = locationFilter.options;
2343
+ for (let i = 0; i < options.length; i++) {
2344
+ if (options[i].value === country) {
2345
+ locationFilter.value = country;
2346
+ onLocationChange();
2347
+ break;
2348
+ }
2349
+ }
2350
+ }
2351
+ }, 100);
2352
+ }
2353
+ </script>
2354
+ </body>
2355
+
2356
2356
  </html>