tt-help-cli-ycl 1.3.40 → 1.3.43

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.
@@ -117,6 +117,15 @@
117
117
  background: #25253a;
118
118
  }
119
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
+
120
129
  .charts {
121
130
  display: grid;
122
131
  grid-template-columns: 1fr 1fr;
@@ -530,6 +539,193 @@
530
539
  color: #fff;
531
540
  }
532
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
+
533
729
  .tag.processed {
534
730
  background: #166534;
535
731
  color: #fff;
@@ -711,6 +907,10 @@
711
907
  grid-template-columns: 1fr;
712
908
  }
713
909
 
910
+ .raw-page-layout {
911
+ grid-template-columns: 1fr;
912
+ }
913
+
714
914
  .table-wrap {
715
915
  padding: 10px;
716
916
  }
@@ -840,118 +1040,251 @@
840
1040
  <span class="status" id="lastUpdate">--</span>
841
1041
  </div>
842
1042
  </div>
843
- <div class="stats">
844
- <div class="stat-card">
845
- <div class="label">总用户</div>
846
- <div class="value total" id="statTotal">0</div>
847
- </div>
848
- <div class="stat-card">
849
- <div class="label">处理中</div>
850
- <div class="value total" id="statProcessing">0</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>
851
1081
  </div>
852
- <div class="stat-card">
853
- <div class="label">已完成</div>
854
- <div class="value done" id="statDone">0</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>
855
1103
  </div>
856
- <div class="stat-card">
857
- <div class="label">待处理</div>
858
- <div class="value pending" id="statPending">0</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>
859
1113
  </div>
860
- <div class="stat-card">
861
- <div class="label">错误</div>
862
- <div class="value error" id="statError">0</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>
863
1161
  </div>
864
- <div class="stat-card">
865
- <div class="label">受限</div>
866
- <div class="value error" id="statRestricted">0</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>
867
1184
  </div>
868
- <div class="stat-card">
869
- <div class="label">待补资料</div>
870
- <div class="value target" id="statUserUpdateTasks">0</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>
871
1190
  </div>
872
- <div class="stat-card clickable" id="statTargetCard">
873
- <div class="label">目标商家</div>
874
- <div class="value target" id="statTarget">0</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>
875
1196
  </div>
876
1197
  </div>
877
- <div class="client-errors-section" id="clientErrorsSection" style="display:none">
878
- <div class="section-header">
879
- <h3>客户端异常</h3>
880
- <span class="error-badge" id="clientErrorsBadge">0</span>
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>
881
1219
  </div>
882
- <table class="client-errors-table">
883
- <thead>
884
- <tr>
885
- <th>客户端</th>
886
- <th>错误类型</th>
887
- <th>错误次数</th>
888
- <th>验证码次数</th>
889
- <th>阶段</th>
890
- <th>错误详情</th>
891
- <th>当时处理的 TikTok 用户</th>
892
- <th>时间</th>
893
- <th>操作</th>
894
- </tr>
895
- </thead>
896
- <tbody id="clientErrorsBody"></tbody>
897
- </table>
898
- </div>
899
- <div class="charts">
900
- <div class="chart-box">
901
- <h3>国家统计</h3>
902
- <div id="countryChart"></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>
903
1225
  </div>
904
- <div class="chart-box">
905
- <h3>来源分布</h3>
906
- <div id="sourceChart"></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>
907
1231
  </div>
908
1232
  </div>
