tt-help-cli-ycl 1.3.62 → 1.3.64

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.
@@ -368,6 +368,10 @@ function renderTable(users) {
368
368
  }
369
369
  const claimTime = u.claimedAt ? formatTime(u.claimedAt) : "-";
370
370
  const procTime = u.processedAt ? formatTime(u.processedAt) : "-";
371
+ const refreshTime =
372
+ u.ttSeller && !u.verified && u.refreshTime
373
+ ? formatTime(u.refreshTime)
374
+ : "-";
371
375
  const statusCodeDisplay =
372
376
  u.statusCode != null && u.statusCode !== 0
373
377
  ? `<span class="tag error" style="font-size:10px">${u.statusCode}</span>`
@@ -378,6 +382,7 @@ function renderTable(users) {
378
382
  <td data-label="粉丝">${fans}</td>
379
383
  <td data-label="视频">${videos}</td>
380
384
  <td data-label="国家">${loc}</td>
385
+ <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
381
386
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
382
387
  <td data-label="猜测国家">${guessedLoc}</td>
383
388
  <td data-label="来源">${sources || "-"}</td>
@@ -738,44 +743,9 @@ async function batchResetErrors() {
738
743
  }
739
744
  }
740
745
 
741
- document
742
- .getElementById("statTargetCard")
743
- .addEventListener("click", async () => {
744
- showLoading("正在导出目标用户...");
745
- try {
746
- const res = await fetch("/api/target-users", {
747
- headers: { Accept: "text/csv" },
748
- });
749
- if (!res.ok) throw new Error("HTTP " + res.status);
750
- const blob = await res.blob();
751
- const ext = blob.size < 200 ? "json" : "csv";
752
- if (ext === "json") {
753
- const text = await blob.text();
754
- const data = JSON.parse(text);
755
- if (!data.users.length) {
756
- showToast("暂无目标用户", true);
757
- return;
758
- }
759
- if (navigator.clipboard && navigator.clipboard.writeText) {
760
- const ids = data.users.map((u) => "@" + u.uniqueId).join(", ");
761
- await navigator.clipboard.writeText(ids);
762
- showToast(data.users.length + " 个目标用户 ID 已复制到剪贴板");
763
- }
764
- return;
765
- }
766
- const url = URL.createObjectURL(blob);
767
- const a = document.createElement("a");
768
- a.href = url;
769
- a.download = "target-users.csv";
770
- a.click();
771
- URL.revokeObjectURL(url);
772
- showToast("CSV 文件已开始下载");
773
- } catch (e) {
774
- showToast("获取失败: " + e.message, true);
775
- } finally {
776
- hideLoading();
777
- }
778
- });
746
+ document.getElementById("statTargetCard").addEventListener("click", () => {
747
+ navigateToTarget();
748
+ });
779
749
 
780
750
  // Hash 路由
781
751
  window.addEventListener("hashchange", handleRoute);
@@ -789,6 +759,8 @@ function handleRoute() {
789
759
  showUserUpdatePage();
790
760
  } else if (hash === "#raw") {
791
761
  showRawPage();
762
+ } else if (hash === "#target") {
763
+ showTargetPage();
792
764
  } else {
793
765
  showMainPage();
794
766
  }
@@ -815,6 +787,7 @@ function showPendingPage() {
815
787
  document.getElementById("pendingPage").classList.add("active");
816
788
  document.getElementById("userUpdatePage").classList.remove("active");
817
789
  document.getElementById("rawPage").classList.remove("active");
790
+ document.getElementById("targetPage").classList.remove("active");
818
791
  fetchPendingByCountry();
819
792
  fetchAttachStuckByCountry();
820
793
  }
@@ -824,6 +797,7 @@ function showUserUpdatePage() {
824
797
  document.getElementById("pendingPage").classList.remove("active");
825
798
  document.getElementById("userUpdatePage").classList.add("active");
826
799
  document.getElementById("rawPage").classList.remove("active");
800
+ document.getElementById("targetPage").classList.remove("active");
827
801
  fetchUserUpdateByCountry();
828
802
  fetchAttachStuckByCountry();
829
803
  }
