tt-help-cli-ycl 1.3.57 → 1.3.59

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.
@@ -5,1078 +5,7 @@
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
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
- .loading-overlay {
425
- position: fixed;
426
- inset: 0;
427
- background: rgba(0, 0, 0, 0.5);
428
- display: flex;
429
- align-items: center;
430
- justify-content: center;
431
- z-index: 9999;
432
- opacity: 0;
433
- transition: opacity 0.2s;
434
- pointer-events: none;
435
- }
436
-
437
- .loading-overlay.visible {
438
- opacity: 1;
439
- pointer-events: auto;
440
- }
441
-
442
- .loading-spinner {
443
- display: flex;
444
- flex-direction: column;
445
- align-items: center;
446
- gap: 12px;
447
- background: #1a1a24;
448
- padding: 24px 36px;
449
- border-radius: 12px;
450
- border: 1px solid #2a2a3a;
451
- }
452
-
453
- .loading-spinner .spinner {
454
- width: 36px;
455
- height: 36px;
456
- border: 3px solid #2a2a3a;
457
- border-top-color: #fe2c55;
458
- border-radius: 50%;
459
- animation: spin 0.8s linear infinite;
460
- }
461
-
462
- .loading-spinner .loading-text {
463
- color: #ccc;
464
- font-size: 14px;
465
- }
466
-
467
- @keyframes spin {
468
- to {
469
- transform: rotate(360deg);
470
- }
471
- }
472
-
473
- @keyframes flashChange {
474
- 0% {
475
- filter: brightness(1.8);
476
- box-shadow: 0 0 12px rgba(254, 44, 85, 0.6);
477
- }
478
-
479
- 100% {
480
- filter: brightness(1);
481
- box-shadow: none;
482
- }
483
- }
484
-
485
- .flash-change {
486
- animation: flashChange 0.6s ease-out;
487
- }
488
-
489
- @keyframes rowFlash {
490
- 0% {
491
- background: rgba(254, 44, 85, 0.25);
492
- }
493
-
494
- 100% {
495
- background: transparent;
496
- }
497
- }
498
-
499
- tr.row-flash {
500
- animation: rowFlash 0.8s ease-out;
501
- }
502
-
503
- @keyframes barFlash {
504
- 0% {
505
- filter: brightness(1.6);
506
- }
507
-
508
- 100% {
509
- filter: brightness(1);
510
- }
511
- }
512
-
513
- .bar-fill.bar-flash {
514
- animation: barFlash 0.5s ease-out;
515
- }
516
-
517
- .table-scroll {
518
- max-height: 500px;
519
- overflow-y: auto;
520
- }
521
-
522
- table {
523
- width: 100%;
524
- border-collapse: collapse;
525
- font-size: 12px;
526
- }
527
-
528
- th {
529
- position: sticky;
530
- top: 0;
531
- background: #22222e;
532
- padding: 8px 10px;
533
- text-align: left;
534
- color: #888;
535
- font-weight: 600;
536
- border-bottom: 1px solid #333;
537
- white-space: nowrap;
538
- }
539
-
540
- td {
541
- padding: 6px 10px;
542
- border-bottom: 1px solid #1f1f2a;
543
- white-space: nowrap;
544
- }
545
-
546
- tr:hover {
547
- background: #1f1f2a;
548
- }
549
-
550
- td.user-id {
551
- cursor: pointer;
552
- color: #60a5fa;
553
- }
554
-
555
- td.user-id:hover {
556
- color: #fe2c55;
557
- }
558
-
559
- .tag {
560
- display: inline-block;
561
- padding: 1px 6px;
562
- border-radius: 3px;
563
- font-size: 10px;
564
- }
565
-
566
- .tag.seller {
567
- background: #dc2626;
568
- color: #fff;
569
- }
570
-
571
- .tag.verified {
572
- background: #2563eb;
573
- color: #fff;
574
- }
575
-
576
- .tag.pending {
577
- background: #ca8a04;
578
- color: #000;
579
- }
580
-
581
- .tag.processing {
582
- background: #0ea5e9;
583
- color: #fff;
584
- }
585
-
586
- .tag.error {
587
- background: #991b1b;
588
- color: #fff;
589
- }
590
-
591
- .tag.unknown-country {
592
- background: #4b5563;
593
- color: #fff;
594
- }
595
-
596
- #pendingPage {
597
- display: none;
598
- }
599
-
600
- #pendingPage.active {
601
- display: block;
602
- }
603
-
604
- #userUpdatePage {
605
- display: none;
606
- }
607
-
608
- #userUpdatePage.active {
609
- display: block;
610
- }
611
-
612
- #rawPage {
613
- display: none;
614
- }
615
-
616
- #rawPage.active {
617
- display: block;
618
- }
619
-
620
- #mainPage.hidden {
621
- display: none;
622
- }
623
-
624
- .pending-country-card {
625
- background: #1a1a24;
626
- border-radius: 8px;
627
- padding: 20px;
628
- margin-bottom: 16px;
629
- }
630
-
631
- .pending-country-card h3 {
632
- font-size: 16px;
633
- color: #facc15;
634
- margin-bottom: 16px;
635
- display: flex;
636
- align-items: center;
637
- gap: 8px;
638
- }
639
-
640
- .pending-country-grid {
641
- display: grid;
642
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
643
- gap: 12px;
644
- }
645
-
646
- .pending-country-item {
647
- background: #2a2a3a;
648
- border-radius: 8px;
649
- padding: 16px;
650
- cursor: pointer;
651
- transition: all 0.2s;
652
- border: 1px solid transparent;
653
- position: relative;
654
- }
655
-
656
- .country-action-btn {
657
- position: absolute;
658
- top: 10px;
659
- right: 10px;
660
- width: 28px;
661
- height: 28px;
662
- border: 1px solid rgba(248, 113, 113, 0.35);
663
- border-radius: 999px;
664
- background: rgba(127, 29, 29, 0.45);
665
- color: #fca5a5;
666
- display: inline-flex;
667
- align-items: center;
668
- justify-content: center;
669
- font-size: 14px;
670
- cursor: pointer;
671
- transition: all 0.2s;
672
- }
673
-
674
- .country-action-btn:hover {
675
- background: rgba(153, 27, 27, 0.85);
676
- color: #fff;
677
- border-color: rgba(248, 113, 113, 0.8);
678
- }
679
-
680
- .country-action-btn.restore {
681
- border-color: rgba(74, 222, 128, 0.35);
682
- background: rgba(20, 83, 45, 0.45);
683
- color: #86efac;
684
- }
685
-
686
- .country-action-btn.restore:hover {
687
- background: rgba(22, 101, 52, 0.9);
688
- border-color: rgba(74, 222, 128, 0.8);
689
- color: #fff;
690
- }
691
-
692
- .raw-page-layout {
693
- display: grid;
694
- grid-template-columns: 320px 1fr;
695
- gap: 16px;
696
- }
697
-
698
- .raw-side-card {
699
- background: #1a1a24;
700
- border-radius: 8px;
701
- padding: 16px;
702
- }
703
-
704
- .raw-side-card h3 {
705
- font-size: 14px;
706
- color: #f59e0b;
707
- margin-bottom: 12px;
708
- }
709
-
710
- .raw-table-card {
711
- background: #1a1a24;
712
- border-radius: 8px;
713
- padding: 16px;
714
- }
715
-
716
- .raw-table-card h3 {
717
- font-size: 14px;
718
- color: #888;
719
- margin-bottom: 12px;
720
- }
721
-
722
- .muted-tip {
723
- font-size: 12px;
724
- color: #777;
725
- margin-top: 8px;
726
- line-height: 1.5;
727
- }
728
-
729
- .pending-country-item:hover {
730
- border-color: #facc15;
731
- background: #33334a;
732
- }
733
-
734
- .pending-country-item .country-name {
735
- font-size: 18px;
736
- font-weight: 700;
737
- color: #facc15;
738
- margin-bottom: 4px;
739
- }
740
-
741
- .pending-country-item .country-count {
742
- font-size: 28px;
743
- font-weight: 700;
744
- color: #fff;
745
- }
746
-
747
- .pending-country-item .country-label {
748
- font-size: 12px;
749
- color: #888;
750
- margin-top: 2px;
751
- }
752
-
753
- .pending-country-item.has-target {
754
- border-color: #a78bfa;
755
- }
756
-
757
- .pending-country-item.has-target .country-name {
758
- color: #a78bfa;
759
- }
760
-
761
- .back-btn {
762
- padding: 6px 14px;
763
- border: 1px solid #333;
764
- border-radius: 6px;
765
- background: #2a2a3a;
766
- color: #ccc;
767
- font-size: 13px;
768
- cursor: pointer;
769
- transition: all 0.2s;
770
- margin-bottom: 16px;
771
- }
772
-
773
- .back-btn:hover {
774
- border-color: #fe2c55;
775
- color: #fff;
776
- }
777
-
778
- .tag.processed {
779
- background: #166534;
780
- color: #fff;
781
- }
782
-
783
- .tag.no-video {
784
- background: #7c3aed;
785
- color: #fff;
786
- }
787
-
788
- .tag.no-follow {
789
- background: #b45309;
790
- color: #fff;
791
- }
792
-
793
- .tag.keep-follow {
794
- background: #059669;
795
- color: #fff;
796
- }
797
-
798
- .tag.pinned {
799
- background: #f59e0b;
800
- color: #000;
801
- }
802
-
803
- .context-menu {
804
- position: fixed;
805
- background: #1e1e2e;
806
- border: 1px solid #333;
807
- border-radius: 6px;
808
- padding: 4px 0;
809
- min-width: 140px;
810
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
811
- z-index: 1000;
812
- }
813
-
814
- .context-menu-item {
815
- padding: 8px 16px;
816
- font-size: 13px;
817
- color: #ccc;
818
- cursor: pointer;
819
- display: flex;
820
- align-items: center;
821
- gap: 8px;
822
- }
823
-
824
- .context-menu-item:hover {
825
- background: #fe2c55;
826
- color: #fff;
827
- }
828
-
829
- .context-menu-item.danger {
830
- color: #f87171;
831
- }
832
-
833
- .context-menu-item.danger:hover {
834
- background: #991b1b;
835
- color: #fff;
836
- }
837
-
838
- ::-webkit-scrollbar {
839
- width: 6px;
840
- }
841
-
842
- ::-webkit-scrollbar-track {
843
- background: #1a1a24;
844
- }
845
-
846
- ::-webkit-scrollbar-thumb {
847
- background: #333;
848
- border-radius: 3px;
849
- }
850
-
851
- .client-errors-section {
852
- margin-bottom: 16px;
853
- }
854
-
855
- .section-header {
856
- display: flex;
857
- align-items: center;
858
- gap: 8px;
859
- margin-bottom: 8px;
860
- }
861
-
862
- .section-header h3 {
863
- font-size: 14px;
864
- color: #e0e0e0;
865
- }
866
-
867
- .error-badge {
868
- background: #991b1b;
869
- color: #fff;
870
- font-size: 11px;
871
- padding: 2px 8px;
872
- border-radius: 10px;
873
- font-weight: 600;
874
- }
875
-
876
- .client-errors-table {
877
- width: 100%;
878
- border-collapse: collapse;
879
- background: #1a1a24;
880
- border-radius: 8px;
881
- overflow: hidden;
882
- font-size: 13px;
883
- }
884
-
885
- .client-errors-table thead tr {
886
- background: #22222e;
887
- }
888
-
889
- .client-errors-table th {
890
- padding: 10px 12px;
891
- text-align: left;
892
- color: #888;
893
- font-weight: 600;
894
- font-size: 12px;
895
- border-bottom: 1px solid #2a2a3a;
896
- }
897
-
898
- .client-errors-table td {
899
- padding: 8px 12px;
900
- border-bottom: 1px solid #1f1f2e;
901
- }
902
-
903
- .client-errors-table tbody tr:last-child td {
904
- border-bottom: none;
905
- }
906
-
907
- .client-errors-table tbody tr:hover {
908
- background: #22222e;
909
- }
910
-
911
- .error-type-captcha {
912
- color: #f59e0b;
913
- }
914
-
915
- .error-type-network {
916
- color: #f87171;
917
- }
918
-
919
- .error-type-other {
920
- color: #a78bfa;
921
- }
922
-
923
- @media (max-width: 768px) {
924
- body {
925
- padding: 8px;
926
- }
927
-
928
- .header {
929
- flex-direction: column;
930
- gap: 6px;
931
- align-items: flex-start;
932
- }
933
-
934
- .header h1 {
935
- font-size: 16px;
936
- }
937
-
938
- .stats {
939
- grid-template-columns: repeat(2, 1fr);
940
- gap: 8px;
941
- }
942
-
943
- .stat-card {
944
- padding: 10px;
945
- }
946
-
947
- .stat-card .label {
948
- font-size: 11px;
949
- }
950
-
951
- .stat-card .value {
952
- font-size: 18px;
953
- }
954
-
955
- .charts {
956
- grid-template-columns: 1fr;
957
- }
958
-
959
- .raw-page-layout {
960
- grid-template-columns: 1fr;
961
- }
962
-
963
- .table-wrap {
964
- padding: 10px;
965
- }
966
-
967
- .controls {
968
- flex-wrap: wrap;
969
- gap: 6px;
970
- }
971
-
972
- .controls input {
973
- flex: 0 0 100%;
974
- width: 100%;
975
- }
976
-
977
- .controls button {
978
- flex: 0 0 calc(33.33% - 4px);
979
- min-width: 0;
980
- text-align: center;
981
- white-space: nowrap;
982
- font-size: 11px;
983
- padding: 8px 4px;
984
- }
985
-
986
- #batchResetBtn {
987
- flex: 0 0 100% !important;
988
- font-size: 12px !important;
989
- padding: 8px 12px !important;
990
- }
991
-
992
- .controls select {
993
- flex: 0 0 100%;
994
- width: 100%;
995
- }
996
-
997
- .table-scroll {
998
- max-height: none;
999
- overflow: visible;
1000
- }
1001
-
1002
- table,
1003
- thead,
1004
- tbody,
1005
- th,
1006
- td,
1007
- tr {
1008
- display: block;
1009
- }
1010
-
1011
- thead {
1012
- display: none;
1013
- }
1014
-
1015
- tr {
1016
- background: #22222e;
1017
- border-radius: 8px;
1018
- padding: 10px 12px;
1019
- margin-bottom: 8px;
1020
- border: 1px solid #2a2a3a;
1021
- }
1022
-
1023
- tr:hover {
1024
- background: #2a2a3a;
1025
- }
1026
-
1027
- td {
1028
- padding: 4px 0;
1029
- border: none;
1030
- text-align: left;
1031
- position: relative;
1032
- padding-left: 40%;
1033
- font-size: 13px;
1034
- }
1035
-
1036
- td::before {
1037
- content: attr(data-label);
1038
- position: absolute;
1039
- left: 0;
1040
- width: 36%;
1041
- text-align: right;
1042
- color: #888;
1043
- font-size: 12px;
1044
- font-weight: 600;
1045
- white-space: nowrap;
1046
- }
1047
-
1048
- td.user-id {
1049
- font-size: 15px;
1050
- font-weight: 700;
1051
- color: #60a5fa;
1052
- padding-left: 0;
1053
- border-bottom: 1px solid #2a2a3a;
1054
- margin-bottom: 4px;
1055
- padding-bottom: 6px;
1056
- }
1057
-
1058
- td.user-id::before {
1059
- display: none;
1060
- }
1061
-
1062
- td.user-id:hover {
1063
- color: #fe2c55;
1064
- }
1065
-
1066
- .add-users {
1067
- justify-content: center;
1068
- }
1069
-
1070
- .modal {
1071
- width: 95vw;
1072
- padding: 16px;
1073
- }
1074
-
1075
- .modal textarea {
1076
- height: 140px;
1077
- }
1078
- }
1079
- </style>
8
+ <link rel="stylesheet" href="style.css">
1080
9
  </head>