909
- <div class="table-wrap">
910
- <h3>用户列表</h3>
911
- <div class="add-users">
912
- <button onclick="openAddModal()">+ 插入队列</button>
913
- </div>
914
- <div id="toast" class="toast" style="display:none"></div>
915
- <div class="controls">
916
- <input type="text" id="searchInput" placeholder="搜索用户名 / 昵称...">
917
- <button data-filter="all" class="active" onclick="setFilter('all')">全部</button>
918
- <button data-filter="processing" onclick="setFilter('processing')">处理中</button>
919
- <button data-filter="pending" onclick="setFilter('pending')">待处理</button>
920
- <button data-filter="done" onclick="setFilter('done')">已完成</button>
921
- <button data-filter="error" onclick="setFilter('error')">错误</button>
922
- <button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
923
- <button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标商家</button>
924
- <select id="targetLocationFilter" onchange="onTargetLocationChange()"
925
- style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;display:none">
926
- <option value="">全部目标国家</option>
927
- </select>
928
- <button id="batchResetBtn" onclick="batchResetErrors()"
929
- 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;
930
- 批量重新处理 (<span id="batchResetCount">0</span>)</button>
931
- <select id="locationFilter" onchange="onLocationChange()"
932
- style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
933
- <option value="">全部国家</option>
934
- </select>
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>
935
1250
  </div>
936
- <div class="table-scroll">
937
- <table>
938
- <thead>
939
- <tr>
940
- <th>用户名</th>
941
- <th>昵称</th>
942
- <th>粉丝</th>
943
- <th>视频</th>
944
- <th>国家</th>
945
- <th>猜测国家</th>
946
- <th>来源</th>
947
- <th>状态</th>
948
- <th>处理端</th>
949
- <th>领取时间</th>
950
- <th>完成时间</th>
951
- </tr>
952
- </thead>
953
- <tbody id="userTable"></tbody>
954
- </table>
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
+ </div>
1269
+ <div class="table-scroll">
1270
+ <table>
1271
+ <thead>
1272
+ <tr>
1273
+ <th>用户名</th>
1274
+ <th>昵称</th>
1275
+ <th>粉丝</th>
1276
+ <th>视频</th>
1277
+ <th>国家</th>
1278
+ <th>猜测国家</th>
1279
+ <th>来源</th>
1280
+ <th>状态</th>
1281
+ <th>创建时间</th>
1282
+ </tr>
1283
+ </thead>
1284
+ <tbody id="rawTable"></tbody>
1285
+ </table>
1286
+ </div>
1287
+ </div>
955
1288
  </div>
956
1289
  </div>
957
1290
  <script>
@@ -961,6 +1294,7 @@
961
1294
  let currentUsers = [];
962
1295
  let currentLocation = '';
963
1296
  let currentTargetLocation = '';
1297
+ let currentRawLocation = '';
964
1298
  let prevStatValues = {};
965
1299
  let prevUserMap = {};
966
1300
 
@@ -1078,6 +1412,30 @@
1078
1412
  flashEl('statRestricted', d.restrictedUsers);
1079
1413
  flashEl('statTarget', d.targetUsers);
1080
1414
  flashEl('statUserUpdateTasks', d.userUpdateTasks || 0);
1415
+ flashEl('statRawJobs', d.rawJobs || 0);
1416
+ // 同步子页面 stats
1417
+ const pendingTotal = document.getElementById('pendingStatTotal');
1418
+ if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
1419
+ const pendingCount = document.getElementById('pendingStatPending');
1420
+ if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
1421
+ const pendingUserUpdate = document.getElementById('pendingStatUserUpdateTasks');
1422
+ if (pendingUserUpdate) pendingUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1423
+ const pendingRawJobs = document.getElementById('pendingStatRawJobs');
1424
+ if (pendingRawJobs) pendingRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1425
+ const userUpdateTotal = document.getElementById('userUpdateStatTotal');
1426
+ if (userUpdateTotal) userUpdateTotal.textContent = formatStatNum(d.totalUsers);
1427
+ const userUpdatePending = document.getElementById('userUpdateStatPending');
1428
+ if (userUpdatePending) userUpdatePending.textContent = formatStatNum(d.pendingUsers);
1429
+ const userUpdateTasks = document.getElementById('userUpdateStatUserUpdateTasks');
1430
+ if (userUpdateTasks) userUpdateTasks.textContent = formatStatNum(d.userUpdateTasks || 0);
1431
+ const userUpdateRawJobs = document.getElementById('userUpdateStatRawJobs');
1432
+ if (userUpdateRawJobs) userUpdateRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1433
+ const rawPageRawJobs = document.getElementById('rawPageStatRawJobs');
1434
+ if (rawPageRawJobs) rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
1435
+ const rawPagePending = document.getElementById('rawPageStatPending');
1436
+ if (rawPagePending) rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
1437
+ const rawPageUserUpdate = document.getElementById('rawPageStatUserUpdateTasks');
1438
+ if (rawPageUserUpdate) rawPageUserUpdate.textContent = formatStatNum(d.userUpdateTasks || 0);
1081
1439
  document.getElementById('lastUpdate').textContent = '\u66f4\u65b0\u4e8e ' + new Date().toLocaleTimeString();
