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.
- package/package.json +1 -1
- package/src/cli/comments.js +63 -1
- package/src/cli/refresh.js +1 -1
- package/src/lib/parse-ssr.mjs +6 -0
- package/src/lib/tag-discover.js +8 -4
- package/src/lib/tag-fetcher.js +5 -0
- package/src/scraper/explore-core.js +14 -4
- package/src/watch/data-store.js +80 -1
- package/src/watch/db-columns.js +2 -0
- package/src/watch/db-schema.js +1 -0
- package/src/watch/db-tags.js +237 -17
- package/src/watch/llm-config.js +319 -0
- package/src/watch/llm-scoring.js +103 -63
- package/src/watch/public/app.js +248 -23
- package/src/watch/public/index.html +39 -0
- package/src/watch/public/style.css +154 -0
- package/src/watch/server.js +109 -6
- package/src/watch/tag-service.js +119 -41
package/src/watch/llm-scoring.js
CHANGED
|
@@ -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
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
101
|
+
parsed = JSON.parse(match[0]);
|
|
85
102
|
} catch {
|
|
86
|
-
// 尝试
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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:
|
|
128
|
-
reason:
|
|
166
|
+
score: 50,
|
|
167
|
+
reason: "LLM 响应解析失败,使用默认分",
|
|
129
168
|
};
|
|
130
|
-
}
|
|
169
|
+
} // end for
|
|
131
170
|
|
|
171
|
+
// 全部模型均失败
|
|
132
172
|
console.error(
|
|
133
|
-
`[scoreJobLocation]
|
|
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:
|
|
178
|
+
reason: `全部模型失败: ${lastError}`,
|
|
139
179
|
};
|
|
140
180
|
} catch (e) {
|
|
141
181
|
console.error(
|
|
142
|
-
`[scoreJobLocation] LLM
|
|
182
|
+
`[scoreJobLocation] LLM 调用异常 (${job.unique_id}): ${e.message}`,
|
|
143
183
|
);
|
|
144
184
|
return {
|
|
145
185
|
uniqueId: job.unique_id,
|
package/src/watch/public/app.js
CHANGED
|
@@ -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
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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">${
|
|
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 =
|
|
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
|
-
|
|
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
|
|