1081
10
 
1082
11
  <body>
@@ -1180,7 +109,7 @@
1180
109
  <option value="">全部目标国家</option>
1181
110
  </select>
1182
111
  <button id="batchResetBtn" onclick="batchResetErrors()"
1183
- 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;
112
+ 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;">↻
1184
113
  批量重新处理 (<span id="batchResetCount">0</span>)</button>
1185
114
  <select id="locationFilter" onchange="onLocationChange()"
1186
115
  style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
@@ -1196,6 +125,7 @@
1196
125
  <th>粉丝</th>
1197
126
  <th>视频</th>
1198
127
  <th>国家</th>
128
+ <th>最近发布</th>
1199
129
  <th>猜测国家</th>
1200
130
  <th>来源</th>
1201
131
  <th>状态</th>
@@ -1341,1115 +271,7 @@
1341
271
  </div>
1342
272
  </div>
1343
273
  </div>
1344
- <script>
1345
- const COLORS = ['#fe2c55', '#60a5fa', '#4ade80', '#facc15', '#f97316', '#a855f7', '#ec4899', '#14b8a6', '#e11d48', '#0ea5e9', '#8b5cf6', '#84cc16'];
1346
- let currentFilter = 'all';
1347
- let currentStats = null;
1348
- let currentUsers = [];
1349
- let currentLocation = '';
1350
- let currentTargetLocation = '';
1351
- let currentRawLocation = '';
1352
- let prevStatValues = {};
1353
- let prevUserMap = {};
1354
-
1355
- async function fetchStats() {
1356
- try {
1357
- const res = await fetch('/api/stats');
1358
- currentStats = await res.json();
1359
- renderStats();
1360
- renderLocationFilter();
1361
- } catch (e) {
1362
- document.getElementById('lastUpdate').textContent = '\u8fde\u63a5\u5931\u8d25';
1363
- }
1364
- }
1365
-
1366
- async function fetchUsers() {
1367
- try {
1368
- const params = new URLSearchParams();
1369
- if (currentFilter === 'target') {
1370
- params.set('target', '1');
1371
- if (currentTargetLocation) params.set('targetLocation', currentTargetLocation);
1372
- } else if (currentFilter !== 'all') {
1373
- params.set('status', currentFilter);
1374
- }
1375
- const search = document.getElementById('searchInput').value.trim();
1376
- if (search) params.set('search', search);
1377
- if (currentLocation) params.set('location', currentLocation);
1378
- params.set('limit', '200');
1379
- const res = await fetch('/api/users?' + params.toString());
1380
- const data = await res.json();
1381
- currentUsers = data.users || [];
1382
- renderTable(currentUsers);
1383
- } catch (e) { }
1384
- }
1385
-
1386
- function escapeHtml(str) {
1387
- const div = document.createElement('div');
1388
- div.textContent = str;
1389
- return div.innerHTML;
1390
- }
1391
-
1392
- async function fetchClientErrors() {
1393
- try {
1394
- const res = await fetch('/api/client-errors');
1395
- const data = await res.json();
1396
- const clients = data.clients || [];
1397
- const section = document.getElementById('clientErrorsSection');
1398
- const badge = document.getElementById('clientErrorsBadge');
1399
- const tbody = document.getElementById('clientErrorsBody');
1400
- if (clients.length === 0) {
1401
- section.style.display = 'none';
1402
- return;
1403
- }
1404
- section.style.display = '';
1405
- badge.textContent = clients.length;
1406
- const typeMap = { captcha: ['验证码', 'error-type-captcha'], network: ['网络', 'error-type-network'], other: ['其他', 'error-type-other'], '被封': ['被封', 'error-type-captcha'] };
1407
- const stageMap = { 'video-page': '视频页', 'comment': '评论', 'follow': '关注/粉丝', 'scrape': 'scrape', 'process': '处理' };
1408
- tbody.innerHTML = clients.map(c => {
1409
- const [typeText, typeClass] = typeMap[c.errorType] || ['未知', ''];
1410
- const stageText = c.stage ? (stageMap[c.stage] || c.stage) : '';
1411
- const captchaText = c.captchaCount ? `${c.captchaCount}次${c.captchaStage ? ' (' + (stageMap[c.captchaStage] || c.captchaStage) + ')' : ''}` : '-';
1412
- const msgDetail = c.errorMessage ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.errorMessage)}</span>` : '';
1413
- 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>` : '';
1414
- const captchaDetail = c.captchaMessage ? `<br><span style="color:#666;font-size:11px">${escapeHtml(c.captchaMessage)}</span>` : '';
1415
- return `<tr>
1416
- <td style="font-family:monospace;font-weight:600;color:#60a5fa">${escapeHtml(c.userId)}</td>
1417
- <td class="${typeClass}">${typeText}</td>
1418
- <td style="color:#f87171;font-weight:600">${c.reportCount || 1}</td>
1419
- <td style="color:#f59e0b;font-weight:600">${captchaText}${captchaDetail}</td>
1420
- <td style="color:#a78bfa;font-size:12px">${stageText}</td>
1421
- <td style="color:#ccc;font-size:12px;max-width:300px;word-break:break-all">${msgDetail}${stackDetail}</td>
1422
- <td style="color:#60a5fa">@${escapeHtml(c.username || '-')}</td>
1423
- <td style="color:#888;font-size:12px">${new Date(c.timestamp).toLocaleTimeString()}</td>
1424
- <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>
1425
- </tr>`;
1426
- }).join('');
1427
- } catch (e) { }
1428
- }
1429
-
1430
- async function deleteClientError(userId) {
1431
- try {
1432
- await fetch(`/api/client-error/${encodeURIComponent(userId)}`, { method: 'DELETE' });
1433
- fetchClientErrors();
1434
- } catch (e) { }
1435
- }
1436
-
1437
- function formatStatNum(value, { full = false } = {}) {
1438
- const num = Number(value) || 0;
1439
- if (full) return num.toLocaleString('zh-CN');
1440
- if (Math.abs(num) < 1000) return String(num);
1441
- if (Math.abs(num) < 10000) return num.toLocaleString('zh-CN');
1442
- const wan = num / 10000;
1443
- return wan.toFixed(1).replace(/\.0+$/, '') + '万';
1444
- }
1445
-
1446
- function flashEl(id, value, options) {
1447
- const el = document.getElementById(id);
1448
- if (!el) return;
1449
- const prev = prevStatValues[id];
1450
- el.textContent = formatStatNum(value, options);
1451
- if (prev !== undefined && prev !== value) {
1452
- el.classList.remove('flash-change');
1453
- void el.offsetWidth;
1454
- el.classList.add('flash-change');
1455
- }
1456
- prevStatValues[id] = value;
1457
- }
1458
-
1459
- function renderStats() {
1460
- if (!currentStats) return;
1461
- const d = currentStats;
1462
- flashEl('statTotal', d.totalUsers);
1463
- flashEl('statProcessing', d.processingUsers || 0);
1464
- flashEl('statDone', d.processedUsers);
1465
- flashEl('statPending', d.pendingUsers);
1466
- flashEl('statError', d.errorUsers);
1467
- flashEl('statRestricted', d.restrictedUsers);
1468
- flashEl('statTarget', d.targetUsers, { full: true });
1469
- flashEl('statUserUpdateTasks', d.userUpdateTasks || 0, { full: true });
1470
- flashEl('statRawJobs', d.rawJobs || 0);
1471
- // 同步子页面 stats
1472
- const pendingTotal = document.getElementById('pendingStatTotal');
1473
- if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
1474
- const pendingCount = document.getElementById('pendingStatPending');
1475
- if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
1476
- const pendingUserUpdate = document.getElementById('pendingStatUserUpdateTasks');
1477
- if (pendingUserUpdate) pendingUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0, { full: true });
1478
- const pendingRawJobs = document.getElementById('pendingStatRawJobs');
1479
- if (pendingRawJobs) pendingRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1480
- const userUpdateTotal = document.getElementById('userUpdateStatTotal');
1481
- if (userUpdateTotal) userUpdateTotal.textContent = formatStatNum(d.totalUsers);
1482
- const userUpdatePending = document.getElementById('userUpdateStatPending');
1483
- if (userUpdatePending) userUpdatePending.textContent = formatStatNum(d.pendingUsers);
1484
- const userUpdateTasks = document.getElementById('userUpdateStatUserUpdateTasks');
1485
- if (userUpdateTasks) userUpdateTasks.textContent = formatStatNum(d.userUpdateTasks || 0, { full: true });
1486
- const userUpdateRawJobs = document.getElementById('userUpdateStatRawJobs');
1487
- if (userUpdateRawJobs) userUpdateRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1488
- const rawPageRawJobs = document.getElementById('rawPageStatRawJobs');
1489
- if (rawPageRawJobs) rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1490
- const rawPagePending = document.getElementById('rawPageStatPending');
1491
- if (rawPagePending) rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
1492
- const rawPageUserUpdate = document.getElementById('rawPageStatUserUpdateTasks');
1493
- if (rawPageUserUpdate) rawPageUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1494
- document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
1495
- document.getElementById('fileMeta').textContent =
1496
- formatStatNum(d.processingUsers || 0) +
1497
- ' \u5904\u7406\u4e2d, ' +
1498
- formatStatNum(d.totalUsers) +
1499
- ' \u4e2a\u7528\u6237, \u5f85\u5904\u7406 ' +
1500
- formatStatNum(d.pendingUsers || 0);
1501
-
1502
- renderCountryChart(d.countryStats);
1503
- renderSourceChart(d.sourceStats);
1504
- renderTargetLocationFilter(d.targetCountryStats);
1505
- }
1506
-
1507
- function renderTargetLocationFilter(targetCountryStats) {
1508
- const sel = document.getElementById('targetLocationFilter');
1509
- if (!sel) return;
1510
- const val = sel.value;
1511
- sel.innerHTML = '<option value="">全部目标国家</option>' +
1512
- (targetCountryStats || []).map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
1513
- }
1514
-
1515
- function renderCountryChart(countries) {
1516
- const el = document.getElementById('countryChart');
1517
- const filtered = countries.filter(c => c.country !== '\u672a\u77e5');
1518
- if (!filtered.length) { el.innerHTML = '<span style="color:#666;font-size:12px">\u6682\u65e0\u6570\u636e</span>'; return; }
1519
- const max = filtered[0].count;
1520
- const top = filtered.slice(0, 15);
1521
- const targetLocations = currentStats?.targetLocations || [];
1522
- el.innerHTML = top.map((c, i) => {
1523
- const isTarget = targetLocations.includes(c.country);
1524
- const targetBadge = c.targetCount > 0
1525
- ? `<span class="target-badge">🎯 ${c.targetCount}</span>`
1526
- : `<span class="target-badge" style="visibility:hidden"> </span>`;
1527
- return `
1528
- <div class="bar-row${isTarget ? ' is-target' : ''}">
1529
- <span class="name">${c.country}</span>
1530
- <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>
1531
- ${targetBadge}
1532
- <span class="count">${(currentStats ? (c.count / currentStats.totalUsers * 100).toFixed(1) : 0)}%</span>
1533
- </div>
1534
- `;
1535
- }).join('');
1536
- }
1537
-
1538
- function renderSourceChart(sources) {
1539
- const el = document.getElementById('sourceChart');
1540
- 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' };
1541
- const entries = Object.entries(sources);
1542
- el.innerHTML = entries.map(([key, val]) => `
1543
- <div class="source-row"><span class="s-name">${labels[key] || key}:</span><span class="s-val">${val}</span></div>
1544
- `).join('');
1545
- }
1546
-
1547
- function renderTable(users) {
1548
- const el = document.getElementById('userTable');
1549
-
1550
- const newUserMap = {};
1551
- for (const u of users) newUserMap[u.uniqueId] = u;
1552
-
1553
- el.innerHTML = users.map(u => {
1554
- const wasStatus = prevUserMap[u.uniqueId]?.status;
1555
- const nowStatus = u.status;
1556
- const changed = wasStatus !== nowStatus &&
1557
- (nowStatus === 'done' || nowStatus === 'restricted' || nowStatus === 'error');
1558
- const rowClass = changed ? ' class="row-flash"' : '';
1559
-
1560
- const statusTags = {
1561
- restricted: '<span class="tag error">\u53d7\u9650(\u8df3\u8fc7)</span>',
1562
- error: '<span class="tag error">\u9519\u8bef(\u5f85\u91cd\u8bd5)</span>',
1563
- done: '<span class="tag processed">\u5df2\u5b8c\u6210</span>',
1564
- processing: '<span class="tag processing">\u5904\u7406\u4e2d</span>',
1565
- pending: '<span class="tag pending">\u5f85\u5904\u7406</span>',
1566
- };
1567
- const statusTag = statusTags[u.status] || '<span class="tag pending">' + (u.status || '\u672a\u77e5') + '</span>';
1568
- const sources = (u.sources || []).join(', ');
1569
- const extraTags = [];
1570
- if (u.pinned) extraTags.push('<span class="tag pinned">&#x1F4CC; 置顶</span>');
1571
- if (u.ttSeller) extraTags.push('<span class="tag seller">\u5546\u5bb6</span>');
1572
- if (u.verified) extraTags.push('<span class="tag verified">\u8ba4\u8bc1</span>');
1573
- if (u.noVideo) extraTags.push('<span class="tag no-video">\u65e0\u89c6\u9891</span>');
1574
- if (u.keepFollow) extraTags.push('<span class="tag keep-follow">\u5173\u6ce8\u5df2\u4fdd\u7559</span>');
1575
- if (u.hasFollowData === false) extraTags.push('<span class="tag no-follow">\u5173\u6ce8\u672a\u83b7\u53d6</span>');
1576
- const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1577
- const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
1578
- const videos = u.videoCount != null ? u.videoCount : '-';
1579
- const loc = u.locationCreated || '-';
1580
- const guessedLoc = u.guessedLocation || '-';
1581
- const claimer = u.claimedBy || '-';
1582
- const claimTime = u.claimedAt ? formatTime(u.claimedAt) : '-';
1583
- const procTime = u.processedAt ? formatTime(u.processedAt) : '-';
1584
- const statusCodeDisplay = u.statusCode != null && u.statusCode !== 0
1585
- ? `<span class="tag error" style="font-size:10px">${u.statusCode}</span>`
1586
- : '';
1587
- return `<tr${rowClass}>
1588
- <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
1589
- <td data-label="昵称">${nick}</td>
1590
- <td data-label="粉丝">${fans}</td>
1591
- <td data-label="视频">${videos}</td>
1592
- <td data-label="国家">${loc}</td>
1593
- <td data-label="猜测国家">${guessedLoc}</td>
1594
- <td data-label="来源">${sources || '-'}</td>
1595
- <td data-label="状态">${statusTag} ${extraTags.join(' ')}</td>
1596
- <td data-label="StatusCode">${statusCodeDisplay}</td>
1597
- <td data-label="处理端" style="font-size:11px;color:#888">${claimer}</td>
1598
- <td data-label="领取时间" style="font-size:11px;color:#888">${claimTime}</td>
1599
- <td data-label="完成时间" style="font-size:11px;color:#888">${procTime}</td>
1600
- </tr>`;
1601
- }).join('');
1602
-
1603
- const errorCount = users.filter(u => u.status === 'error').length;
1604
- const countEl = document.getElementById('batchResetCount');
1605
- if (countEl) countEl.textContent = errorCount;
1606
-
1607
- prevUserMap = newUserMap;
1608
- }
1609
-
1610
- function formatNum(n) {
1611
- if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1612
- if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1613
- return n;
1614
- }
1615
-
1616
- function formatTime(ts) {
1617
- const d = new Date(ts);
1618
- const pad = n => String(n).padStart(2, '0');
1619
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1620
- }
1621
-
1622
- function setFilter(f) {
1623
- currentFilter = f;
1624
- document.querySelectorAll('.controls button').forEach(b => {
1625
- b.classList.toggle('active', b.dataset.filter === f);
1626
- });
1627
- const btn = document.getElementById('batchResetBtn');
1628
- btn.style.display = f === 'error' ? '' : 'none';
1629
- const targetLocSel = document.getElementById('targetLocationFilter');
1630
- if (f === 'target') {
1631
- targetLocSel.style.display = '';
1632
- } else {
1633
- targetLocSel.style.display = 'none';
1634
- currentTargetLocation = '';
1635
- }
1636
- fetchUsers();
1637
- }
1638
-
1639
- function renderLocationFilter() {
1640
- if (!currentStats || !currentStats.countryStats) return;
1641
- const sel = document.getElementById('locationFilter');
1642
- if (!sel) return;
1643
- const val = sel.value;
1644
- const entries = currentStats.countryStats.sort((a, b) => b.count - a.count);
1645
- sel.innerHTML = '<option value="">全部国家</option>' +
1646
- entries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
1647
- }
1648
-
1649
- function onLocationChange() {
1650
- const sel = document.getElementById('locationFilter');
1651
- currentLocation = sel.value;
1652
- fetchUsers();
1653
- }
1654
-
1655
- function onTargetLocationChange() {
1656
- const sel = document.getElementById('targetLocationFilter');
1657
- currentTargetLocation = sel.value;
1658
- fetchUsers();
1659
- }
1660
-
1661
- let searchTimer = null;
1662
- document.getElementById('searchInput').addEventListener('input', () => {
1663
- if (searchTimer) clearTimeout(searchTimer);
1664
- searchTimer = setTimeout(fetchUsers, 300);
1665
- });
1666
-
1667
- let rawSearchTimer = null;
1668
- document.getElementById('rawSearchInput').addEventListener('input', () => {
1669
- if (rawSearchTimer) clearTimeout(rawSearchTimer);
1670
- rawSearchTimer = setTimeout(fetchRawJobs, 300);
1671
- });
1672
-
1673
- function parseUsernames(raw) {
1674
- return raw.split(/[,,\n\r]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
1675
- }
1676
-
1677
- function openAddModal() {
1678
- let overlay = document.getElementById('addModalOverlay');
1679
- if (overlay) return;
1680
- overlay = document.createElement('div');
1681
- overlay.id = 'addModalOverlay';
1682
- overlay.className = 'modal-overlay';
1683
- overlay.innerHTML = `
1684
- <div class="modal">
1685
- <h3>插入用户到队列</h3>
1686
- <div class="hint">每行一个用户名,或用逗号分隔。支持 @username 或 username 格式。插入到队列最前面优先处理。</div>
1687
- <textarea id="modalUserInput" placeholder="例如:&#10;user1&#10;user2&#10;user3&#10;&#10;或:user1, user2, user3"></textarea>
1688
- <div class="preview" id="modalPreview"></div>
1689
- <div class="btn-row">
1690
- <button class="btn-cancel" onclick="closeAddModal()">取消</button>
1691
- <button class="btn-submit" onclick="submitAddUsers()">确认插入</button>
1692
- </div>
1693
- </div>
1694
- `;
1695
- document.body.appendChild(overlay);
1696
- overlay.addEventListener('click', e => { if (e.target === overlay) closeAddModal(); });
1697
- const ta = document.getElementById('modalUserInput');
1698
- ta.focus();
1699
- ta.addEventListener('input', () => {
1700
- const names = parseUsernames(ta.value);
1701
- const preview = document.getElementById('modalPreview');
1702
- preview.textContent = names.length ? `共 ${names.length} 个用户名` : '';
1703
- });
1704
- ta.addEventListener('keydown', e => {
1705
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1706
- e.preventDefault();
1707
- submitAddUsers();
1708
- }
1709
- });
1710
- }
1711
-
1712
- function closeAddModal() {
1713
- const overlay = document.getElementById('addModalOverlay');
1714
- if (overlay) overlay.remove();
1715
- }
1716
-
1717
- async function submitAddUsers() {
1718
- const ta = document.getElementById('modalUserInput');
1719
- const raw = ta.value.trim();
1720
- if (!raw) return;
1721
-
1722
- const names = parseUsernames(raw);
1723
- if (names.length === 0) return;
1724
-
1725
- showLoading('正在添加用户...');
1726
- try {
1727
- const res = await fetch('/api/users', {
1728
- method: 'POST',
1729
- headers: { 'Content-Type': 'application/json' },
1730
- body: JSON.stringify({ usernames: names })
1731
- });
1732
- const data = await res.json();
1733
- if (data.error) { showToast(data.error, true); return; }
1734
- closeAddModal();
1735
- showToast(data.message || `\u5df2\u63d2\u5165 ${data.added} \u4e2a\u7528\u6237`);
1736
- fetchStats();
1737
- fetchUsers();
1738
- } catch (e) {
1739
- showToast('\u63d2\u5165\u5931\u8d25: ' + e.message, true);
1740
- } finally {
1741
- hideLoading();
1742
- }
1743
- }
1744
-
1745
- function showToast(msg, isError) {
1746
- let toast = document.getElementById('globalToast') || document.getElementById('toast');
1747
- if (!toast) {
1748
- toast = document.createElement('div');
1749
- toast.id = 'globalToast';
1750
- toast.className = 'toast';
1751
- toast.style.display = 'none';
1752
- document.body.appendChild(toast);
1753
- }
1754
- toast.textContent = msg;
1755
- toast.className = 'toast' + (isError ? ' error' : '');
1756
- toast.style.display = 'block';
1757
- setTimeout(() => { toast.style.display = 'none'; }, 3000);
1758
- }
1759
-
1760
- function showLoading(text) {
1761
- let overlay = document.getElementById('loadingOverlay');
1762
- if (!overlay) {
1763
- overlay = document.createElement('div');
1764
- overlay.id = 'loadingOverlay';
1765
- overlay.className = 'loading-overlay';
1766
- overlay.innerHTML = `
1767
- <div class="loading-spinner">
1768
- <div class="spinner"></div>
1769
- <div class="loading-text" id="loadingText">处理中...</div>
1770
- </div>
1771
- `;
1772
- document.body.appendChild(overlay);
1773
- }
1774
- const textEl = document.getElementById('loadingText');
1775
- if (textEl) textEl.textContent = text || '处理中...';
1776
- overlay.classList.add('visible');
1777
- }
1778
-
1779
- function hideLoading() {
1780
- const overlay = document.getElementById('loadingOverlay');
1781
- if (overlay) overlay.classList.remove('visible');
1782
- }
1783
-
1784
- function escapeJsString(str) {
1785
- return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1786
- }
1787
-
1788
- document.addEventListener('keydown', e => {
1789
- if (e.key === 'Escape') closeAddModal();
1790
- });
1791
-
1792
- document.getElementById('userTable').addEventListener('click', e => {
1793
- const td = e.target.closest('td.user-id');
1794
- if (!td) return;
1795
- hideContextMenu();
1796
- const username = td.textContent.trim().replace(/^@/, '');
1797
- if (!username) return;
1798
- window.open('https://www.tiktok.com/@' + username, '_blank');
1799
- });
1800
-
1801
- let contextMenuEl = null;
1802
- let contextMenuUserId = null;
1803
- let contextMenuPinned = false;
1804
-
1805
- function showContextMenu(x, y, uniqueId, pinned) {
1806
- hideContextMenu();
1807
- contextMenuUserId = uniqueId;
1808
- contextMenuPinned = !!pinned;
1809
- contextMenuEl = document.createElement('div');
1810
- contextMenuEl.className = 'context-menu';
1811
- contextMenuEl.innerHTML = `
1812
- <div class="context-menu-item" data-action="pin">${contextMenuPinned ? '&#x1F4CC; 取消置顶' : '&#x1F4CD; 置顶优先'}</div>
1813
- <div class="context-menu-item" data-action="reset">&#x21bb; 重新处理</div>
1814
- <div class="context-menu-item" data-action="open">&#x1F517; 打开主页</div>
1815
- `;
1816
- document.body.appendChild(contextMenuEl);
1817
- contextMenuEl.style.left = Math.min(x, window.innerWidth - 160) + 'px';
1818
- contextMenuEl.style.top = Math.min(y, window.innerHeight - 100) + 'px';
1819
-
1820
- contextMenuEl.addEventListener('click', e => {
1821
- const item = e.target.closest('.context-menu-item');
1822
- if (!item) return;
1823
- const action = item.dataset.action;
1824
- if (action === 'pin') togglePin(contextMenuUserId);
1825
- if (action === 'reset') resetJob(contextMenuUserId);
1826
- if (action === 'open') window.open('https://www.tiktok.com/@' + contextMenuUserId, '_blank');
1827
- hideContextMenu();
1828
- });
1829
- }
1830
-
1831
- function hideContextMenu() {
1832
- if (contextMenuEl) {
1833
- contextMenuEl.remove();
1834
- contextMenuEl = null;
1835
- contextMenuUserId = null;
1836
- }
1837
- }
1838
-
1839
- document.getElementById('userTable').addEventListener('contextmenu', e => {
1840
- const td = e.target.closest('td');
1841
- if (!td || td.parentElement.tagName !== 'TR') return;
1842
- e.preventDefault();
1843
- const tr = td.parentElement;
1844
- const userIdTd = tr.querySelector('td.user-id');
1845
- if (!userIdTd) return;
1846
- const uniqueId = userIdTd.textContent.trim().replace(/^@/, '');
1847
- const pinned = !!tr.querySelector('.tag.pinned');
1848
- showContextMenu(e.clientX, e.clientY, uniqueId, pinned);
1849
- });
1850
-
1851
- document.addEventListener('click', e => {
1852
- if (contextMenuEl && !contextMenuEl.contains(e.target)) {
1853
- hideContextMenu();
1854
- }
1855
- });
1856
-
1857
- async function togglePin(uniqueId) {
1858
- showLoading('操作中...');
1859
- try {
1860
- const res = await fetch('/api/job/' + encodeURIComponent(uniqueId) + '/pin', {
1861
- method: 'POST',
1862
- });
1863
- const data = await res.json();
1864
- if (data.saved) {
1865
- showToast(data.pinned ? '已置顶' : '已取消置顶');
1866
- fetchUsers();
1867
- } else {
1868
- showToast(data.error || '操作失败', true);
1869
- }
1870
- } catch (e) {
1871
- showToast('操作失败: ' + e.message, true);
1872
- } finally {
1873
- hideLoading();
1874
- }
1875
- }
1876
-
1877
- async function resetJob(uniqueId) {
1878
- showLoading('重置任务...');
1879
- try {
1880
- const res = await fetch('/api/job/' + encodeURIComponent(uniqueId) + '/reset', {
1881
- method: 'POST',
1882
- });
1883
- const data = await res.json();
1884
- if (data.saved) {
1885
- showToast('\u5df2\u91cd\u7f6e\u4efb\u52a1');
1886
- fetchUsers();
1887
- fetchStats();
1888
- } else {
1889
- showToast(data.error || '\u91cd\u7f6e\u5931\u8d25', true);
1890
- }
1891
- } catch (e) {
1892
- showToast('\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
1893
- } finally {
1894
- hideLoading();
1895
- }
1896
- }
1897
-
1898
- async function batchResetErrors() {
1899
- const btn = document.getElementById('batchResetBtn');
1900
- const errorUsers = currentUsers.filter(u => u.status === 'error');
1901
- if (errorUsers.length === 0) {
1902
- showToast('\u6ca1\u6709\u9700\u8981\u91cd\u7f6e\u7684\u9519\u8bef\u7528\u6237', true);
1903
- return;
1904
- }
1905
- const userIds = errorUsers.map(u => u.uniqueId);
1906
- showLoading('正在批量重置...');
1907
- try {
1908
- const res = await fetch('/api/jobs/batch-reset', {
1909
- method: 'POST',
1910
- headers: { 'Content-Type': 'application/json' },
1911
- body: JSON.stringify({ userIds })
1912
- });
1913
- const data = await res.json();
1914
- if (data.error) {
1915
- showToast(data.error, true);
1916
- return;
1917
- }
1918
- showToast(`\u5df2\u91cd\u7f6e ${data.reset} / ${data.total} \u4e2a\u7528\u6237`);
1919
- fetchUsers();
1920
- fetchStats();
1921
- } catch (e) {
1922
- showToast('\u6279\u91cf\u91cd\u7f6e\u5931\u8d25: ' + e.message, true);
1923
- } finally {
1924
- hideLoading();
1925
- }
1926
- }
1927
-
1928
- document.getElementById('statTargetCard').addEventListener('click', async () => {
1929
- showLoading('正在导出目标用户...');
1930
- try {
1931
- const res = await fetch('/api/target-users', {
1932
- headers: { 'Accept': 'text/csv' },
1933
- });
1934
- if (!res.ok) throw new Error('HTTP ' + res.status);
1935
- const blob = await res.blob();
1936
- const ext = blob.size < 200 ? 'json' : 'csv';
1937
- if (ext === 'json') {
1938
- const text = await blob.text();
1939
- const data = JSON.parse(text);
1940
- if (!data.users.length) { showToast('暂无目标用户', true); return; }
1941
- if (navigator.clipboard && navigator.clipboard.writeText) {
1942
- const ids = data.users.map(u => '@' + u.uniqueId).join(', ');
1943
- await navigator.clipboard.writeText(ids);
1944
- showToast(data.users.length + ' 个目标用户 ID 已复制到剪贴板');
1945
- }
1946
- return;
1947
- }
1948
- const url = URL.createObjectURL(blob);
1949
- const a = document.createElement('a');
1950
- a.href = url;
1951
- a.download = 'target-users.csv';
1952
- a.click();
1953
- URL.revokeObjectURL(url);
1954
- showToast('CSV 文件已开始下载');
1955
- } catch (e) {
1956
- showToast('获取失败: ' + e.message, true);
1957
- } finally {
1958
- hideLoading();
1959
- }
1960
- });
1961
-
1962
- fetchStats();
1963
- fetchUsers();
1964
- fetchClientErrors();
1965
- setInterval(fetchStats, 10000);
1966
- setInterval(fetchUsers, 10000);
1967
- setInterval(fetchClientErrors, 10000);
1968
- setInterval(refreshUserUpdateIfActive, 10000);
1969
-
1970
- // Hash 路由
1971
- window.addEventListener('hashchange', handleRoute);
1972
- window.addEventListener('DOMContentLoaded', handleRoute);
1973
-
1974
- function handleRoute() {
1975
- const hash = window.location.hash;
1976
- if (hash === '#pending') {
1977
- showPendingPage();
1978
- } else if (hash === '#userUpdate') {
1979
- showUserUpdatePage();
1980
- } else if (hash === '#raw') {
1981
- showRawPage();
1982
- } else {
1983
- showMainPage();
1984
- }
1985
- }
1986
-
1987
- function navigateToPending() {
1988
- window.location.hash = '#pending';
1989
- }
1990
-
1991
- function navigateToUserUpdate() {
1992
- window.location.hash = '#userUpdate';
1993
- }
1994
-
1995
- function navigateToRaw() {
1996
- window.location.hash = '#raw';
1997
- }
1998
-
1999
- function navigateToMain() {
2000
- window.location.hash = '';
2001
- }
2002
-
2003
- function showPendingPage() {
2004
- document.getElementById('mainPage').classList.add('hidden');
2005
- document.getElementById('pendingPage').classList.add('active');
2006
- document.getElementById('userUpdatePage').classList.remove('active');
2007
- document.getElementById('rawPage').classList.remove('active');
2008
- fetchPendingByCountry();
2009
- fetchAttachStuckByCountry();
2010
- }
2011
-
2012
- function showUserUpdatePage() {
2013
- document.getElementById('mainPage').classList.add('hidden');
2014
- document.getElementById('pendingPage').classList.remove('active');
2015
- document.getElementById('userUpdatePage').classList.add('active');
2016
- document.getElementById('rawPage').classList.remove('active');
2017
- fetchUserUpdateByCountry();
2018
- fetchAttachStuckByCountry();
2019
- }
2020
-
2021
- function refreshUserUpdateIfActive() {
2022
- if (document.getElementById('userUpdatePage').classList.contains('active')) {
2023
- fetchUserUpdateByCountry();
2024
- fetchAttachStuckByCountry();
2025
- }
2026
- }
2027
-
2028
- function showRawPage() {
2029
- document.getElementById('mainPage').classList.add('hidden');
2030
- document.getElementById('pendingPage').classList.remove('active');
2031
- document.getElementById('userUpdatePage').classList.remove('active');
2032
- document.getElementById('rawPage').classList.add('active');
2033
- fetchRawByCountry();
2034
- fetchRawJobs();
2035
- }
2036
-
2037
- function showMainPage() {
2038
- document.getElementById('mainPage').classList.remove('hidden');
2039
- document.getElementById('pendingPage').classList.remove('active');
2040
- document.getElementById('userUpdatePage').classList.remove('active');
2041
- document.getElementById('rawPage').classList.remove('active');
2042
- }
2043
-
2044
- async function fetchPendingByCountry() {
2045
- try {
2046
- const res = await fetch('/api/pending-by-country');
2047
- const data = await res.json();
2048
- renderPendingCountryGrid(data.countries || []);
2049
- } catch (e) {
2050
- console.error('获取待处理国家分布失败:', e);
2051
- }
2052
- }
2053
-
2054
- function renderPendingCountryGrid(countries) {
2055
- const grid = document.getElementById('pendingCountryGrid');
2056
- if (!countries.length) {
2057
- grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待处理任务</span>';
2058
- return;
2059
- }
2060
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2061
- grid.innerHTML = countries.map(c => {
2062
- const pct = ((c.count / total) * 100).toFixed(1);
2063
- const isUnknown = c.country === '未知';
2064
- const safeCountry = escapeJsString(c.country);
2065
- return `
2066
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2067
- onclick="filterByPendingCountry('${safeCountry}')">
2068
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
2069
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2070
- <div class="country-count">${c.count}</div>
2071
- <div class="country-label">${pct}% 待处理</div>
2072
- </div>
2073
- `;
2074
- }).join('');
2075
- }
2076
-
2077
- function filterByPendingCountry(country) {
2078
- // 返回主页面并设置国家筛选
2079
- window.location.hash = '';
2080
- setTimeout(() => {
2081
- const searchInput = document.getElementById('searchInput');
2082
- const locationFilter = document.getElementById('locationFilter');
2083
- // 设置状态筛选为 pending
2084
- setFilter('pending');
2085
- // 如果有国家筛选下拉框,设置国家
2086
- if (locationFilter && country && country !== '未知') {
2087
- // 检查选项中是否有该国家
2088
- const options = locationFilter.options;
2089
- for (let i = 0; i < options.length; i++) {
2090
- if (options[i].value === country) {
2091
- locationFilter.value = country;
2092
- onLocationChange();
2093
- break;
2094
- }
2095
- }
2096
- }
2097
- }, 100);
2098
- }
2099
-
2100
- async function fetchUserUpdateByCountry() {
2101
- try {
2102
- const res = await fetch('/api/user-update-by-country');
2103
- const data = await res.json();
2104
- renderUserUpdateCountryGrid(data.countries || []);
2105
- } catch (e) {
2106
- console.error('获取待补资料国家分布失败:', e);
2107
- }
2108
- }
2109
-
2110
- function renderUserUpdateCountryGrid(countries) {
2111
- const grid = document.getElementById('userUpdateCountryGrid');
2112
- if (!countries.length) {
2113
- grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待补资料任务</span>';
2114
- return;
2115
- }
2116
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2117
- grid.innerHTML = countries.map(c => {
2118
- const pct = ((c.count / total) * 100).toFixed(1);
2119
- const isUnknown = c.country === '未知';
2120
- const safeCountry = escapeJsString(c.country);
2121
- return `
2122
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2123
- onclick="filterByUserUpdateCountry('${safeCountry}')">
2124
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
2125
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2126
- <div class="country-count">${c.count}</div>
2127
- <div class="country-label">${pct}% 待补资料</div>
2128
- </div>
2129
- `;
2130
- }).join('');
2131
- }
2132
-
2133
- async function fetchAttachStuckByCountry() {
2134
- try {
2135
- const res = await fetch('/api/attach-stuck-by-country');
2136
- const data = await res.json();
2137
- renderAttachStuckGrid('pendingAttachStuckGrid', data.countries || []);
2138
- renderAttachStuckGrid('userUpdateAttachStuckGrid', data.countries || []);
2139
- } catch (e) {
2140
- console.error('获取 attach 未成功国家分布失败:', e);
2141
- }
2142
- }
2143
-
2144
- function renderAttachStuckGrid(gridId, countries) {
2145
- const grid = document.getElementById(gridId);
2146
- if (!grid) return;
2147
- if (!countries.length) {
2148
- grid.innerHTML = '<span style="color:#666;font-size:12px">暂无 attach 未成功任务</span>';
2149
- return;
2150
- }
2151
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2152
- grid.innerHTML = countries.map(c => {
2153
- const pct = ((c.count / total) * 100).toFixed(1);
2154
- const isUnknown = c.country === '未知';
2155
- const safeCountry = escapeJsString(c.country);
2156
- return `
2157
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}">
2158
- <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
2159
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2160
- <div class="country-count">${c.count}</div>
2161
- <div class="country-label">${pct}% attach 未成功</div>
2162
- </div>
2163
- `;
2164
- }).join('');
2165
- }
2166
-
2167
- async function restoreAttachStuckByCountry(country, count) {
2168
- const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2169
- if (!window.confirm(`确认将 ${country} 下 attach 未成功的任务恢复为待补资料吗?${countText}`)) {
2170
- return;
2171
- }
2172
- showLoading('正在恢复任务...');
2173
- try {
2174
- const res = await fetch('/api/attach-stuck/restore', {
2175
- method: 'POST',
2176
- headers: { 'Content-Type': 'application/json' },
2177
- body: JSON.stringify({ country })
2178
- });
2179
- const data = await res.json();
2180
- if (!res.ok || data.error) {
2181
- showToast(data.error || '恢复 attach 任务失败', true);
2182
- return;
2183
- }
2184
- showToast(`${country} 的 attach 未成功任务已恢复,共 ${data.restored} 条`);
2185
- await fetchStats();
2186
- await fetchPendingByCountry();
2187
- await fetchUserUpdateByCountry();
2188
- await fetchAttachStuckByCountry();
2189
- if (!document.getElementById('rawPage').classList.contains('active')) {
2190
- fetchUsers();
2191
- }
2192
- } catch (e) {
2193
- showToast('恢复 attach 任务失败: ' + e.message, true);
2194
- } finally {
2195
- hideLoading();
2196
- }
2197
- }
2198
-
2199
- async function moveCountryJobsToRaw(scope, country, count) {
2200
- const scopeLabel = scope === 'pending' ? '待处理任务' : '待补资料任务';
2201
- const countText = count != null ? `将移动 ${formatStatNum(count)} 条。` : '';
2202
- if (!window.confirm(`确认将 ${country} 的${scopeLabel}移到毛料库吗?${countText} 这些任务会先暂存,不再进入当前处理队列。`)) {
2203
- return;
2204
- }
2205
- showLoading('正在移到毛料库...');
2206
- try {
2207
- const res = await fetch('/api/jobs/move-to-raw', {
2208
- method: 'POST',
2209
- headers: { 'Content-Type': 'application/json' },
2210
- body: JSON.stringify({ scope, country })
2211
- });
2212
- const data = await res.json();
2213
- if (!res.ok || data.error) {
2214
- showToast(data.error || '移到毛料库失败', true);
2215
- return;
2216
- }
2217
- showToast(`${country} 已移到毛料库,共 ${data.moved} 条`);
2218
- await fetchStats();
2219
- if (scope === 'pending') {
2220
- await fetchPendingByCountry();
2221
- } else {
2222
- await fetchUserUpdateByCountry();
2223
- }
2224
- if (!document.getElementById('mainPage').classList.contains('hidden')) {
2225
- fetchUsers();
2226
- }
2227
- } catch (e) {
2228
- showToast('移到毛料库失败: ' + e.message, true);
2229
- } finally {
2230
- hideLoading();
2231
- }
2232
- }
2233
-
2234
- async function fetchRawByCountry() {
2235
- try {
2236
- const res = await fetch('/api/raw-by-country');
2237
- const data = await res.json();
2238
- renderRawCountryGrid(data.countries || []);
2239
- renderRawLocationFilter(data.countries || []);
2240
- } catch (e) {
2241
- console.error('获取毛料库国家分布失败:', e);
2242
- }
2243
- }
2244
-
2245
- function renderRawCountryGrid(countries) {
2246
- const grid = document.getElementById('rawCountryGrid');
2247
- if (!countries.length) {
2248
- grid.innerHTML = '<span style="color:#666;font-size:12px">毛料库暂无数据</span>';
2249
- return;
2250
- }
2251
- const total = countries.reduce((sum, c) => sum + c.count, 0);
2252
- grid.innerHTML = countries.map(c => {
2253
- const pct = ((c.count / total) * 100).toFixed(1);
2254
- const isUnknown = c.country === '未知';
2255
- const safeCountry = escapeJsString(c.country);
2256
- return `
2257
- <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2258
- onclick="filterRawByCountry('${safeCountry}')">
2259
- <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
2260
- <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2261
- <div class="country-count">${c.count}</div>
2262
- <div class="country-label">${pct}% 毛料库</div>
2263
- </div>
2264
- `;
2265
- }).join('');
2266
- }
2267
-
2268
- async function fetchRawJobs() {
2269
- try {
2270
- const params = new URLSearchParams();
2271
- const search = document.getElementById('rawSearchInput').value.trim();
2272
- if (search) params.set('search', search);
2273
- if (currentRawLocation) params.set('location', currentRawLocation);
2274
- params.set('limit', '200');
2275
- const res = await fetch('/api/raw-jobs?' + params.toString());
2276
- const data = await res.json();
2277
- renderRawJobsTable(data.users || []);
2278
- } catch (e) {
2279
- console.error('获取毛料库列表失败:', e);
2280
- }
2281
- }
2282
-
2283
- function renderRawJobsTable(users) {
2284
- const el = document.getElementById('rawTable');
2285
- if (!users.length) {
2286
- el.innerHTML = '<tr><td colspan="10" style="color:#666;text-align:center;padding:24px">暂无毛料库任务</td></tr>';
2287
- return;
2288
- }
2289
- el.innerHTML = users.map(u => {
2290
- const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2291
- const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
2292
- const videos = u.videoCount != null ? u.videoCount : '-';
2293
- const loc = u.locationCreated || '-';
2294
- const guessedLoc = u.guessedLocation || '-';
2295
- const sources = (u.sources || []).join(', ');
2296
- const created = u.createdAt ? formatTime(u.createdAt) : '-';
2297
- const statusTag = u.status || '-';
2298
- return `<tr>
2299
- <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
2300
- <td data-label="昵称">${nick}</td>
2301
- <td data-label="粉丝">${fans}</td>
2302
- <td data-label="视频">${videos}</td>
2303
- <td data-label="国家">${loc}</td>
2304
- <td data-label="猜测国家">${guessedLoc}</td>
2305
- <td data-label="来源">${sources || '-'}</td>
2306
- <td data-label="状态">${statusTag}</td>
2307
- <td data-label="创建时间" style="font-size:11px;color:#888">${created}</td>
2308
- <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>
2309
- </tr>`;
2310
- }).join('');
2311
- }
2312
-
2313
- function renderRawLocationFilter(countries) {
2314
- const sel = document.getElementById('rawLocationFilter');
2315
- if (!sel) return;
2316
- const val = currentRawLocation;
2317
- sel.innerHTML = '<option value="">全部国家</option>' +
2318
- countries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
2319
- }
2320
-
2321
- function filterRawByCountry(country) {
2322
- currentRawLocation = country;
2323
- const sel = document.getElementById('rawLocationFilter');
2324
- if (sel) sel.value = country;
2325
- fetchRawJobs();
2326
- }
2327
-
2328
- function onRawLocationChange() {
2329
- const sel = document.getElementById('rawLocationFilter');
2330
- currentRawLocation = sel.value;
2331
- fetchRawJobs();
2332
- }
2333
-
2334
- function clearRawFilters() {
2335
- currentRawLocation = '';
2336
- const rawSearchInput = document.getElementById('rawSearchInput');
2337
- const rawLocationFilter = document.getElementById('rawLocationFilter');
2338
- if (rawSearchInput) rawSearchInput.value = '';
2339
- if (rawLocationFilter) rawLocationFilter.value = '';
2340
- fetchRawJobs();
2341
- }
2342
-
2343
- async function restoreRawJob(uniqueId) {
2344
- if (!window.confirm(`确认将 @${uniqueId} 从毛料库恢复到 jobs 队列吗?`)) {
2345
- return;
2346
- }
2347
- showLoading('正在恢复...');
2348
- try {
2349
- const res = await fetch('/api/raw-jobs/restore', {
2350
- method: 'POST',
2351
- headers: { 'Content-Type': 'application/json' },
2352
- body: JSON.stringify({ uniqueId })
2353
- });
2354
- const data = await res.json();
2355
- if (!res.ok || data.error) {
2356
- showToast(data.error || '恢复失败', true);
2357
- return;
2358
- }
2359
- showToast(`@${uniqueId} 已恢复到队列`);
2360
- await fetchStats();
2361
- await fetchRawByCountry();
2362
- await fetchRawJobs();
2363
- } catch (e) {
2364
- showToast('恢复失败: ' + e.message, true);
2365
- } finally {
2366
- hideLoading();
2367
- }
2368
- }
2369
-
2370
- async function restoreFilteredRawJobs() {
2371
- const search = document.getElementById('rawSearchInput').value.trim();
2372
- const location = currentRawLocation;
2373
- let desc = '当前筛选条件';
2374
- if (search && location) desc = `搜索="${search}" + 国家=${location}`;
2375
- else if (search) desc = `搜索="${search}"`;
2376
- else if (location) desc = `国家=${location}`;
2377
- if (!window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)) {
2378
- return;
2379
- }
2380
- showLoading('正在恢复筛选任务...');
2381
- try {
2382
- const body = {};
2383
- if (search) body.search = search;
2384
- if (location) body.location = location;
2385
- const res = await fetch('/api/raw-jobs/restore', {
2386
- method: 'POST',
2387
- headers: { 'Content-Type': 'application/json' },
2388
- body: JSON.stringify(body)
2389
- });
2390
- const data = await res.json();
2391
- if (!res.ok || data.error) {
2392
- showToast(data.error || '恢复失败', true);
2393
- return;
2394
- }
2395
- showToast(`已恢复 ${data.restored} 条任务到队列`);
2396
- await fetchStats();
2397
- await fetchRawByCountry();
2398
- await fetchRawJobs();
2399
- } catch (e) {
2400
- showToast('恢复失败: ' + e.message, true);
2401
- } finally {
2402
- hideLoading();
2403
- }
2404
- }
2405
-
2406
- async function restoreRawJobsByCountry(country, count) {
2407
- const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2408
- if (!window.confirm(`确认将 ${country} 从毛料库恢复到 jobs 队列吗?${countText}`)) {
2409
- return;
2410
- }
2411
- showLoading('正在恢复...');
2412
- try {
2413
- const res = await fetch('/api/raw-jobs/restore', {
2414
- method: 'POST',
2415
- headers: { 'Content-Type': 'application/json' },
2416
- body: JSON.stringify({ country })
2417
- });
2418
- const data = await res.json();
2419
- if (!res.ok || data.error) {
2420
- showToast(data.error || '恢复失败', true);
2421
- return;
2422
- }
2423
- showToast(`${country} 已恢复到队列,共 ${data.restored} 条`);
2424
- await fetchStats();
2425
- await fetchRawByCountry();
2426
- await fetchRawJobs();
2427
- } catch (e) {
2428
- showToast('恢复失败: ' + e.message, true);
2429
- } finally {
2430
- hideLoading();
2431
- }
2432
- }
2433
-
2434
- function filterByUserUpdateCountry(country) {
2435
- // 返回主页面,筛选待补资料(通过搜索 tt_seller 为空的用户)
2436
- window.location.hash = '';
2437
- setTimeout(() => {
2438
- const locationFilter = document.getElementById('locationFilter');
2439
- // 待补资料没有独立的状态筛选,这里只设置国家筛选
2440
- if (locationFilter && country && country !== '未知') {
2441
- const options = locationFilter.options;
2442
- for (let i = 0; i < options.length; i++) {
2443
- if (options[i].value === country) {
2444
- locationFilter.value = country;
2445
- onLocationChange();
2446
- break;
2447
- }
2448
- }
2449
- }
2450
- }, 100);
2451
- }
2452
- </script>
274
+ <script src="app.js"></script>
2453
275
  </body>
2454
276
 
2455
277
  </html>