1082
1440
  document.getElementById('fileMeta').textContent =
1083
1441
  formatStatNum(d.processingUsers || 0) +
@@ -1247,6 +1605,12 @@
1247
1605
  searchTimer = setTimeout(fetchUsers, 300);
1248
1606
  });
1249
1607
 
1608
+ let rawSearchTimer = null;
1609
+ document.getElementById('rawSearchInput').addEventListener('input', () => {
1610
+ if (rawSearchTimer) clearTimeout(rawSearchTimer);
1611
+ rawSearchTimer = setTimeout(fetchRawJobs, 300);
1612
+ });
1613
+
1250
1614
  function parseUsernames(raw) {
1251
1615
  return raw.split(/[,,\n\r]+/).map(s => s.replace(/^@/, '').trim()).filter(Boolean);
1252
1616
  }
@@ -1317,13 +1681,24 @@
1317
1681
  }
1318
1682
 
1319
1683
  function showToast(msg, isError) {
1320
- const toast = document.getElementById('toast');
1684
+ let toast = document.getElementById('globalToast') || document.getElementById('toast');
1685
+ if (!toast) {
1686
+ toast = document.createElement('div');
1687
+ toast.id = 'globalToast';
1688
+ toast.className = 'toast';
1689
+ toast.style.display = 'none';
1690
+ document.body.appendChild(toast);
1691
+ }
1321
1692
  toast.textContent = msg;
1322
1693
  toast.className = 'toast' + (isError ? ' error' : '');
1323
1694
  toast.style.display = 'block';
1324
1695
  setTimeout(() => { toast.style.display = 'none'; }, 3000);
1325
1696
  }
1326
1697
 
1698
+ function escapeJsString(str) {
1699
+ return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1700
+ }
1701
+
1327
1702
  document.addEventListener('keydown', e => {
1328
1703
  if (e.key === 'Escape') closeAddModal();
1329
1704
  });
@@ -1502,6 +1877,409 @@
1502
1877
  setInterval(fetchStats, 10000);
1503
1878
  setInterval(fetchUsers, 10000);
1504
1879
  setInterval(fetchClientErrors, 10000);
