tt-help-cli-ycl 1.3.72 → 1.3.74

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.
@@ -33,7 +33,7 @@ async function fetchStats() {
33
33
  }
34
34
  }
35
35
 
36
- async function fetchUsers() {
36
+ async function fetchUsers(showLoad) {
37
37
  try {
38
38
  const params = new URLSearchParams();
39
39
  if (currentFilter === "target") {
@@ -46,11 +46,13 @@ async function fetchUsers() {
46
46
  const search = document.getElementById("searchInput").value.trim();
47
47
  if (search) params.set("search", search);
48
48
  if (currentLocation) params.set("location", currentLocation);
49
- params.set("limit", "200");
49
+ params.set("limit", "100");
50
+ if (showLoad) showLoading("加载中...");
50
51
  const res = await fetch("/api/users?" + params.toString());
51
52
  const data = await res.json();
52
53
  currentUsers = data.users || [];
53
54
  renderTable(currentUsers);
55
+ if (showLoad) hideLoading();
54
56
  } catch (e) {}
55
57
  }
56
58
 
@@ -434,7 +436,7 @@ function setFilter(f) {
434
436
  targetLocSel.style.display = "none";
435
437
  currentTargetLocation = "";
436
438
  }
437
- fetchUsers();
439
+ fetchUsers(true);
438
440
  }
439
441
 
440
442
  function renderLocationFilter() {
@@ -454,15 +456,20 @@ function renderLocationFilter() {
454
456
  }
455
457
 
456
458
  function onLocationChange() {
457
- const sel = document.getElementById("locationFilter");
458
- currentLocation = sel.value;
459
- fetchUsers();
460
- }
461
-
462
- function onTargetLocationChange() {
463
- const sel = document.getElementById("targetLocationFilter");
464
- currentTargetLocation = sel.value;
465
- fetchUsers();
459
+ const locationSel = document.getElementById("locationFilter");
460
+ currentLocation = locationSel.value;
461
+ fetchUsers(true);
462
+ const targetSel = document.getElementById("targetLocationFilter");
463
+ currentTargetLocation = targetSel.value;
464
+ // 判断当前在哪个页面
465
+ if (document.getElementById("targetPage").classList.contains("active")) {
466
+ if (currentTargetSummary) {
467
+ renderTargetCountryGrid(currentTargetSummary.countries || []);
468
+ loadTargetUsersPage();
469
+ }
470
+ } else {
471
+ fetchUsers();
472
+ }
466
473
  }
467
474
 
468
475
  let searchTimer = null;
@@ -591,40 +598,6 @@ async function selectLocation(uniqueId, location) {
591
598
  }
592
599
  showToast(`@${uniqueId} 国家已更新为 ${location}`);
593
600
 
594
- // 同步更新内存数据:将用户从旧国家组移到新国家组
595
- if (currentTargetData && currentTargetData.countries) {
596
- let oldCountry = null;
597
- let userObj = null;
598
- for (const country of currentTargetData.countries) {
599
- const idx = country.users.findIndex((u) => u.uniqueId === uniqueId);
600
- if (idx !== -1) {
601
- oldCountry = country;
602
- userObj = country.users.splice(idx, 1)[0];
603
- break;
604
- }
605
- }
606
- if (userObj) {
607
- userObj.locationCreated = location;
608
- userObj.modifiedAt = Date.now();
609
- // 找到或创建新国家组
610
- let newCountry = currentTargetData.countries.find(
611
- (c) => c.country === location,
612
- );
613
- if (!newCountry) {
614
- newCountry = { country: location, count: 0, users: [] };
615
- currentTargetData.countries.push(newCountry);
616
- }
617
- newCountry.users.push(userObj);
618
- newCountry.count = newCountry.users.length;
619
- // 更新旧国家组的 count
620
- if (oldCountry) oldCountry.count = oldCountry.users.length;
621
- // 重新渲染左侧国家列表和表格
622
- renderTargetCountryGrid(currentTargetData.countries);
623
- renderTargetLocationFilter(currentTargetData.countries);
624
- renderTargetTable(currentTargetData.countries);
625
- }
626
- }
627
-
628
601
  // 只更新当前行 DOM
629
602
  const tr = document.querySelector(`tr[data-user="${uniqueId}"]`);
630
603
  if (tr) {
@@ -634,8 +607,16 @@ async function selectLocation(uniqueId, location) {
634
607
  locCell.className = "location-cell modified";
635
608
  locCell.innerHTML = `${location}${editIcon}`;
636
609
  locCell.title = `点击修改国家(已修改: ${formatTime(Date.now())})`;
610
+ locCell.onclick = () => openLocationModal(uniqueId, location);
637
611
  }
638
612
  }
613
+
614
+ // 在内存用户列表中也更新
615
+ const userInMem = currentTargetUsers.find((u) => u.uniqueId === uniqueId);
616
+ if (userInMem) {
617
+ userInMem.locationCreated = location;
618
+ userInMem.modifiedAt = Date.now();
619
+ }
639
620
  } catch (e) {
640
621
  showToast("更新失败: " + e.message, true);
641
622
  } finally {
@@ -960,6 +941,7 @@ function showTargetPage() {
960
941
  document.getElementById("userUpdatePage").classList.remove("active");
961
942
  document.getElementById("rawPage").classList.remove("active");
962
943
  document.getElementById("targetPage").classList.add("active");
944
+ showLoading("正在加载目标商家数据...");
963
945
  fetchTargetByCountry();
964
946
  // 同步统计
965
947
  if (currentStats) {
@@ -970,16 +952,21 @@ function showTargetPage() {
970
952
  }
971
953
  }
972
954
 
973
- let currentTargetData = null;
955
+ let currentTargetSummary = null;
956
+ let currentTargetUsers = [];
957
+ let currentTargetTotal = 0;
958
+ let currentTargetLoading = false;
959
+ let currentTargetAllLoaded = false;
960
+ let currentTargetSeq = 0;
961
+ const TARGET_PAGE_SIZE = 200;
974
962
 
975
963
  async function fetchTargetByCountry() {
976
964
  try {
977
- const res = await fetch("/api/target-users-by-country");
965
+ const res = await fetch("/api/target-users-by-country?summary=1");
978
966
  const data = await res.json();
979
- currentTargetData = data;
967
+ currentTargetSummary = data;
980
968
  renderTargetCountryGrid(data.countries || []);
981
- renderTargetLocationFilter(data.countries || []);
982
- renderTargetTable(data.countries || []);
969
+ renderTargetPageLocationFilter(data.countries || []);
983
970
  document.getElementById("targetPageStatTotal").textContent = formatStatNum(
984
971
  data.total || 0,
985
972
  { full: true },
@@ -987,8 +974,11 @@ async function fetchTargetByCountry() {
987
974
  document.getElementById("targetPageStatCountries").textContent = (
988
975
  data.countries || []
989
976
  ).length;
977
+ await loadTargetUsersPage(false, true);
990
978
  } catch (e) {
991
979
  console.error("获取目标商家数据失败:", e);
980
+ } finally {
981
+ hideLoading();
992
982
  }
993
983
  }
994
984
 
@@ -999,17 +989,18 @@ function renderTargetCountryGrid(countries) {
999
989
  '<span style="color:#666;font-size:12px">暂无目标商家</span>';
1000
990
  return;
1001
991
  }
1002
- const total = countries.reduce((sum, c) => sum + c.count, 0);
992
+ const total = countries.reduce((sum, c) => sum + (c.count || 0), 0);
1003
993
  grid.innerHTML = countries
1004
994
  .map((c) => {
1005
- const pct = ((c.count / total) * 100).toFixed(1);
995
+ const cnt = c.count || 0;
996
+ const pct = total > 0 ? ((cnt / total) * 100).toFixed(1) : "0.0";
1006
997
  const safeCountry = escapeJsString(c.country);
1007
998
  const sel = currentTargetLocation === c.country ? " selected" : "";
1008
999
  return `
1009
1000
  <div class="pending-country-item has-target${sel}"
1010
1001
  onclick="filterTargetByCountry('${safeCountry}')">
1011
1002
  <div class="country-name">${c.country}</div>
1012
- <div class="country-count">${c.count}</div>
1003
+ <div class="country-count">${cnt}</div>
1013
1004
  <div class="country-label">${pct}% 目标商家</div>
1014
1005
  </div>
1015
1006
  `;
@@ -1017,7 +1008,7 @@ function renderTargetCountryGrid(countries) {
1017
1008
  .join("");
1018
1009
  }
1019
1010
 
1020
- function renderTargetLocationFilter(countries) {
1011
+ function renderTargetPageLocationFilter(countries) {
1021
1012
  const sel = document.getElementById("targetLocationFilter");
1022
1013
  if (!sel) return;
1023
1014
  const val = sel.value;
@@ -1026,46 +1017,90 @@ function renderTargetLocationFilter(countries) {
1026
1017
  countries
1027
1018
  .map(
1028
1019
  (c) =>
1029
- `<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
1020
+ `<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count || 0})</option>`,
1030
1021
  )
1031
1022
  .join("");
1032
1023
  }
1033
1024
 
1034
- function renderTargetTable(countries) {
1035
- const el = document.getElementById("targetTable");
1025
+ async function loadTargetUsersPage(append = false, skipLoading = false) {
1026
+ if (currentTargetLoading) return;
1027
+ currentTargetLoading = true;
1028
+
1029
+ const seq = ++currentTargetSeq;
1036
1030
  const search = document.getElementById("targetSearchInput")
1037
- ? document.getElementById("targetSearchInput").value.trim().toLowerCase()
1031
+ ? document.getElementById("targetSearchInput").value.trim()
1038
1032
  : "";
1039
- const location = currentTargetLocation;
1033
+ const offset = append ? currentTargetUsers.length : 0;
1040
1034
 
1041
- let allUsers = [];
1042
- for (const c of countries) {
1043
- if (location && c.country !== location) continue;
1044
- allUsers = allUsers.concat(c.users);
1035
+ // 非追加模式(重置加载)时显示 loading,除非外层已经显示了
1036
+ if (!append && !skipLoading) {
1037
+ showLoading("正在加载数据...");
1045
1038
  }
1046
1039
 
1047
- if (search) {
1048
- allUsers = allUsers.filter(
1049
- (u) =>
1050
- (u.uniqueId || "").toLowerCase().includes(search) ||
1051
- (u.nickname || "").toLowerCase().includes(search),
1052
- );
1040
+ let data = null;
1041
+ let error = null;
1042
+ try {
1043
+ let url = `/api/target-users-by-country?limit=${TARGET_PAGE_SIZE}&offset=${offset}`;
1044
+ if (currentTargetLocation)
1045
+ url += `&country=${encodeURIComponent(currentTargetLocation)}`;
1046
+ if (search) url += `&search=${encodeURIComponent(search)}`;
1047
+
1048
+ const res = await fetch(url);
1049
+ data = await res.json();
1050
+ } catch (e) {
1051
+ error = e;
1052
+ console.error("加载目标用户失败:", e);
1053
+ } finally {
1054
+ // 非追加模式时隐藏 loading,除非外层负责隐藏
1055
+ if (!append && !skipLoading) {
1056
+ hideLoading();
1057
+ }
1058
+ currentTargetLoading = false;
1059
+
1060
+ if (error || !data) return;
1061
+
1062
+ // 防止旧请求覆盖新请求的结果
1063
+ if (seq !== currentTargetSeq && !append) return;
1064
+
1065
+ if (!append) {
1066
+ currentTargetUsers = data.users || [];
1067
+ currentTargetTotal = data.total || 0;
1068
+ currentTargetAllLoaded = currentTargetUsers.length >= currentTargetTotal;
1069
+ } else {
1070
+ currentTargetUsers = currentTargetUsers.concat(data.users || []);
1071
+ currentTargetAllLoaded = currentTargetUsers.length >= currentTargetTotal;
1072
+ }
1073
+
1074
+ // 所有状态更新完成后,再渲染
1075
+ renderTargetTable();
1053
1076
  }
1077
+ }
1054
1078
 
1055
- if (!allUsers.length) {
1079
+ function updateLoadMoreButton() {
1080
+ // 按钮已移除,保留函数名以兼容旧调用
1081
+ }
1082
+
1083
+ function renderTargetTable() {
1084
+ const el = document.getElementById("targetTable");
1085
+ const moreHint = document.getElementById("targetMoreHint");
1086
+
1087
+ if (currentTargetUsers.length === 0) {
1056
1088
  el.innerHTML =
1057
- '<tr><td colspan="8" style="color:#666;text-align:center;padding:24px">暂无目标商家</td></tr>';
1089
+ '<tr><td colspan="9" style="text-align:center;color:#888;padding:24px">暂无数据</td></tr>';
1090
+ if (moreHint) {
1091
+ moreHint.style.display = "none";
1092
+ }
1058
1093
  return;
1059
1094
  }
1060
1095
 
1061
- el.innerHTML = allUsers
1062
- .map((u) => {
1096
+ el.innerHTML = currentTargetUsers
1097
+ .map((u, i) => {
1063
1098
  const nick = (u.nickname || "")
1064
1099
  .replace(/</g, "&lt;")
1065
1100
  .replace(/>/g, "&gt;");
1066
1101
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
1067
1102
  const videos = u.videoCount != null ? u.videoCount : "-";
1068
- const location = u.locationCreated || "-";
1103
+ const userLocation = u.locationCreated || "-";
1069
1104
  const confirmedLocation = u.confirmedLocation
1070
1105
  ? u.confirmedLocation === u.locationCreated
1071
1106
  ? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
@@ -1075,13 +1110,11 @@ function renderTargetTable(countries) {
1075
1110
  ? formatTime(u.latestVideoTime * 1000)
1076
1111
  : "-";
1077
1112
  const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
1078
- const sources = (u.sources || []).join(", ");
1079
1113
 
1080
- // 修改过的国家用特殊颜色,带编辑图标
1081
1114
  const editIcon = ' <span style="font-size:10px;opacity:0.5">✏️</span>';
1082
1115
  const locationCell = u.modifiedAt
1083
- ? `<td data-label="国家" class="location-cell modified" onclick="openLocationModal('${u.uniqueId}','${location}')" title="点击修改国家(已修改: ${formatTime(u.modifiedAt)})">${location}${editIcon}</td>`
1084
- : `<td data-label="国家" class="location-cell" onclick="openLocationModal('${u.uniqueId}','${location}')" title="点击修改国家">${location}${editIcon}</td>`;
1116
+ ? `<td data-label="国家" class="location-cell modified" onclick="openLocationModal('${u.uniqueId}','${userLocation}')" title="点击修改国家(已修改: ${formatTime(u.modifiedAt)})">${userLocation}${editIcon}</td>`
1117
+ : `<td data-label="国家" class="location-cell" onclick="openLocationModal('${u.uniqueId}','${userLocation}')" title="点击修改国家">${userLocation}${editIcon}</td>`;
1085
1118
 
1086
1119
  let statusTag = "";
1087
1120
  if (u.status === "done")
@@ -1097,6 +1130,7 @@ function renderTargetTable(countries) {
1097
1130
  else statusTag = u.status || "-";
1098
1131
 
1099
1132
  return `<tr data-user="${u.uniqueId}">
1133
+ <td style="color:#9ca3af;font-size:12px;text-align:center" data-label="#">${i + 1}</td>
1100
1134
  <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
1101
1135
  <td data-label="昵称">${nick}</td>
1102
1136
  <td data-label="粉丝">${fans}</td>
@@ -1108,6 +1142,24 @@ function renderTargetTable(countries) {
1108
1142
  </tr>`;
1109
1143
  })
1110
1144
  .join("");
1145
+
1146
+ if (moreHint) {
1147
+ moreHint.style.display = "";
1148
+ if (currentTargetAllLoaded) {
1149
+ moreHint.innerHTML = `共 <b>${currentTargetTotal}</b> 条,已全部加载`;
1150
+ moreHint.style.color = "#9ca3af";
1151
+ moreHint.style.cursor = "default";
1152
+ } else if (currentTargetLoading) {
1153
+ moreHint.innerHTML = `已加载 <b>${currentTargetUsers.length}</b> / ${currentTargetTotal} 条,加载中...`;
1154
+ moreHint.style.color = "#9ca3af";
1155
+ moreHint.style.cursor = "wait";
1156
+ } else {
1157
+ moreHint.innerHTML = `已加载 <b>${currentTargetUsers.length}</b> / ${currentTargetTotal} 条,<u style="color:#3b82f6">点击此处加载更多</u> ↓`;
1158
+ moreHint.style.color = "#6b7280";
1159
+ moreHint.style.cursor = "pointer";
1160
+ }
1161
+ }
1162
+ updateLoadMoreButton();
1111
1163
  }
1112
1164
 
1113
1165
  function filterTargetByCountry(country) {
@@ -1116,18 +1168,9 @@ function filterTargetByCountry(country) {
1116
1168
  if (sel) sel.value = country;
1117
1169
  const btn = document.getElementById("targetReprocessBtn");
1118
1170
  if (btn) btn.style.display = "";
1119
- if (currentTargetData) {
1120
- renderTargetCountryGrid(currentTargetData.countries || []);
1121
- renderTargetTable(currentTargetData.countries || []);
1122
- }
1123
- }
1124
-
1125
- function onTargetLocationChange() {
1126
- const sel = document.getElementById("targetLocationFilter");
1127
- currentTargetLocation = sel.value;
1128
- if (currentTargetData) {
1129
- renderTargetCountryGrid(currentTargetData.countries || []);
1130
- renderTargetTable(currentTargetData.countries || []);
1171
+ if (currentTargetSummary) {
1172
+ renderTargetCountryGrid(currentTargetSummary.countries || []);
1173
+ loadTargetUsersPage();
1131
1174
  }
1132
1175
  }
1133
1176
 
@@ -1139,53 +1182,54 @@ function clearTargetFilters() {
1139
1182
  if (locationFilter) locationFilter.value = "";
1140
1183
  const btn = document.getElementById("targetReprocessBtn");
1141
1184
  if (btn) btn.style.display = "none";
1142
- if (currentTargetData) {
1143
- renderTargetCountryGrid(currentTargetData.countries || []);
1144
- renderTargetTable(currentTargetData.countries || []);
1185
+ if (currentTargetSummary) {
1186
+ renderTargetCountryGrid(currentTargetSummary.countries || []);
1187
+ loadTargetUsersPage();
1145
1188
  }
1146
1189
  }
1147
1190
 
1148
1191
  async function reprocessTargetUsers() {
1149
- if (!currentTargetLocation || !currentTargetData) return;
1192
+ if (!currentTargetLocation) return;
1150
1193
  const country = currentTargetLocation;
1151
- const countryData = currentTargetData.countries.find((c) => c.country === country);
1152
- const users = countryData ? countryData.users : [];
1153
1194
  const search = document.getElementById("targetSearchInput")
1154
- ? document.getElementById("targetSearchInput").value.trim().toLowerCase()
1195
+ ? document.getElementById("targetSearchInput").value.trim()
1155
1196
  : "";
1156
- const filtered = search
1157
- ? users.filter(
1158
- (u) =>
1159
- (u.uniqueId || "").toLowerCase().includes(search) ||
1160
- (u.nickname || "").toLowerCase().includes(search),
1161
- )
1162
- : users;
1163
- if (filtered.length === 0) {
1164
- showToast("当前筛选结果为空", true);
1165
- return;
1166
- }
1167
- if (
1168
- !confirm(
1169
- `确定要重新处理 ${country} 的 ${filtered.length} 个目标商家吗?\n这将重置它们的任务状态,使客户端重新采集。`,
1170
- )
1171
- ) {
1172
- return;
1173
- }
1174
- const userIds = filtered.map((u) => u.uniqueId);
1175
- showLoading("正在批量重置...");
1197
+
1198
+ // 加载该国家全量用户用于重置
1199
+ showLoading("正在加载用户列表...");
1176
1200
  try {
1177
- const res = await fetch("/api/jobs/batch-reset", {
1201
+ let url = `/api/target-users-by-country?limit=99999&offset=0&country=${encodeURIComponent(country)}`;
1202
+ if (search) url += `&search=${encodeURIComponent(search)}`;
1203
+ const res = await fetch(url);
1204
+ const data = await res.json();
1205
+ const users = data.users || [];
1206
+
1207
+ if (users.length === 0) {
1208
+ showToast("当前筛选结果为空", true);
1209
+ return;
1210
+ }
1211
+ if (
1212
+ !confirm(
1213
+ `确定要重新处理 ${country} 的 ${users.length} 个目标商家吗?\n这将重置它们的任务状态,使客户端重新采集。`,
1214
+ )
1215
+ ) {
1216
+ hideLoading();
1217
+ return;
1218
+ }
1219
+ showLoading("正在批量重置...");
1220
+ const userIds = users.map((u) => u.uniqueId);
1221
+ const resetRes = await fetch("/api/jobs/batch-reset", {
1178
1222
  method: "POST",
1179
1223
  headers: { "Content-Type": "application/json" },
1180
1224
  body: JSON.stringify({ userIds }),
1181
1225
  });
1182
- const data = await res.json();
1183
- if (data.error) {
1184
- showToast(data.error, true);
1226
+ const resetData = await resetRes.json();
1227
+ if (resetData.error) {
1228
+ showToast(resetData.error, true);
1185
1229
  return;
1186
1230
  }
1187
- showToast(`已重置 ${data.reset} / ${data.total} 个用户`);
1188
- fetchTargetData();
1231
+ showToast(`已重置 ${resetData.reset} / ${resetData.total} 个用户`);
1232
+ fetchTargetByCountry();
1189
1233
  } catch (e) {
1190
1234
  showToast("批量重置失败: " + e.message, true);
1191
1235
  } finally {
@@ -1198,8 +1242,8 @@ let targetSearchTimer = null;
1198
1242
  document.getElementById("targetSearchInput").addEventListener("input", () => {
1199
1243
  if (targetSearchTimer) clearTimeout(targetSearchTimer);
1200
1244
  targetSearchTimer = setTimeout(() => {
1201
- if (currentTargetData) {
1202
- renderTargetTable(currentTargetData.countries || []);
1245
+ if (currentTargetSummary) {
1246
+ loadTargetUsersPage();
1203
1247
  }
1204
1248
  }, 300);
1205
1249
  });
@@ -1228,6 +1272,29 @@ document
1228
1272
  }
1229
1273
  });
1230
1274
 
1275
+ document
1276
+ .getElementById("refreshTargetBtn")
1277
+ .addEventListener("click", async () => {
1278
+ showLoading("正在刷新...");
1279
+ try {
1280
+ currentTargetLocation = "";
1281
+ const searchInput = document.getElementById("targetSearchInput");
1282
+ const locationFilter = document.getElementById("targetLocationFilter");
1283
+ if (searchInput) searchInput.value = "";
1284
+ if (locationFilter) locationFilter.value = "";
1285
+ const btn = document.getElementById("targetReprocessBtn");
1286
+ if (btn) btn.style.display = "none";
1287
+ await fetchTargetByCountry();
1288
+ showToast("刷新完成");
1289
+ } catch (e) {
1290
+ showToast("刷新失败: " + e.message, true);
1291
+ } finally {
1292
+ hideLoading();
1293
+ }
1294
+ });
1295
+
1296
+ // 加载更多按钮由 HTML 中 onclick="loadTargetUsersPage(true)" 直接触发
1297
+
1231
1298
  async function fetchPendingByCountry() {
1232
1299
  try {
1233
1300
  const res = await fetch("/api/pending-by-country");
@@ -1771,6 +1838,7 @@ fetchStats();
1771
1838
  fetchUsers();
1772
1839
  fetchClientErrors();
1773
1840
  initTableSorting();
1841
+
1774
1842
  setInterval(fetchStats, 10000);
1775
1843
  setInterval(fetchUsers, 10000);
1776
1844
  setInterval(fetchClientErrors, 10000);
@@ -296,6 +296,9 @@
296
296
  <div class="stat-card clickable" id="exportTargetCsvBtn" style="background:rgba(34,197,94,0.12);cursor:pointer">
297
297
  <div class="label">导出 CSV</div>
298
298
  </div>
299
+ <div class="stat-card clickable" id="refreshTargetBtn" style="background:rgba(59,130,246,0.12);cursor:pointer">
300
+ <div class="label">🔄 刷新</div>
301
+ </div>
299
302
  </div>
300
303
  <div class="target-page-layout">
301
304
  <div class="target-side-card">
@@ -316,6 +319,7 @@
316
319
  <table>
317
320
  <thead>
318
321
  <tr>
322
+ <th style="width:48px">#</th>
319
323
  <th>用户名</th>
320
324
  <th>昵称</th>
321
325
  <th>粉丝</th>
@@ -328,6 +332,9 @@
328
332
  </thead>
329
333
  <tbody id="targetTable"></tbody>
330
334
  </table>
335
+ <div id="targetMoreHint" onclick="loadTargetUsersPage(true)"
336
+ style="text-align:center;padding:10px 8px;font-size:13px;color:#6b7280;cursor:default;user-select:none;transition:color 0.2s">
337
+ </div>
331
338
  </div>
332
339
  </div>
333
340
  </div>
@@ -24,8 +24,24 @@ function getLocalIP() {
24
24
  const __dirname = dirname(__filename);
25
25
  const publicDir = join(__dirname, "public");
26
26
 
27
+ // Stats 缓存:避免每次请求都扫描 100 万+ 条 jobs 数据
28
+ let statsCache = null;
29
+ let statsCacheTime = 0;
30
+ const STATS_CACHE_TTL = 10000; // 10 秒缓存
31
+
32
+ function invalidateStatsCache() {
33
+ statsCache = null;
34
+ statsCacheTime = 0;
35
+ }
36
+
27
37
  function computeStatsIncremental(st) {
28
- return st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
38
+ const now = Date.now();
39
+ if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
40
+ return statsCache;
41
+ }
42
+ statsCache = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
43
+ statsCacheTime = now;
44
+ return statsCache;
29
45
  }
30
46
 
31
47
  function readBody(req) {
@@ -553,8 +569,21 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
553
569
  req.method === "GET" &&
554
570
  routePath === "/api/target-users-by-country"
555
571
  ) {
556
- const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
572
+ // 摘要模式:只返回各国统计数
573
+ if (params.summary === "1") {
574
+ const result = store.getTargetUsersByCountry(
575
+ DEFAULT_TARGET_LOCATIONS,
576
+ { summaryOnly: true },
577
+ );
578
+ sendJSON(res, 200, result);
579
+ return;
580
+ }
581
+
582
+ // CSV 导出:全量数据
557
583
  if (req.headers["accept"]?.includes("text/csv")) {
584
+ const result = store.getTargetUsersByCountry(
585
+ DEFAULT_TARGET_LOCATIONS,
586
+ );
558
587
  const columns = [
559
588
  "uniqueId",
560
589
  "nickname",
@@ -603,9 +632,27 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
603
632
  columns.map((c) => csvEscape(r[c])).join(","),
604
633
  );
605
634
  res.end(BOM + [header, ...lines].join("\r\n"));
606
- } else {
635
+ return;
636
+ }
637
+
638
+ // 分页模式:按国家+搜索+分页查询用户
639
+ if (params.limit) {
640
+ const result = store.getTargetUsersByCountry(
641
+ DEFAULT_TARGET_LOCATIONS,
642
+ {
643
+ country: params.country || undefined,
644
+ search: params.search || undefined,
645
+ limit: parseInt(params.limit, 10),
646
+ offset: parseInt(params.offset || "0", 10),
647
+ },
648
+ );
607
649
  sendJSON(res, 200, result);
650
+ return;
608
651
  }
652
+
653
+ // 默认:全量(兼容旧调用)
654
+ const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
655
+ sendJSON(res, 200, result);
609
656
  return;
610
657
  }
611
658