@@ -840,6 +814,7 @@ function showRawPage() {
840
814
  document.getElementById("pendingPage").classList.remove("active");
841
815
  document.getElementById("userUpdatePage").classList.remove("active");
842
816
  document.getElementById("rawPage").classList.add("active");
817
+ document.getElementById("targetPage").classList.remove("active");
843
818
  fetchRawByCountry();
844
819
  fetchRawJobs();
845
820
  }
@@ -849,8 +824,219 @@ function showMainPage() {
849
824
  document.getElementById("pendingPage").classList.remove("active");
850
825
  document.getElementById("userUpdatePage").classList.remove("active");
851
826
  document.getElementById("rawPage").classList.remove("active");
827
+ document.getElementById("targetPage").classList.remove("active");
828
+ }
829
+
830
+ function navigateToTarget() {
831
+ window.location.hash = "#target";
832
+ }
833
+
834
+ function showTargetPage() {
835
+ document.getElementById("mainPage").classList.add("hidden");
836
+ document.getElementById("pendingPage").classList.remove("active");
837
+ document.getElementById("userUpdatePage").classList.remove("active");
838
+ document.getElementById("rawPage").classList.remove("active");
839
+ document.getElementById("targetPage").classList.add("active");
840
+ fetchTargetByCountry();
841
+ // 同步统计
842
+ if (currentStats) {
843
+ document.getElementById("targetPageStatTotal").textContent = formatStatNum(
844
+ currentStats.targetUsers || 0,
845
+ { full: true },
846
+ );
847
+ }
848
+ }
849
+
850
+ let currentTargetData = null;
851
+
852
+ async function fetchTargetByCountry() {
853
+ try {
854
+ const res = await fetch("/api/target-users-by-country");
855
+ const data = await res.json();
856
+ currentTargetData = data;
857
+ renderTargetCountryGrid(data.countries || []);
858
+ renderTargetLocationFilter(data.countries || []);
859
+ renderTargetTable(data.countries || []);
860
+ document.getElementById("targetPageStatTotal").textContent = formatStatNum(
861
+ data.total || 0,
862
+ { full: true },
863
+ );
864
+ document.getElementById("targetPageStatCountries").textContent = (
865
+ data.countries || []
866
+ ).length;
867
+ } catch (e) {
868
+ console.error("获取目标商家数据失败:", e);
869
+ }
870
+ }
871
+
872
+ function renderTargetCountryGrid(countries) {
873
+ const grid = document.getElementById("targetCountryGrid");
874
+ if (!countries.length) {
875
+ grid.innerHTML =
876
+ '<span style="color:#666;font-size:12px">暂无目标商家</span>';
877
+ return;
878
+ }
879
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
880
+ grid.innerHTML = countries
881
+ .map((c) => {
882
+ const pct = ((c.count / total) * 100).toFixed(1);
883
+ const safeCountry = escapeJsString(c.country);
884
+ return `
885
+ <div class="pending-country-item has-target"
886
+ onclick="filterTargetByCountry('${safeCountry}')">
887
+ <div class="country-name">${c.country}</div>
888
+ <div class="country-count">${c.count}</div>
889
+ <div class="country-label">${pct}% 目标商家</div>
890
+ </div>
891
+ `;
892
+ })
893
+ .join("");
894
+ }
895
+
896
+ function renderTargetLocationFilter(countries) {
897
+ const sel = document.getElementById("targetLocationFilter");
898
+ if (!sel) return;
899
+ const val = sel.value;
900
+ sel.innerHTML =
901
+ '<option value="">全部国家</option>' +
902
+ countries
903
+ .map(
904
+ (c) =>
905
+ `<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
906
+ )
907
+ .join("");
908
+ }
909
+
910
+ function renderTargetTable(countries) {
911
+ const el = document.getElementById("targetTable");
912
+ const search = document.getElementById("targetSearchInput")
913
+ ? document.getElementById("targetSearchInput").value.trim().toLowerCase()
914
+ : "";
915
+ const location = currentTargetLocation;
916
+
917
+ let allUsers = [];
918
+ for (const c of countries) {
919
+ if (location && c.country !== location) continue;
920
+ allUsers = allUsers.concat(c.users);
921
+ }
922
+
923
+ if (search) {
924
+ allUsers = allUsers.filter(
925
+ (u) =>
926
+ (u.uniqueId || "").toLowerCase().includes(search) ||
927
+ (u.nickname || "").toLowerCase().includes(search),
928
+ );
929
+ }
930
+
931
+ if (!allUsers.length) {
932
+ el.innerHTML =
933
+ '<tr><td colspan="8" style="color:#666;text-align:center;padding:24px">暂无目标商家</td></tr>';
934
+ return;
935
+ }
936
+
937
+ el.innerHTML = allUsers
938
+ .map((u) => {
939
+ const nick = (u.nickname || "")
940
+ .replace(/</g, "&lt;")
941
+ .replace(/>/g, "&gt;");
942
+ const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
943
+ const videos = u.videoCount != null ? u.videoCount : "-";
944
+ const latestVideo = u.latestVideoTime
945
+ ? formatTime(u.latestVideoTime * 1000)
946
+ : "-";
947
+ const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
948
+ const sources = (u.sources || []).join(", ");
949
+
950
+ let statusTag = "";
951
+ if (u.status === "done")
952
+ statusTag = '<span class="tag processed">已完成</span>';
953
+ else if (u.status === "processing")
954
+ statusTag = '<span class="tag processing">处理中</span>';
955
+ else if (u.status === "pending")
956
+ statusTag = '<span class="tag pending">待处理</span>';
957
+ else if (u.status === "error")
958
+ statusTag = '<span class="tag error">错误</span>';
959
+ else if (u.status === "restricted")
960
+ statusTag = '<span class="tag error">受限</span>';
961
+ else statusTag = u.status || "-";
962
+
963
+ return `<tr>
964
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
965
+ <td data-label="昵称">${nick}</td>
966
+ <td data-label="粉丝">${fans}</td>
967
+ <td data-label="视频">${videos}</td>
968
+ <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
969
+ <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
970
+ <td data-label="来源">${sources || "-"}</td>
971
+ <td data-label="状态">${statusTag}</td>
972
+ </tr>`;
973
+ })
974
+ .join("");
975
+ }
976
+
977
+ function filterTargetByCountry(country) {
978
+ currentTargetLocation = country;
979
+ const sel = document.getElementById("targetLocationFilter");
980
+ if (sel) sel.value = country;
981
+ if (currentTargetData) {
982
+ renderTargetTable(currentTargetData.countries || []);
983
+ }
852
984
  }
853
985
 
986
+ function onTargetLocationChange() {
987
+ const sel = document.getElementById("targetLocationFilter");
988
+ currentTargetLocation = sel.value;
989
+ if (currentTargetData) {
990
+ renderTargetTable(currentTargetData.countries || []);
991
+ }
992
+ }
993
+
994
+ function clearTargetFilters() {
995
+ currentTargetLocation = "";
996
+ const searchInput = document.getElementById("targetSearchInput");
997
+ const locationFilter = document.getElementById("targetLocationFilter");
998
+ if (searchInput) searchInput.value = "";
999
+ if (locationFilter) locationFilter.value = "";
1000
+ if (currentTargetData) {
1001
+ renderTargetTable(currentTargetData.countries || []);
1002
+ }
1003
+ }
1004
+
1005
+ let targetSearchTimer = null;
1006
+
1007
+ document.getElementById("targetSearchInput").addEventListener("input", () => {
1008
+ if (targetSearchTimer) clearTimeout(targetSearchTimer);
1009
+ targetSearchTimer = setTimeout(() => {
1010
+ if (currentTargetData) {
1011
+ renderTargetTable(currentTargetData.countries || []);
1012
+ }
1013
+ }, 300);
1014
+ });
1015
+
1016
+ document
1017
+ .getElementById("exportTargetCsvBtn")
1018
+ .addEventListener("click", async () => {
1019
+ showLoading("正在导出目标用户...");
1020
+ try {
1021
+ const res = await fetch("/api/target-users-by-country", {
1022
+ headers: { Accept: "text/csv" },
1023
+ });
1024
+ if (!res.ok) throw new Error("HTTP " + res.status);
1025
+ const blob = await res.blob();
1026
+ const url = URL.createObjectURL(blob);
1027
+ const a = document.createElement("a");
1028
+ a.href = url;
1029
+ a.download = "target-users-by-country.csv";
1030
+ a.click();
1031
+ URL.revokeObjectURL(url);
1032
+ showToast("CSV 文件已开始下载");
1033
+ } catch (e) {
1034
+ showToast("导出失败: " + e.message, true);
1035
+ } finally {
1036
+ hideLoading();
1037
+ }
1038
+ });
1039
+
854
1040
  async function fetchPendingByCountry() {
855
1041
  try {
856
1042
  const res = await fetch("/api/pending-by-country");
@@ -877,7 +1063,10 @@ function renderPendingCountryGrid(countries) {
877
1063
  return `
878
1064
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
879
1065
  onclick="filterByPendingCountry('${safeCountry}')">
880
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1066
+ <div class="country-action-btns">
1067
+ <button class="country-action-btn restore" title="重置为需要预处理" onclick="event.stopPropagation(); resetPendingByCountry('${safeCountry}', ${c.count})">↺</button>
1068
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1069
+ </div>
881
1070
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
882
1071
  <div class="country-count">${c.count}</div>
883
1072
  <div class="country-label">${pct}% 待处理</div>
@@ -931,7 +1120,9 @@ function renderUserUpdateCountryGrid(countries) {
931
1120
  return `
932
1121
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
933
1122
  onclick="filterByUserUpdateCountry('${safeCountry}')">
934
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
1123
+ <div class="country-action-btns">
1124
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
1125
+ </div>
935
1126
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
936
1127
  <div class="country-count">${c.count}</div>
937
1128
  <div class="country-label">${pct}% 待补资料</div>
@@ -968,7 +1159,9 @@ function renderAttachStuckGrid(gridId, countries) {
968
1159
  const safeCountry = escapeJsString(c.country);
969
1160
  return `
970
1161
  <div class="pending-country-item${isUnknown ? "" : " has-target"}">
971
- <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
1162
+ <div class="country-action-btns">
1163
+ <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
1164
+ </div>
972
1165
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
973
1166
  <div class="country-count">${c.count}</div>
974
1167
  <div class="country-label">${pct}% attach 未成功</div>
@@ -1053,6 +1246,40 @@ async function moveCountryJobsToRaw(scope, country, count) {
1053
1246
  }
1054
1247
  }
1055
1248
 
1249
+ async function resetPendingByCountry(country, count) {
1250
+ const countText = count != null ? `将重置 ${formatStatNum(count)} 条。` : "";
1251
+ if (
1252
+ !window.confirm(
1253
+ `确认将 ${country} 下已预处理的待处理任务重置为需要预处理吗?${countText}`,
1254
+ )
1255
+ ) {
1256
+ return;
1257
+ }
1258
+ showLoading("正在重置...");
1259
+ try {
1260
+ const res = await fetch("/api/pending-by-country/reset", {
1261
+ method: "POST",
1262
+ headers: { "Content-Type": "application/json" },
1263
+ body: JSON.stringify({ country }),
1264
+ });
1265
+ const data = await res.json();
1266
+ if (!res.ok || data.error) {
1267
+ showToast(data.error || "重置失败", true);
1268
+ return;
1269
+ }
1270
+ showToast(`${country} 的待处理任务已重置,共 ${data.reset} 条`);
1271
+ await fetchStats();
1272
+ await fetchPendingByCountry();
1273
+ if (!document.getElementById("mainPage").classList.contains("hidden")) {
1274
+ fetchUsers();
1275
+ }
1276
+ } catch (e) {
1277
+ showToast("重置失败: " + e.message, true);
1278
+ } finally {
1279
+ hideLoading();
1280
+ }
1281
+ }
1282
+
1056
1283
  async function fetchRawByCountry() {
1057
1284
  try {
1058
1285
  const res = await fetch("/api/raw-by-country");
@@ -1080,7 +1307,9 @@ function renderRawCountryGrid(countries) {
1080
1307
  return `
1081
1308
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
1082
1309
  onclick="filterRawByCountry('${safeCountry}')">
1083
- <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
1310
+ <div class="country-action-btns">
1311
+ <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
1312
+ </div>
1084
1313
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
1085
1314
  <div class="country-count">${c.count}</div>
1086
1315
  <div class="country-label">${pct}% 毛料库</div>
@@ -1096,6 +1325,11 @@ async function fetchRawJobs() {
1096
1325
  const search = document.getElementById("rawSearchInput").value.trim();
1097
1326
  if (search) params.set("search", search);
1098
1327
  if (currentRawLocation) params.set("location", currentRawLocation);
1328
+ const videoFilter = document.getElementById("rawVideoFilter");
1329
+ if (videoFilter && videoFilter.checked) params.set("hasVideo", "1");
1330
+ const followerFilter = document.getElementById("rawFollowerFilter");
1331
+ if (followerFilter && followerFilter.checked)
1332
+ params.set("hasFollower", "1");
1099
1333
  params.set("limit", "200");
1100
1334
  const res = await fetch("/api/raw-jobs?" + params.toString());
1101
1335
  const data = await res.json();
@@ -1171,8 +1405,16 @@ function clearRawFilters() {
1171
1405
  currentRawLocation = "";
1172
1406
  const rawSearchInput = document.getElementById("rawSearchInput");
1173
1407
  const rawLocationFilter = document.getElementById("rawLocationFilter");
1408
+ const rawVideoFilter = document.getElementById("rawVideoFilter");
1409
+ const rawFollowerFilter = document.getElementById("rawFollowerFilter");
1174
1410
  if (rawSearchInput) rawSearchInput.value = "";
1175
1411
  if (rawLocationFilter) rawLocationFilter.value = "";
1412
+ if (rawVideoFilter) rawVideoFilter.checked = false;
1413
+ if (rawFollowerFilter) rawFollowerFilter.checked = false;
1414
+ fetchRawJobs();
1415
+ }
1416
+
1417
+ function onRawFilterChange() {
1176
1418
  fetchRawJobs();
1177
1419
  }
1178
1420
 
@@ -1206,10 +1448,20 @@ async function restoreRawJob(uniqueId) {
1206
1448
  async function restoreFilteredRawJobs() {
1207
1449
  const search = document.getElementById("rawSearchInput").value.trim();
1208
1450
  const location = currentRawLocation;
1451
+ const videoFilter = document.getElementById("rawVideoFilter");
1452
+ const hasVideo = videoFilter && videoFilter.checked;
1453
+ const followerFilter = document.getElementById("rawFollowerFilter");
1454
+ const hasFollower = followerFilter && followerFilter.checked;
1209
1455
  let desc = "当前筛选条件";
1210
1456
  if (search && location) desc = `搜索="${search}" + 国家=${location}`;
1211
1457
  else if (search) desc = `搜索="${search}"`;
1212
1458
  else if (location) desc = `国家=${location}`;
1459
+ if (hasVideo || hasFollower) {
1460
+ const tags = [];
1461
+ if (hasVideo) tags.push("有视频");
1462
+ if (hasFollower) tags.push("有粉丝");
1463
+ desc += ` + ${tags.join("、")}`;
1464
+ }
1213
1465
  if (
1214
1466
  !window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)
1215
1467
  ) {
@@ -1220,6 +1472,8 @@ async function restoreFilteredRawJobs() {
1220
1472
  const body = {};
1221
1473
  if (search) body.search = search;
1222
1474
  if (location) body.location = location;
1475
+ if (hasVideo) body.hasVideo = true;
1476
+ if (hasFollower) body.hasFollower = true;
1223
1477
  const res = await fetch("/api/raw-jobs/restore", {
1224
1478
  method: "POST",
1225
1479
  headers: { "Content-Type": "application/json" },
@@ -125,6 +125,7 @@
125
125
  <th>粉丝</th>
126
126
  <th>视频</th>
127
127
  <th>国家</th>
128
+ <th class="sortable" data-sort="refreshTime">最近刷新 <span class="sort-icon">↕</span></th>
128
129
  <th class="sortable" data-sort="latestVideoTime">最近发布 <span class="sort-icon">↕</span></th>
129
130
  <th>猜测国家</th>
130
131
  <th>来源</th>
@@ -244,6 +245,13 @@
244
245
  style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
245
246
  <option value="">全部国家</option>
246
247
  </select>
248
+ <label style="display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#999;cursor:pointer;">
249
+ <input type="checkbox" id="rawVideoFilter" onchange="onRawFilterChange()" style="accent-color:#22c55e;"> 有视频
250
+ </label>
251
+ <label style="display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#999;cursor:pointer;">
252
+ <input type="checkbox" id="rawFollowerFilter" onchange="onRawFilterChange()" style="accent-color:#22c55e;">
253
+ 有粉丝
254
+ </label>
247
255
  <button onclick="clearRawFilters()">清空筛选</button>
248
256
  <button onclick="restoreFilteredRawJobs()"
249
257
  style="background:#22c55e;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;">恢复当前筛选到
@@ -271,6 +279,61 @@
271
279
  </div>
272
280
  </div>
273
281
  </div>
282
+ <div id="targetPage">
283
+ <div class="stats" style="margin-bottom:16px">
284
+ <div class="stat-card">
285
+ <div class="label">目标商家</div>
286
+ <div class="value target" id="targetPageStatTotal">0</div>
287
+ </div>
288
+ <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(167,139,250,0.1)">
289
+ <div class="label">← 返回主页面</div>
290
+ </div>
291
+ <div class="stat-card">
292
+ <div class="label">目标国家</div>
293
+ <div class="value" id="targetPageStatCountries" style="font-size:17px;color:#a78bfa">0</div>
294
+ </div>
295
+ <div class="stat-card clickable" id="exportTargetCsvBtn" style="background:rgba(34,197,94,0.12);cursor:pointer">
296
+ <div class="label">导出 CSV</div>
297
+ </div>
298
+ </div>
299
+ <div class="target-page-layout">
300
+ <div class="target-side-card">
301
+ <h3>🎯 目标商家国家分布</h3>
302
+ <div class="pending-country-grid" id="targetCountryGrid">
303
+ <span style="color:#666;font-size:12px">加载中...</span>
304
+ </div>
305
+ <div class="muted-tip">点击国家卡片可筛选列表。</div>
306
+ </div>
307
+ <div class="target-table-card">
308
+ <h3>目标商家列表</h3>
309
+ <div class="controls">
310
+ <input type="text" id="targetSearchInput" placeholder="搜索目标商家用户名 / 昵称...">
311
+ <select id="targetLocationFilter" onchange="onTargetLocationChange()"
312
+ style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
313
+ <option value="">全部国家</option>
314
+ </select>
315
+ <button onclick="clearTargetFilters()">清空筛选</button>
316
+ </div>
317
+ <div class="table-scroll">
318
+ <table>
319
+ <thead>
320
+ <tr>
321
+ <th>用户名</th>
322
+ <th>昵称</th>
323
+ <th>粉丝</th>
324
+ <th>视频</th>
325
+ <th>最近发布</th>
326
+ <th>最近刷新</th>
327
+ <th>来源</th>
328
+ <th>状态</th>
329
+ </tr>
330
+ </thead>
331
+ <tbody id="targetTable"></tbody>
332
+ </table>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ </div>
274
337
  <script src="app.js"></script>
275
338
  </body>
276
339
 
@@ -634,6 +634,14 @@ td.user-id:hover {
634
634
  display: block;
635
635
  }
636
636
 
637
+ #targetPage {
638
+ display: none;
639
+ }
640
+
641
+ #targetPage.active {
642
+ display: block;
643
+ }
644
+
637
645
  #mainPage.hidden {
638
646
  display: none;
639
647
  }
@@ -670,10 +678,15 @@ td.user-id:hover {
670
678
  position: relative;
671
679
  }
672
680
 
673
- .country-action-btn {
681
+ .country-action-btns {
674
682
  position: absolute;
675
- top: 10px;
676
- right: 10px;
683
+ top: 8px;
684
+ right: 8px;
685
+ display: flex;
686
+ gap: 4px;
687
+ }
688
+
689
+ .country-action-btn {
677
690
  width: 28px;
678
691
  height: 28px;
679
692
  border: 1px solid rgba(248, 113, 113, 0.35);
@@ -937,6 +950,66 @@ td.user-id:hover {
937
950
  color: #a78bfa;
938
951
  }
939
952
 
953
+ .target-country-group {
954
+ background: #1a1a24;
955
+ border-radius: 8px;
956
+ padding: 20px;
957
+ margin-bottom: 20px;
958
+ }
959
+
960
+ .target-country-header {
961
+ display: flex;
962
+ align-items: center;
963
+ gap: 12px;
964
+ margin-bottom: 16px;
965
+ padding-bottom: 12px;
966
+ border-bottom: 1px solid #2a2a3a;
967
+ }
968
+
969
+ .target-country-header .country-flag {
970
+ font-size: 20px;
971
+ font-weight: 700;
972
+ color: #a78bfa;
973
+ }
974
+
975
+ .target-country-header .country-user-count {
976
+ font-size: 13px;
977
+ color: #888;
978
+ background: #2a2a3a;
979
+ padding: 2px 10px;
980
+ border-radius: 10px;
981
+ }
982
+
983
+ .target-page-layout {
984
+ display: grid;
985
+ grid-template-columns: 320px 1fr;
986
+ gap: 16px;
987
+ }
988
+
989
+ .target-side-card {
990
+ background: #1a1a24;
991
+ border-radius: 8px;
992
+ padding: 16px;
993
+ }
994
+
995
+ .target-side-card h3 {
996
+ font-size: 14px;
997
+ color: #a78bfa;
998
+ margin-bottom: 12px;
999
+ }
1000
+
1001
+ .target-table-card {
1002
+ background: #1a1a24;
1003
+ border-radius: 8px;
1004
+ padding: 16px;
1005
+ }
1006
+
1007
+ .target-table-card h3 {
1008
+ font-size: 14px;
1009
+ color: #888;
1010
+ margin-bottom: 12px;
1011
+ }
1012
+
940
1013
  @media (max-width: 768px) {
941
1014
  body {
942
1015
  padding: 8px;
@@ -977,6 +1050,10 @@ td.user-id:hover {
977
1050
  grid-template-columns: 1fr;
978
1051
  }
979
1052
 
1053
+ .target-page-layout {
1054
+ grid-template-columns: 1fr;
1055
+ }
1056
+
980
1057
  .table-wrap {
981
1058
  padding: 10px;
982
1059
  }