1880
+
1881
+ // Hash 路由
1882
+ window.addEventListener('hashchange', handleRoute);
1883
+ window.addEventListener('DOMContentLoaded', handleRoute);
1884
+
1885
+ function handleRoute() {
1886
+ const hash = window.location.hash;
1887
+ if (hash === '#pending') {
1888
+ showPendingPage();
1889
+ } else if (hash === '#userUpdate') {
1890
+ showUserUpdatePage();
1891
+ } else if (hash === '#raw') {
1892
+ showRawPage();
1893
+ } else {
1894
+ showMainPage();
1895
+ }
1896
+ }
1897
+
1898
+ function navigateToPending() {
1899
+ window.location.hash = '#pending';
1900
+ }
1901
+
1902
+ function navigateToUserUpdate() {
1903
+ window.location.hash = '#userUpdate';
1904
+ }
1905
+
1906
+ function navigateToRaw() {
1907
+ window.location.hash = '#raw';
1908
+ }
1909
+
1910
+ function navigateToMain() {
1911
+ window.location.hash = '';
1912
+ }
1913
+
1914
+ function showPendingPage() {
1915
+ document.getElementById('mainPage').classList.add('hidden');
1916
+ document.getElementById('pendingPage').classList.add('active');
1917
+ document.getElementById('userUpdatePage').classList.remove('active');
1918
+ document.getElementById('rawPage').classList.remove('active');
1919
+ fetchPendingByCountry();
1920
+ fetchAttachStuckByCountry();
1921
+ }
1922
+
1923
+ function showUserUpdatePage() {
1924
+ document.getElementById('mainPage').classList.add('hidden');
1925
+ document.getElementById('pendingPage').classList.remove('active');
1926
+ document.getElementById('userUpdatePage').classList.add('active');
1927
+ document.getElementById('rawPage').classList.remove('active');
1928
+ fetchUserUpdateByCountry();
1929
+ fetchAttachStuckByCountry();
1930
+ }
1931
+
1932
+ function showRawPage() {
1933
+ document.getElementById('mainPage').classList.add('hidden');
1934
+ document.getElementById('pendingPage').classList.remove('active');
1935
+ document.getElementById('userUpdatePage').classList.remove('active');
1936
+ document.getElementById('rawPage').classList.add('active');
1937
+ fetchRawByCountry();
1938
+ fetchRawJobs();
1939
+ }
1940
+
1941
+ function showMainPage() {
1942
+ document.getElementById('mainPage').classList.remove('hidden');
1943
+ document.getElementById('pendingPage').classList.remove('active');
1944
+ document.getElementById('userUpdatePage').classList.remove('active');
1945
+ document.getElementById('rawPage').classList.remove('active');
1946
+ }
1947
+
1948
+ async function fetchPendingByCountry() {
1949
+ try {
1950
+ const res = await fetch('/api/pending-by-country');
1951
+ const data = await res.json();
1952
+ renderPendingCountryGrid(data.countries || []);
1953
+ } catch (e) {
1954
+ console.error('获取待处理国家分布失败:', e);
1955
+ }
1956
+ }
1957
+
1958
+ function renderPendingCountryGrid(countries) {
1959
+ const grid = document.getElementById('pendingCountryGrid');
1960
+ if (!countries.length) {
1961
+ grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待处理任务</span>';
1962
+ return;
1963
+ }
1964
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
1965
+ grid.innerHTML = countries.map(c => {
1966
+ const pct = ((c.count / total) * 100).toFixed(1);
1967
+ const isUnknown = c.country === '未知';
1968
+ const safeCountry = escapeJsString(c.country);
1969
+ return `
1970
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
1971
+ onclick="filterByPendingCountry('${safeCountry}')">
1972
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1973
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
1974
+ <div class="country-count">${c.count}</div>
1975
+ <div class="country-label">${pct}% 待处理</div>
1976
+ </div>
1977
+ `;
1978
+ }).join('');
1979
+ }
1980
+
1981
+ function filterByPendingCountry(country) {
1982
+ // 返回主页面并设置国家筛选
1983
+ window.location.hash = '';
1984
+ setTimeout(() => {
1985
+ const searchInput = document.getElementById('searchInput');
1986
+ const locationFilter = document.getElementById('locationFilter');
1987
+ // 设置状态筛选为 pending
1988
+ setFilter('pending');
1989
+ // 如果有国家筛选下拉框,设置国家
1990
+ if (locationFilter && country && country !== '未知') {
1991
+ // 检查选项中是否有该国家
1992
+ const options = locationFilter.options;
1993
+ for (let i = 0; i < options.length; i++) {
1994
+ if (options[i].value === country) {
1995
+ locationFilter.value = country;
1996
+ onLocationChange();
1997
+ break;
1998
+ }
1999
+ }
2000
+ }
2001
+ }, 100);
2002
+ }
2003
+
2004
+ async function fetchUserUpdateByCountry() {
2005
+ try {
2006
+ const res = await fetch('/api/user-update-by-country');
2007
+ const data = await res.json();
2008
+ renderUserUpdateCountryGrid(data.countries || []);
2009
+ } catch (e) {
2010
+ console.error('获取待补资料国家分布失败:', e);
2011
+ }
2012
+ }
2013
+
2014
+ function renderUserUpdateCountryGrid(countries) {
2015
+ const grid = document.getElementById('userUpdateCountryGrid');
2016
+ if (!countries.length) {
2017
+ grid.innerHTML = '<span style="color:#666;font-size:12px">暂无待补资料任务</span>';
2018
+ return;
2019
+ }
2020
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
2021
+ grid.innerHTML = countries.map(c => {
2022
+ const pct = ((c.count / total) * 100).toFixed(1);
2023
+ const isUnknown = c.country === '未知';
2024
+ const safeCountry = escapeJsString(c.country);
2025
+ return `
2026
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2027
+ onclick="filterByUserUpdateCountry('${safeCountry}')">
2028
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
2029
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2030
+ <div class="country-count">${c.count}</div>
2031
+ <div class="country-label">${pct}% 待补资料</div>
2032
+ </div>
2033
+ `;
2034
+ }).join('');
2035
+ }
2036
+
2037
+ async function fetchAttachStuckByCountry() {
2038
+ try {
2039
+ const res = await fetch('/api/attach-stuck-by-country');
2040
+ const data = await res.json();
2041
+ renderAttachStuckGrid('pendingAttachStuckGrid', data.countries || []);
2042
+ renderAttachStuckGrid('userUpdateAttachStuckGrid', data.countries || []);
2043
+ } catch (e) {
2044
+ console.error('获取 attach 未成功国家分布失败:', e);
2045
+ }
2046
+ }
2047
+
2048
+ function renderAttachStuckGrid(gridId, countries) {
2049
+ const grid = document.getElementById(gridId);
2050
+ if (!grid) return;
2051
+ if (!countries.length) {
2052
+ grid.innerHTML = '<span style="color:#666;font-size:12px">暂无 attach 未成功任务</span>';
2053
+ return;
2054
+ }
2055
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
2056
+ grid.innerHTML = countries.map(c => {
2057
+ const pct = ((c.count / total) * 100).toFixed(1);
2058
+ const isUnknown = c.country === '未知';
2059
+ const safeCountry = escapeJsString(c.country);
2060
+ return `
2061
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}">
2062
+ <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
2063
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2064
+ <div class="country-count">${c.count}</div>
2065
+ <div class="country-label">${pct}% attach 未成功</div>
2066
+ </div>
2067
+ `;
2068
+ }).join('');
2069
+ }
2070
+
2071
+ async function restoreAttachStuckByCountry(country, count) {
2072
+ const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2073
+ if (!window.confirm(`确认将 ${country} 下 attach 未成功的任务恢复为待补资料吗?${countText}`)) {
2074
+ return;
2075
+ }
2076
+ try {
2077
+ const res = await fetch('/api/attach-stuck/restore', {
2078
+ method: 'POST',
2079
+ headers: { 'Content-Type': 'application/json' },
2080
+ body: JSON.stringify({ country })
2081
+ });
2082
+ const data = await res.json();
2083
+ if (!res.ok || data.error) {
2084
+ showToast(data.error || '恢复 attach 任务失败', true);
2085
+ return;
2086
+ }
2087
+ showToast(`${country} 的 attach 未成功任务已恢复,共 ${data.restored} 条`);
2088
+ await fetchStats();
2089
+ await fetchPendingByCountry();
2090
+ await fetchUserUpdateByCountry();
2091
+ await fetchAttachStuckByCountry();
2092
+ if (!document.getElementById('rawPage').classList.contains('active')) {
2093
+ fetchUsers();
2094
+ }
2095
+ } catch (e) {
2096
+ showToast('恢复 attach 任务失败: ' + e.message, true);
2097
+ }
2098
+ }
2099
+
2100
+ async function moveCountryJobsToRaw(scope, country, count) {
2101
+ const scopeLabel = scope === 'pending' ? '待处理任务' : '待补资料任务';
2102
+ const countText = count != null ? `将移动 ${formatStatNum(count)} 条。` : '';
2103
+ if (!window.confirm(`确认将 ${country} 的${scopeLabel}移到毛料库吗?${countText} 这些任务会先暂存,不再进入当前处理队列。`)) {
2104
+ return;
2105
+ }
2106
+ try {
2107
+ const res = await fetch('/api/jobs/move-to-raw', {
2108
+ method: 'POST',
2109
+ headers: { 'Content-Type': 'application/json' },
2110
+ body: JSON.stringify({ scope, country })
2111
+ });
2112
+ const data = await res.json();
2113
+ if (!res.ok || data.error) {
2114
+ showToast(data.error || '移到毛料库失败', true);
2115
+ return;
2116
+ }
2117
+ showToast(`${country} 已移到毛料库,共 ${data.moved} 条`);
2118
+ await fetchStats();
2119
+ if (scope === 'pending') {
2120
+ await fetchPendingByCountry();
2121
+ } else {
2122
+ await fetchUserUpdateByCountry();
2123
+ }
2124
+ if (!document.getElementById('mainPage').classList.contains('hidden')) {
2125
+ fetchUsers();
2126
+ }
2127
+ } catch (e) {
2128
+ showToast('移到毛料库失败: ' + e.message, true);
2129
+ }
2130
+ }
2131
+
2132
+ async function fetchRawByCountry() {
2133
+ try {
2134
+ const res = await fetch('/api/raw-by-country');
2135
+ const data = await res.json();
2136
+ renderRawCountryGrid(data.countries || []);
2137
+ renderRawLocationFilter(data.countries || []);
2138
+ } catch (e) {
2139
+ console.error('获取毛料库国家分布失败:', e);
2140
+ }
2141
+ }
2142
+
2143
+ function renderRawCountryGrid(countries) {
2144
+ const grid = document.getElementById('rawCountryGrid');
2145
+ if (!countries.length) {
2146
+ grid.innerHTML = '<span style="color:#666;font-size:12px">毛料库暂无数据</span>';
2147
+ return;
2148
+ }
2149
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
2150
+ grid.innerHTML = countries.map(c => {
2151
+ const pct = ((c.count / total) * 100).toFixed(1);
2152
+ const isUnknown = c.country === '未知';
2153
+ const safeCountry = escapeJsString(c.country);
2154
+ return `
2155
+ <div class="pending-country-item${isUnknown ? '' : ' has-target'}"
2156
+ onclick="filterRawByCountry('${safeCountry}')">
2157
+ <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
2158
+ <div class="country-name">${isUnknown ? '🌍 ' : ''}${c.country}</div>
2159
+ <div class="country-count">${c.count}</div>
2160
+ <div class="country-label">${pct}% 毛料库</div>
2161
+ </div>
2162
+ `;
2163
+ }).join('');
2164
+ }
2165
+
2166
+ async function fetchRawJobs() {
2167
+ try {
2168
+ const params = new URLSearchParams();
2169
+ const search = document.getElementById('rawSearchInput').value.trim();
2170
+ if (search) params.set('search', search);
2171
+ if (currentRawLocation) params.set('location', currentRawLocation);
2172
+ params.set('limit', '200');
2173
+ const res = await fetch('/api/raw-jobs?' + params.toString());
2174
+ const data = await res.json();
2175
+ renderRawJobsTable(data.users || []);
2176
+ } catch (e) {
2177
+ console.error('获取毛料库列表失败:', e);
2178
+ }
2179
+ }
2180
+
2181
+ function renderRawJobsTable(users) {
2182
+ const el = document.getElementById('rawTable');
2183
+ if (!users.length) {
2184
+ el.innerHTML = '<tr><td colspan="9" style="color:#666;text-align:center;padding:24px">暂无毛料库任务</td></tr>';
2185
+ return;
2186
+ }
2187
+ el.innerHTML = users.map(u => {
2188
+ const nick = (u.nickname || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2189
+ const fans = u.followerCount != null ? formatNum(u.followerCount) : '-';
2190
+ const videos = u.videoCount != null ? u.videoCount : '-';
2191
+ const loc = u.locationCreated || '-';
2192
+ const guessedLoc = u.guessedLocation || '-';
2193
+ const sources = (u.sources || []).join(', ');
2194
+ const created = u.createdAt ? formatTime(u.createdAt) : '-';
2195
+ const statusTag = u.status || '-';
2196
+ return `<tr>
2197
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
2198
+ <td data-label="昵称">${nick}</td>
2199
+ <td data-label="粉丝">${fans}</td>
2200
+ <td data-label="视频">${videos}</td>
2201
+ <td data-label="国家">${loc}</td>
2202
+ <td data-label="猜测国家">${guessedLoc}</td>
2203
+ <td data-label="来源">${sources || '-'}</td>
2204
+ <td data-label="状态">${statusTag}</td>
2205
+ <td data-label="创建时间" style="font-size:11px;color:#888">${created}</td>
2206
+ </tr>`;
2207
+ }).join('');
2208
+ }
2209
+
2210
+ function renderRawLocationFilter(countries) {
2211
+ const sel = document.getElementById('rawLocationFilter');
2212
+ if (!sel) return;
2213
+ const val = currentRawLocation;
2214
+ sel.innerHTML = '<option value="">全部国家</option>' +
2215
+ countries.map(c => `<option value="${c.country}"${val === c.country ? ' selected' : ''}>${c.country} (${c.count})</option>`).join('');
2216
+ }
2217
+
2218
+ function filterRawByCountry(country) {
2219
+ currentRawLocation = country;
2220
+ const sel = document.getElementById('rawLocationFilter');
2221
+ if (sel) sel.value = country;
2222
+ fetchRawJobs();
2223
+ }
2224
+
2225
+ function onRawLocationChange() {
2226
+ const sel = document.getElementById('rawLocationFilter');
2227
+ currentRawLocation = sel.value;
2228
+ fetchRawJobs();
2229
+ }
2230
+
2231
+ function clearRawFilters() {
2232
+ currentRawLocation = '';
2233
+ const rawSearchInput = document.getElementById('rawSearchInput');
2234
+ const rawLocationFilter = document.getElementById('rawLocationFilter');
2235
+ if (rawSearchInput) rawSearchInput.value = '';
2236
+ if (rawLocationFilter) rawLocationFilter.value = '';
2237
+ fetchRawJobs();
2238
+ }
2239
+
2240
+ async function restoreRawJobsByCountry(country, count) {
2241
+ const countText = count != null ? `将恢复 ${formatStatNum(count)} 条。` : '';
2242
+ if (!window.confirm(`确认将 ${country} 从毛料库恢复到 jobs 队列吗?${countText}`)) {
2243
+ return;
2244
+ }
2245
+ try {
2246
+ const res = await fetch('/api/raw-jobs/restore', {
2247
+ method: 'POST',
2248
+ headers: { 'Content-Type': 'application/json' },
2249
+ body: JSON.stringify({ country })
2250
+ });
2251
+ const data = await res.json();
2252
+ if (!res.ok || data.error) {
2253
+ showToast(data.error || '恢复失败', true);
2254
+ return;
2255
+ }
2256
+ showToast(`${country} 已恢复到队列,共 ${data.restored} 条`);
2257
+ await fetchStats();
2258
+ await fetchRawByCountry();
2259
+ await fetchRawJobs();
2260
+ } catch (e) {
2261
+ showToast('恢复失败: ' + e.message, true);
2262
+ }
2263
+ }
2264
+
2265
+ function filterByUserUpdateCountry(country) {
2266
+ // 返回主页面,筛选待补资料(通过搜索 tt_seller 为空的用户)
2267
+ window.location.hash = '';
2268
+ setTimeout(() => {
2269
+ const locationFilter = document.getElementById('locationFilter');
2270
+ // 待补资料没有独立的状态筛选,这里只设置国家筛选
2271
+ if (locationFilter && country && country !== '未知') {
2272
+ const options = locationFilter.options;
2273
+ for (let i = 0; i < options.length; i++) {
2274
+ if (options[i].value === country) {
2275
+ locationFilter.value = country;
2276
+ onLocationChange();
2277
+ break;
2278
+ }
2279
+ }
2280
+ }
2281
+ }, 100);
2282
+ }
1505
2283
  </script>
1506
2284
  </body>
1507
2285