tt-help-cli-ycl 1.4.21 → 1.4.23

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.
@@ -42,104 +42,144 @@ Standards: 90-100=clear match, 70-89=likely, 50-69=possible, 20-49=low, 0-19=unl
42
42
  `;
43
43
 
44
44
  try {
45
- const apiKey = process.env.APIKEY || "";
46
- const response = await undiciFetch(
47
- "http://82.156.52.214:18000/v1/chat/completions",
48
- {
45
+ const { getLlmUrl, getLlmApiKey, isAutoMode, getAutoPool } =
46
+ await import("./llm-config.js");
47
+ const llmUrl = getLlmUrl();
48
+ const apiKey = getLlmApiKey();
49
+
50
+ // auto 模式:依次尝试候选池中的模型
51
+ const models = isAutoMode() ? getAutoPool() : [null];
52
+
53
+ let lastError = "";
54
+ for (let i = 0; i < models.length; i++) {
55
+ const llmModel =
56
+ models[i] !== null
57
+ ? models[i]
58
+ : (await import("./llm-config.js")).getLlmModel();
59
+
60
+ const response = await undiciFetch(llmUrl, {
49
61
  method: "POST",
50
62
  headers: {
51
63
  "Content-Type": "application/json",
52
64
  Authorization: `Bearer ${apiKey}`,
53
65
  },
54
66
  body: JSON.stringify({
55
- model: "zc-fast",
67
+ model: llmModel,
56
68
  messages: [{ role: "user", content: prompt }],
57
69
  max_tokens: 512,
58
70
  temperature: 0.1,
71
+ enable_thinking: false,
59
72
  }),
60
- },
61
- );
73
+ });
74
+
75
+ const result = await response.json().catch(() => null);
76
+ const content = result?.choices?.[0]?.message?.content || "";
77
+
78
+ if (!content) {
79
+ const errMsg = result?.error?.message || `HTTP ${response.status}`;
80
+ lastError = errMsg;
81
+ console.error(
82
+ `[scoreJobLocation] ${llmModel} 失败: ${errMsg},尝试下一个`,
83
+ );
84
+ continue;
85
+ }
86
+
87
+ if (i > 0) {
88
+ console.error(`[scoreJobLocation] ✅ auto 切换成功: ${llmModel}`);
89
+ }
90
+
91
+ let parsed = null;
62
92
 
63
- const result = await response.json();
64
- const content = result.choices?.[0]?.message?.content || "";
65
-
66
- let parsed = null;
67
-
68
- // 尝试 1: 直接解析
69
- try {
70
- parsed = JSON.parse(content);
71
- } catch {
72
- // 尝试 2: 提取 {} 包裹的内容
73
- const match = content.match(/\{[\s\S]*\}/);
74
- if (match) {
75
- try {
76
- parsed = JSON.parse(match[0]);
77
- } catch {
78
- // 尝试 3: 清理常见问题后解析
79
- const cleaned = match[0]
80
- .replace(/"/g, '"')
81
- .replace(/\s+/g, " ")
82
- .trim();
93
+ // 尝试 1: 直接解析
94
+ try {
95
+ parsed = JSON.parse(content);
96
+ } catch {
97
+ // 尝试 2: 提取 {} 包裹的内容
98
+ const match = content.match(/\{[\s\S]*\}/);
99
+ if (match) {
83
100
  try {
84
- parsed = JSON.parse(cleaned);
101
+ parsed = JSON.parse(match[0]);
85
102
  } catch {
86
- // 尝试 4: 从文本中提取 score 和 reason
87
- const scoreMatch = content.match(/"?score"?\s*:\s*(\d+)/i);
88
- if (scoreMatch) {
89
- let reason = "解析降级";
90
- const reasonKeyPos = content.search(/"?reason"?\s*:\s*"/i);
91
- if (reasonKeyPos !== -1) {
92
- const afterKey = content.substring(reasonKeyPos);
93
- const colonPos = afterKey.indexOf(":");
94
- const valueStart = afterKey.indexOf('"', colonPos + 1) + 1;
95
- const rawValue = afterKey.substring(valueStart);
96
- const lastBrace = content.lastIndexOf("}");
97
- const reasonEnd = lastBrace - reasonKeyPos - valueStart;
98
- if (reasonEnd > 0) {
99
- reason = rawValue.substring(0, reasonEnd).trim();
100
- if (reason.startsWith('"')) reason = reason.substring(1);
101
- if (reason.endsWith('"'))
102
- reason = reason.substring(0, reason.length - 1);
103
+ // 尝试 3: 清理常见问题后解析
104
+ const cleaned = match[0]
105
+ .replace(/"/g, '"')
106
+ .replace(/\s+/g, " ")
107
+ .trim();
108
+ try {
109
+ parsed = JSON.parse(cleaned);
110
+ } catch {
111
+ // 尝试 4: 从文本中提取 score 和 reason
112
+ const scoreMatch = content.match(/"?score"?\s*:\s*(\d+)/i);
113
+ if (scoreMatch) {
114
+ let reason = "解析降级";
115
+ const reasonKeyPos = content.search(/"?reason"?\s*:\s*"/i);
116
+ if (reasonKeyPos !== -1) {
117
+ const afterKey = content.substring(reasonKeyPos);
118
+ const colonPos = afterKey.indexOf(":");
119
+ const valueStart = afterKey.indexOf('"', colonPos + 1) + 1;
120
+ const rawValue = afterKey.substring(valueStart);
121
+ const lastBrace = content.lastIndexOf("}");
122
+ const reasonEnd = lastBrace - reasonKeyPos - valueStart;
123
+ if (reasonEnd > 0) {
124
+ reason = rawValue.substring(0, reasonEnd).trim();
125
+ if (reason.startsWith('"')) reason = reason.substring(1);
126
+ if (reason.endsWith('"'))
127
+ reason = reason.substring(0, reason.length - 1);
128
+ }
103
129
  }
130
+ parsed = { score: parseInt(scoreMatch[1]) || 50, reason };
104
131
  }
105
- parsed = { score: parseInt(scoreMatch[1]) || 50, reason };
106
132
  }
107
133
  }
108
134
  }
135
+
136
+ // 尝试 5: 更宽松的正则提取
137
+ if (!parsed) {
138
+ const scoreMatch = content.match(/"score"\s*:\s*(\d+)/);
139
+ const reasonMatch = content.match(/"reason"\s*:\s*"([^"]*)"/);
140
+ if (scoreMatch) {
141
+ parsed = {
142
+ score: parseInt(scoreMatch[1]) || 50,
143
+ reason: reasonMatch ? reasonMatch[1] : "解析降级 - 宽松模式",
144
+ };
145
+ }
146
+ }
109
147
  }
110
148
 
111
- // 尝试 5: 更宽松的正则提取
112
- if (!parsed) {
113
- const scoreMatch = content.match(/"score"\s*:\s*(\d+)/);
114
- const reasonMatch = content.match(/"reason"\s*:\s*"([^"]*)"/);
115
- if (scoreMatch) {
116
- parsed = {
117
- score: parseInt(scoreMatch[1]) || 50,
118
- reason: reasonMatch ? reasonMatch[1] : "解析降级 - 宽松模式",
119
- };
149
+ if (parsed && typeof parsed.score === "number") {
150
+ if (isAutoMode()) {
151
+ const { recordAutoSuccess } = await import("./llm-config.js");
152
+ recordAutoSuccess(llmModel);
120
153
  }
154
+ return {
155
+ uniqueId: job.unique_id,
156
+ score: Math.max(0, Math.min(100, parsed.score)),
157
+ reason: parsed.reason || "",
158
+ };
121
159
  }
122
- }
123
160
 
124
- if (parsed && typeof parsed.score === "number") {
161
+ console.error(
162
+ `[scoreJobLocation] JSON 解析失败 (${job.unique_id}): ${content.substring(0, 100)}`,
163
+ );
125
164
  return {
126
165
  uniqueId: job.unique_id,
127
- score: Math.max(0, Math.min(100, parsed.score)),
128
- reason: parsed.reason || "",
166
+ score: 50,
167
+ reason: "LLM 响应解析失败,使用默认分",
129
168
  };
130
- }
169
+ } // end for
131
170
 
171
+ // 全部模型均失败
132
172
  console.error(
133
- `[scoreJobLocation] JSON 解析失败 (${job.unique_id}): ${content.substring(0, 100)}`,
173
+ `[scoreJobLocation] ❌ ${models.length} 个模型均失败 (${job.unique_id}): ${lastError}`,
134
174
  );
135
175
  return {
136
176
  uniqueId: job.unique_id,
137
177
  score: 50,
138
- reason: "LLM 响应解析失败,使用默认分",
178
+ reason: `全部模型失败: ${lastError}`,
139
179
  };
140
180
  } catch (e) {
141
181
  console.error(
142
- `[scoreJobLocation] LLM 调用失败 (${job.unique_id}): ${e.message}`,
182
+ `[scoreJobLocation] LLM 调用异常 (${job.unique_id}): ${e.message}`,
143
183
  );
144
184
  return {
145
185
  uniqueId: job.unique_id,
@@ -1245,6 +1245,8 @@ function handleRoute() {
1245
1245
  showTargetPage();
1246
1246
  } else if (hash === "#tags") {
1247
1247
  showTagsPage();
1248
+ } else if (hash === "#settings") {
1249
+ showSettingsPage();
1248
1250
  } else {
1249
1251
  showMainPage();
1250
1252
  }
@@ -1273,6 +1275,7 @@ function showPendingPage() {
1273
1275
  document.getElementById("rawPage").classList.remove("active");
1274
1276
  document.getElementById("targetPage").classList.remove("active");
1275
1277
  document.getElementById("tagsPage").classList.remove("active");
1278
+ document.getElementById("settingsPage").classList.remove("active");
1276
1279
  fetchPendingByCountry();
1277
1280
  fetchAttachStuckByCountry();
1278
1281
  }
@@ -1284,6 +1287,7 @@ function showUserUpdatePage() {
1284
1287
  document.getElementById("rawPage").classList.remove("active");
1285
1288
  document.getElementById("targetPage").classList.remove("active");
1286
1289
  document.getElementById("tagsPage").classList.remove("active");
1290
+ document.getElementById("settingsPage").classList.remove("active");
1287
1291
  fetchUserUpdateByCountry();
1288
1292
  fetchAttachStuckByCountry();
1289
1293
  }
@@ -1302,6 +1306,7 @@ function showRawPage() {
1302
1306
  document.getElementById("rawPage").classList.add("active");
1303
1307
  document.getElementById("targetPage").classList.remove("active");
1304
1308
  document.getElementById("tagsPage").classList.remove("active");
1309
+ document.getElementById("settingsPage").classList.remove("active");
1305
1310
  fetchRawByCountry();
1306
1311
  fetchRawJobs();
1307
1312
  }
@@ -1313,6 +1318,7 @@ function showMainPage() {
1313
1318
  document.getElementById("rawPage").classList.remove("active");
1314
1319
  document.getElementById("targetPage").classList.remove("active");
1315
1320
  document.getElementById("tagsPage").classList.remove("active");
1321
+ document.getElementById("settingsPage").classList.remove("active");
1316
1322
  }
1317
1323
 
1318
1324
  function navigateToTarget() {
@@ -1323,6 +1329,189 @@ function navigateToTags() {
1323
1329
  window.location.hash = "#tags";
1324
1330
  }
1325
1331
 
1332
+ function navigateToSettings() {
1333
+ window.location.hash = "#settings";
1334
+ }
1335
+
1336
+ // ========== LLM 配置页面 ==========
1337
+
1338
+ function hideAllPages() {
1339
+ document.getElementById("mainPage").classList.add("hidden");
1340
+ document.getElementById("pendingPage").classList.remove("active");
1341
+ document.getElementById("userUpdatePage").classList.remove("active");
1342
+ document.getElementById("rawPage").classList.remove("active");
1343
+ document.getElementById("targetPage").classList.remove("active");
1344
+ document.getElementById("tagsPage").classList.remove("active");
1345
+ document.getElementById("settingsPage").classList.remove("active");
1346
+ }
1347
+
1348
+ function showSettingsPage() {
1349
+ hideAllPages();
1350
+ document.getElementById("settingsPage").classList.add("active");
1351
+ fetchLlmConfig();
1352
+ }
1353
+
1354
+ let _llmProvidersCache = null;
1355
+
1356
+ async function fetchLlmConfig() {
1357
+ try {
1358
+ const res = await fetch("/api/llm-config");
1359
+ const data = await res.json();
1360
+ _llmProvidersCache = data.providers || [];
1361
+
1362
+ const providerSel = document.getElementById("settingsProvider");
1363
+ const modelSel = document.getElementById("settingsModel");
1364
+ const urlInput = document.getElementById("settingsUrl");
1365
+ const keyInput = document.getElementById("settingsApiKey");
1366
+ const keyHint = document.getElementById("settingsKeyHint");
1367
+ const status = document.getElementById("settingsStatus");
1368
+
1369
+ // 渲染服务商下拉(只渲染一次)
1370
+ if (providerSel && providerSel.options.length === 0) {
1371
+ providerSel.innerHTML = _llmProvidersCache
1372
+ .map(
1373
+ (p) =>
1374
+ `<option value="${p.id}" ${p.id === data.provider ? "selected" : ""}>${p.name}</option>`,
1375
+ )
1376
+ .join("");
1377
+ } else if (providerSel) {
1378
+ providerSel.value = data.provider;
1379
+ }
1380
+
1381
+ // 根据当前 provider 渲染模型下拉
1382
+ renderModelSelect(data.provider, data.model);
1383
+
1384
+ if (urlInput) urlInput.value = data.url || "";
1385
+ if (keyInput) {
1386
+ keyInput.value = data.hasApiKey ? data.apiKeyHint : "";
1387
+ }
1388
+ if (keyHint) {
1389
+ keyHint.textContent = data.hasApiKey
1390
+ ? "已配置"
1391
+ : "未配置,请输入 API Key";
1392
+ keyHint.style.color = data.hasApiKey ? "#4ade80" : "#f87171";
1393
+ }
1394
+ if (status) {
1395
+ const provider = _llmProvidersCache.find((p) => p.id === data.provider);
1396
+ status.textContent = `当前: ${provider?.name || data.provider} / ${data.model}`;
1397
+ status.className = "settings-status ok";
1398
+ }
1399
+ } catch (e) {
1400
+ console.error("fetchLlmConfig error:", e);
1401
+ }
1402
+ }
1403
+
1404
+ function renderModelSelect(providerId, selectedModel) {
1405
+ const modelSel = document.getElementById("settingsModel");
1406
+ if (!modelSel) return;
1407
+ const provider = (_llmProvidersCache || []).find((p) => p.id === providerId);
1408
+ const models = provider?.models || [];
1409
+ modelSel.innerHTML = models
1410
+ .map(
1411
+ (m) =>
1412
+ `<option value="${m.id}" ${m.id === selectedModel ? "selected" : ""}>${m.label}</option>`,
1413
+ )
1414
+ .join("");
1415
+ }
1416
+
1417
+ // Provider 切换 → 更新 Model 列表 + URL
1418
+ function onProviderChange() {
1419
+ const providerSel = document.getElementById("settingsProvider");
1420
+ const providerId = providerSel?.value;
1421
+ if (!providerId) return;
1422
+
1423
+ const provider = (_llmProvidersCache || []).find((p) => p.id === providerId);
1424
+ if (!provider) return;
1425
+
1426
+ // 更新 URL
1427
+ const urlInput = document.getElementById("settingsUrl");
1428
+ if (urlInput) urlInput.value = provider.url;
1429
+
1430
+ // 更新模型列表(默认选第一个)
1431
+ renderModelSelect(providerId, provider.models[0]?.id);
1432
+ }
1433
+
1434
+ // 绑定事件
1435
+ (function initSettingsEvents() {
1436
+ const providerSel = document.getElementById("settingsProvider");
1437
+ if (providerSel) {
1438
+ providerSel.addEventListener("change", onProviderChange);
1439
+ }
1440
+ })();
1441
+
1442
+ function toggleApiKeyVisibility() {
1443
+ const input = document.getElementById("settingsApiKey");
1444
+ const btn = document.getElementById("settingsToggleKey");
1445
+ if (input.type === "password") {
1446
+ input.type = "text";
1447
+ btn.textContent = "🙈";
1448
+ } else {
1449
+ input.type = "password";
1450
+ btn.textContent = "👁";
1451
+ }
1452
+ }
1453
+
1454
+ async function saveLlmConfig() {
1455
+ const provider = document.getElementById("settingsProvider").value;
1456
+ const model = document.getElementById("settingsModel").value;
1457
+ const url = document.getElementById("settingsUrl").value;
1458
+ const apiKeyInput = document.getElementById("settingsApiKey");
1459
+ const status = document.getElementById("settingsStatus");
1460
+
1461
+ // 防止把提示字符串当 key 保存:若输入值含 "..." 且未被用户修改过,不发送
1462
+ const rawKey = apiKeyInput.value.trim();
1463
+ const isHint = rawKey.includes("...") && rawKey.length < 20;
1464
+ const keyWasEdited = apiKeyInput.dataset.edited === "true";
1465
+ let apiKey = undefined;
1466
+ if (rawKey && !isHint) {
1467
+ apiKey = rawKey;
1468
+ } else if (rawKey && isHint && keyWasEdited) {
1469
+ // 用户手动修改了(可能是输入了新 key),发送
1470
+ apiKey = rawKey;
1471
+ }
1472
+ // 如果 isHint && !keyWasEdited → 不发送,保留服务端已有 key
1473
+
1474
+ status.textContent = "保存中...";
1475
+ status.className = "settings-status loading";
1476
+
1477
+ try {
1478
+ const body = { provider, model, url };
1479
+ if (apiKey !== undefined) body.apiKey = apiKey;
1480
+ const res = await fetch("/api/llm-config", {
1481
+ method: "POST",
1482
+ headers: { "Content-Type": "application/json" },
1483
+ body: JSON.stringify(body),
1484
+ });
1485
+ const data = await res.json();
1486
+ if (data.hasApiKey) {
1487
+ apiKeyInput.value = data.apiKeyHint;
1488
+ apiKeyInput.dataset.edited = "false";
1489
+ const keyHint = document.getElementById("settingsKeyHint");
1490
+ if (keyHint) {
1491
+ keyHint.textContent = "已配置";
1492
+ keyHint.style.color = "#4ade80";
1493
+ }
1494
+ }
1495
+ const p = (_llmProvidersCache || []).find((x) => x.id === data.provider);
1496
+ status.textContent = `✅ 已保存: ${p?.name || data.provider} / ${data.model}`;
1497
+ status.className = "settings-status ok";
1498
+ _llmProvidersCache = data.providers || _llmProvidersCache;
1499
+ } catch (e) {
1500
+ status.textContent = "❌ 保存失败: " + e.message;
1501
+ status.className = "settings-status error";
1502
+ }
1503
+ }
1504
+
1505
+ // 追踪 API Key 输入框是否被用户编辑过
1506
+ (function initKeyEditTracking() {
1507
+ const input = document.getElementById("settingsApiKey");
1508
+ if (input) {
1509
+ input.addEventListener("input", () => {
1510
+ input.dataset.edited = "true";
1511
+ });
1512
+ }
1513
+ })();
1514
+
1326
1515
  // ========== 添加关键词弹窗 ==========
1327
1516
 
1328
1517
  function openAddTagModal() {
@@ -1483,6 +1672,7 @@ function showTagsPage() {
1483
1672
  document.getElementById("rawPage").classList.remove("active");
1484
1673
  document.getElementById("targetPage").classList.remove("active");
1485
1674
  document.getElementById("tagsPage").classList.add("active");
1675
+ document.getElementById("settingsPage").classList.remove("active");
1486
1676
  showLoading("正在加载关键词...");
1487
1677
  Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
1488
1678
  hideLoading,
@@ -1626,6 +1816,11 @@ async function loadTagsPage(append = false) {
1626
1816
  if (currentTagsFilter !== "all") url += `&status=${currentTagsFilter}`;
1627
1817
  if (country) url += `&country=${encodeURIComponent(country)}`;
1628
1818
 
1819
+ const searchVal =
1820
+ document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
1821
+ "";
1822
+ if (searchVal) url += `&search=${encodeURIComponent(searchVal)}`;
1823
+
1629
1824
  const res = await fetch(url);
1630
1825
  const data = await res.json();
1631
1826
 
@@ -1652,14 +1847,6 @@ function renderTagsTable() {
1652
1847
  const moreHint = document.getElementById("tagsMoreHint");
1653
1848
  let display = [...currentTags];
1654
1849
 
1655
- // 搜索过滤(本地二次过滤)
1656
- const searchVal =
1657
- document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
1658
- "";
1659
- if (searchVal) {
1660
- display = display.filter((t) => t.tag.toLowerCase().includes(searchVal));
1661
- }
1662
-
1663
1850
  // 本地排序
1664
1851
  if (currentTagsSort.key) {
1665
1852
  display.sort((a, b) => {
@@ -1957,6 +2144,7 @@ function showTargetPage() {
1957
2144
  document.getElementById("rawPage").classList.remove("active");
1958
2145
  document.getElementById("targetPage").classList.add("active");
1959
2146
  document.getElementById("tagsPage").classList.remove("active");
2147
+ document.getElementById("settingsPage").classList.remove("active");
1960
2148
  showLoading("正在加载目标商家数据...");
1961
2149
  fetchTargetByCountry();
1962
2150
  fetchTargetOtherCountries();
@@ -2101,6 +2289,10 @@ async function loadTargetUsersPage(append = false, skipLoading = false) {
2101
2289
  if (currentTargetLocation)
2102
2290
  url += `&country=${encodeURIComponent(currentTargetLocation)}`;
2103
2291
  if (search) url += `&search=${encodeURIComponent(search)}`;
2292
+ if (currentTargetSort.key) {
2293
+ url += `&sort=${encodeURIComponent(currentTargetSort.key)}`;
2294
+ url += `&sortOrder=${currentTargetSort.asc ? "asc" : "desc"}`;
2295
+ }
2104
2296
 
2105
2297
  const res = await fetch(url);
2106
2298
  data = await res.json();
@@ -2141,9 +2333,9 @@ function renderTargetTable() {
2141
2333
  const el = document.getElementById("targetTable");
2142
2334
  const moreHint = document.getElementById("targetMoreHint");
2143
2335
 
2144
- // 应用本地排序
2336
+ // 应用本地排序(topVideoPlayCount 由服务端排序,此处跳过)
2145
2337
  let displayUsers = currentTargetUsers;
2146
- if (currentTargetSort.key) {
2338
+ if (currentTargetSort.key && currentTargetSort.key !== "topVideoPlayCount") {
2147
2339
  displayUsers = [...currentTargetUsers].sort((a, b) => {
2148
2340
  let va = a[currentTargetSort.key];
2149
2341
  let vb = b[currentTargetSort.key];
@@ -2190,16 +2382,47 @@ function renderTargetTable() {
2190
2382
  ? formatTime(u.latestVideoTime * 1000)
2191
2383
  : "-";
2192
2384
  const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
2193
- const topPlayCount =
2194
- u.topVideoPlayCount != null && u.topVideoPlayCount >= 1000
2195
- ? formatNum(u.topVideoPlayCount)
2196
- : "-";
2197
- const adTag = u.topVideoIsAd
2198
- ? ' <span style="color:red">(AD)</span>'
2199
- : "";
2200
- const topPlayCountCell = u.topVideoHref
2201
- ? `<td data-label="最大播放量" style="font-size:11px;color:#888"><a href="${u.topVideoHref}" target="_blank" style="color:#3b82f6;text-decoration:none" title="点击查看视频">${topPlayCount}${adTag}</a></td>`
2202
- : `<td data-label="最大播放量" style="font-size:11px;color:#888">${topPlayCount}${adTag}</td>`;
2385
+
2386
+ // 解析 recentEcVideos JSON,获取所有高播放 EC 视频
2387
+ let ecVideos = [];
2388
+ try {
2389
+ if (typeof u.recentEcVideos === "string") {
2390
+ ecVideos = JSON.parse(u.recentEcVideos);
2391
+ } else if (Array.isArray(u.recentEcVideos)) {
2392
+ ecVideos = u.recentEcVideos;
2393
+ }
2394
+ } catch (e) {
2395
+ ecVideos = [];
2396
+ }
2397
+
2398
+ // 最大播放量单元格:列出所有 playCount >= 1000 的视频
2399
+ const highPlayVideos = ecVideos.filter((v) => v.playCount >= 1000);
2400
+ let topPlayCountCell;
2401
+ if (highPlayVideos.length > 0) {
2402
+ const videoLinks = highPlayVideos
2403
+ .sort((a, b) => b.playCount - a.playCount)
2404
+ .map((v) => {
2405
+ const adTag = v.isAd ? ' <span style="color:red">(AD)</span>' : "";
2406
+ return `<a href="${v.href}" target="_blank" style="color:#3b82f6;text-decoration:none;display:block;line-height:1.6" title="点击查看视频">${formatNum(v.playCount)}${adTag}</a>`;
2407
+ })
2408
+ .join("");
2409
+ topPlayCountCell = `<td data-label="最大播放量" style="font-size:11px;color:#888">${videoLinks}</td>`;
2410
+ } else {
2411
+ topPlayCountCell = `<td data-label="最大播放量" style="font-size:11px;color:#888">-</td>`;
2412
+ }
2413
+
2414
+ // 最近发布时间:仅当有 playCount >= 1000 的视频时才显示
2415
+ let latestEcTime;
2416
+ if (highPlayVideos.length > 0) {
2417
+ const maxCreateTime = ecVideos.reduce(
2418
+ (max, v) => (v.createTime > max ? v.createTime : max),
2419
+ 0,
2420
+ );
2421
+ if (maxCreateTime > 0) {
2422
+ latestEcTime = formatTime(maxCreateTime * 1000);
2423
+ }
2424
+ }
2425
+ const latestVideoCell = latestEcTime || "-";
2203
2426
 
2204
2427
  const editIcon = ' <span style="font-size:10px;opacity:0.5">✏️</span>';
2205
2428
  const locationCell = u.modifiedAt
@@ -2226,7 +2449,7 @@ function renderTargetTable() {
2226
2449
  ${locationCell}
2227
2450
  <td data-label="确认国家" style="font-size:11px">${confirmedLocation}</td>
2228
2451
  ${topPlayCountCell}
2229
- <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
2452
+ <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideoCell}</td>
2230
2453
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
2231
2454
  <td data-label="操作">
2232
2455
  <button class="btn-pending" onclick="moveToBase('${escapeJsString(u.uniqueId)}')" title="移到 jobs_base 重走流程">待确认</button>
@@ -2970,6 +3193,7 @@ function initTableSorting() {
2970
3193
  }
2971
3194
 
2972
3195
  function initTargetTableSorting() {
3196
+ console.log("[target-sort] 初始化排序监听...");
2973
3197
  document.querySelectorAll("th.sortable-target").forEach((th) => {
2974
3198
  th.addEventListener("click", () => {
2975
3199
  const key = th.dataset.sort;
@@ -2978,7 +3202,7 @@ function initTargetTableSorting() {
2978
3202
  currentTargetSort.asc = !currentTargetSort.asc;
2979
3203
  } else {
2980
3204
  currentTargetSort.key = key;
2981
- currentTargetSort.asc = true;
3205
+ currentTargetSort.asc = false; // 默认降序(高播放在前)
2982
3206
  }
2983
3207
  // 更新排序指示器
2984
3208
  document.querySelectorAll("th.sortable-target").forEach((h) => {
@@ -2989,7 +3213,8 @@ function initTargetTableSorting() {
2989
3213
  th.classList.add(currentTargetSort.asc ? "sort-asc" : "sort-desc");
2990
3214
  const icon = th.querySelector(".sort-icon");
2991
3215
  if (icon) icon.textContent = currentTargetSort.asc ? "↑" : "↓";
2992
- renderTargetTable();
3216
+ // 重新从服务端加载(带排序参数)
3217
+ loadTargetUsersPage(false);
2993
3218
  });
2994
3219
  });
2995
3220
  }
@@ -16,6 +16,7 @@
16
16
  <div style="display:flex;gap:8px;align-items:center">
17
17
  <a href="/scripts/run-explore.sh" class="script-link" download="run-explore.sh">mac</a>
18
18
  <a href="/scripts/run-explore.bat" class="script-link" download="run-explore.bat">windows</a>
19
+ <a class="script-link" onclick="navigateToSettings()" style="cursor:pointer">⚙️ LLM</a>
19
20
  <span class="status" id="lastUpdate">--</span>
20
21
  </div>
21
22
  </div>
@@ -446,6 +447,44 @@
446
447
  </div>
447
448
  </div>
448
449
  </div>
450
+ <div id="settingsPage">
451
+ <div class="stats" style="margin-bottom:16px">
452
+ <div class="stat-card clickable" onclick="navigateToMain()" style="background:rgba(96,165,250,0.1)">
453
+ <div class="label">← 返回主页面</div>
454
+ </div>
455
+ </div>
456
+ <div class="settings-card">
457
+ <h3>⚙️ LLM 模型配置</h3>
458
+ <p class="settings-hint">切换后立即生效,无需重启服务。配置保存在数据库中。</p>
459
+ <div class="settings-form">
460
+ <div class="settings-field">
461
+ <label>服务商</label>
462
+ <select id="settingsProvider" onchange="onProviderChange()"></select>
463
+ </div>
464
+ <div class="settings-field">
465
+ <label>模型</label>
466
+ <select id="settingsModel"></select>
467
+ </div>
468
+ <div class="settings-field">
469
+ <label>API 地址</label>
470
+ <input type="text" id="settingsUrl" placeholder="https://..." readonly>
471
+ <span class="settings-note">选服务商后自动填入,也可手动修改</span>
472
+ </div>
473
+ <div class="settings-field">
474
+ <label>API Key</label>
475
+ <div class="settings-key-row">
476
+ <input type="password" id="settingsApiKey" placeholder="sk-...">
477
+ <button id="settingsToggleKey" onclick="toggleApiKeyVisibility()" title="显示/隐藏">👁</button>
478
+ </div>
479
+ <span class="settings-note" id="settingsKeyHint"></span>
480
+ </div>
481
+ <div class="settings-status" id="settingsStatus"></div>
482
+ <div class="btn-row">
483
+ <button class="btn-submit" onclick="saveLlmConfig()">保存配置</button>
484
+ </div>
485
+ </div>
486
+ </div>
487
+ </div>
449
488
  <script src="app.js"></script>
450
489
  </body>
451
490