tt-help-cli-ycl 1.4.20 → 1.4.22

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.
@@ -268,6 +268,7 @@ function renderActiveClients(clients) {
268
268
  "explore",
269
269
  "refresh",
270
270
  "attach",
271
+ "recheck",
271
272
  "comments",
272
273
  "scoring",
273
274
  "tag-refresh",
@@ -276,6 +277,7 @@ function renderActiveClients(clients) {
276
277
  explore: "Explore",
277
278
  refresh: "Refresh",
278
279
  attach: "Attach",
280
+ recheck: "Recheck",
279
281
  comments: "Comments",
280
282
  scoring: "Scoring",
281
283
  "tag-refresh": "TagRefresh",
@@ -1243,6 +1245,8 @@ function handleRoute() {
1243
1245
  showTargetPage();
1244
1246
  } else if (hash === "#tags") {
1245
1247
  showTagsPage();
1248
+ } else if (hash === "#settings") {
1249
+ showSettingsPage();
1246
1250
  } else {
1247
1251
  showMainPage();
1248
1252
  }
@@ -1271,6 +1275,7 @@ function showPendingPage() {
1271
1275
  document.getElementById("rawPage").classList.remove("active");
1272
1276
  document.getElementById("targetPage").classList.remove("active");
1273
1277
  document.getElementById("tagsPage").classList.remove("active");
1278
+ document.getElementById("settingsPage").classList.remove("active");
1274
1279
  fetchPendingByCountry();
1275
1280
  fetchAttachStuckByCountry();
1276
1281
  }
@@ -1282,6 +1287,7 @@ function showUserUpdatePage() {
1282
1287
  document.getElementById("rawPage").classList.remove("active");
1283
1288
  document.getElementById("targetPage").classList.remove("active");
1284
1289
  document.getElementById("tagsPage").classList.remove("active");
1290
+ document.getElementById("settingsPage").classList.remove("active");
1285
1291
  fetchUserUpdateByCountry();
1286
1292
  fetchAttachStuckByCountry();
1287
1293
  }
@@ -1300,6 +1306,7 @@ function showRawPage() {
1300
1306
  document.getElementById("rawPage").classList.add("active");
1301
1307
  document.getElementById("targetPage").classList.remove("active");
1302
1308
  document.getElementById("tagsPage").classList.remove("active");
1309
+ document.getElementById("settingsPage").classList.remove("active");
1303
1310
  fetchRawByCountry();
1304
1311
  fetchRawJobs();
1305
1312
  }
@@ -1311,6 +1318,7 @@ function showMainPage() {
1311
1318
  document.getElementById("rawPage").classList.remove("active");
1312
1319
  document.getElementById("targetPage").classList.remove("active");
1313
1320
  document.getElementById("tagsPage").classList.remove("active");
1321
+ document.getElementById("settingsPage").classList.remove("active");
1314
1322
  }
1315
1323
 
1316
1324
  function navigateToTarget() {
@@ -1321,6 +1329,189 @@ function navigateToTags() {
1321
1329
  window.location.hash = "#tags";
1322
1330
  }
1323
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
+
1324
1515
  // ========== 添加关键词弹窗 ==========
1325
1516
 
1326
1517
  function openAddTagModal() {
@@ -1481,6 +1672,7 @@ function showTagsPage() {
1481
1672
  document.getElementById("rawPage").classList.remove("active");
1482
1673
  document.getElementById("targetPage").classList.remove("active");
1483
1674
  document.getElementById("tagsPage").classList.add("active");
1675
+ document.getElementById("settingsPage").classList.remove("active");
1484
1676
  showLoading("正在加载关键词...");
1485
1677
  Promise.all([loadTagStats(), loadTagsPage(), loadRefreshStats()]).finally(
1486
1678
  hideLoading,
@@ -1624,6 +1816,11 @@ async function loadTagsPage(append = false) {
1624
1816
  if (currentTagsFilter !== "all") url += `&status=${currentTagsFilter}`;
1625
1817
  if (country) url += `&country=${encodeURIComponent(country)}`;
1626
1818
 
1819
+ const searchVal =
1820
+ document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
1821
+ "";
1822
+ if (searchVal) url += `&search=${encodeURIComponent(searchVal)}`;
1823
+
1627
1824
  const res = await fetch(url);
1628
1825
  const data = await res.json();
1629
1826
 
@@ -1650,14 +1847,6 @@ function renderTagsTable() {
1650
1847
  const moreHint = document.getElementById("tagsMoreHint");
1651
1848
  let display = [...currentTags];
1652
1849
 
1653
- // 搜索过滤(本地二次过滤)
1654
- const searchVal =
1655
- document.getElementById("tagsSearchInput")?.value.trim().toLowerCase() ||
1656
- "";
1657
- if (searchVal) {
1658
- display = display.filter((t) => t.tag.toLowerCase().includes(searchVal));
1659
- }
1660
-
1661
1850
  // 本地排序
1662
1851
  if (currentTagsSort.key) {
1663
1852
  display.sort((a, b) => {
@@ -1955,6 +2144,7 @@ function showTargetPage() {
1955
2144
  document.getElementById("rawPage").classList.remove("active");
1956
2145
  document.getElementById("targetPage").classList.add("active");
1957
2146
  document.getElementById("tagsPage").classList.remove("active");
2147
+ document.getElementById("settingsPage").classList.remove("active");
1958
2148
  showLoading("正在加载目标商家数据...");
1959
2149
  fetchTargetByCountry();
1960
2150
  fetchTargetOtherCountries();
@@ -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
 
@@ -1623,3 +1623,157 @@ td.user-id:hover {
1623
1623
  pointer-events: none;
1624
1624
  z-index: 9999;
1625
1625
  }
1626
+
1627
+ /* ========== LLM 设置页 ========== */
1628
+ #settingsPage {
1629
+ display: none;
1630
+ }
1631
+
1632
+ #settingsPage.active {
1633
+ display: block;
1634
+ }
1635
+
1636
+ .settings-card {
1637
+ background: #1a1a24;
1638
+ border-radius: 12px;
1639
+ padding: 28px;
1640
+ max-width: 560px;
1641
+ margin: 0 auto;
1642
+ }
1643
+
1644
+ .settings-card h3 {
1645
+ font-size: 17px;
1646
+ color: #e0e0e0;
1647
+ margin-bottom: 6px;
1648
+ }
1649
+
1650
+ .settings-hint {
1651
+ font-size: 12px;
1652
+ color: #888;
1653
+ margin-bottom: 24px;
1654
+ }
1655
+
1656
+ .settings-form {
1657
+ display: flex;
1658
+ flex-direction: column;
1659
+ gap: 18px;
1660
+ }
1661
+
1662
+ .settings-field label {
1663
+ display: block;
1664
+ font-size: 13px;
1665
+ color: #aaa;
1666
+ margin-bottom: 6px;
1667
+ font-weight: 600;
1668
+ }
1669
+
1670
+ .settings-field input,
1671
+ .settings-field select {
1672
+ width: 100%;
1673
+ padding: 10px 14px;
1674
+ border: 1px solid #333;
1675
+ border-radius: 8px;
1676
+ background: #0f0f13;
1677
+ color: #e0e0e0;
1678
+ font-size: 14px;
1679
+ font-family: inherit;
1680
+ outline: none;
1681
+ transition: border-color 0.2s;
1682
+ }
1683
+
1684
+ .settings-field input:focus,
1685
+ .settings-field select:focus {
1686
+ border-color: #fe2c55;
1687
+ }
1688
+
1689
+ .settings-field input[readonly] {
1690
+ color: #888;
1691
+ cursor: default;
1692
+ }
1693
+
1694
+ .settings-field select {
1695
+ cursor: pointer;
1696
+ appearance: none;
1697
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
1698
+ background-repeat: no-repeat;
1699
+ background-position: right 12px center;
1700
+ padding-right: 36px;
1701
+ }
1702
+
1703
+ .settings-key-row {
1704
+ display: flex;
1705
+ gap: 6px;
1706
+ }
1707
+
1708
+ .settings-key-row input {
1709
+ flex: 1;
1710
+ }
1711
+
1712
+ .settings-key-row button {
1713
+ padding: 8px 12px;
1714
+ border: 1px solid #333;
1715
+ border-radius: 8px;
1716
+ background: #2a2a3a;
1717
+ color: #ccc;
1718
+ font-size: 16px;
1719
+ cursor: pointer;
1720
+ transition: background 0.2s;
1721
+ }
1722
+
1723
+ .settings-key-row button:hover {
1724
+ background: #333;
1725
+ }
1726
+
1727
+ .settings-note {
1728
+ display: block;
1729
+ font-size: 11px;
1730
+ color: #666;
1731
+ margin-top: 4px;
1732
+ }
1733
+
1734
+ .settings-status {
1735
+ padding: 10px 14px;
1736
+ border-radius: 8px;
1737
+ font-size: 13px;
1738
+ text-align: center;
1739
+ }
1740
+
1741
+ .settings-status.ok {
1742
+ background: rgba(74, 222, 128, 0.1);
1743
+ color: #4ade80;
1744
+ border: 1px solid rgba(74, 222, 128, 0.2);
1745
+ }
1746
+
1747
+ .settings-status.error {
1748
+ background: rgba(248, 113, 113, 0.1);
1749
+ color: #f87171;
1750
+ border: 1px solid rgba(248, 113, 113, 0.2);
1751
+ }
1752
+
1753
+ .settings-status.loading {
1754
+ background: rgba(250, 204, 21, 0.1);
1755
+ color: #facc15;
1756
+ border: 1px solid rgba(250, 204, 21, 0.2);
1757
+ }
1758
+
1759
+ .settings-form .btn-row {
1760
+ display: flex;
1761
+ justify-content: flex-end;
1762
+ margin-top: 4px;
1763
+ }
1764
+
1765
+ .settings-form .btn-submit {
1766
+ padding: 10px 28px;
1767
+ border: none;
1768
+ border-radius: 8px;
1769
+ background: #fe2c55;
1770
+ color: #fff;
1771
+ font-size: 14px;
1772
+ font-weight: 600;
1773
+ cursor: pointer;
1774
+ transition: background 0.2s;
1775
+ }
1776
+
1777
+ .settings-form .btn-submit:hover {
1778
+ background: #e61944;
1779
+ }
@@ -216,6 +216,16 @@ export function startWatchServer(
216
216
  return new Promise((_resolve, reject) => {
217
217
  const store = existingStore || createStore(dataAnchor, options);
218
218
 
219
+ // 初始化 LLM 动态配置(SQLite > 环境变量 > 默认值)
220
+ import("./llm-config.js").then((m) => m.loadLlmConfig());
221
+
222
+ // 启动时清理废弃的反思规则(避免 DB 膨胀)
223
+ import("./db-tags.js").then((m) => {
224
+ if (m.cleanupInactiveReflectionRules) {
225
+ m.cleanupInactiveReflectionRules();
226
+ }
227
+ });
228
+
219
229
  function logJob(action, detail) {
220
230
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
221
231
  const d = detail
@@ -1253,6 +1263,55 @@ export function startWatchServer(
1253
1263
  return;
1254
1264
  }
1255
1265
 
1266
+ // ====== LLM 配置 API ======
1267
+ if (req.method === "GET" && routePath === "/api/llm-config") {
1268
+ try {
1269
+ const { getLlmConfig, LLM_PROVIDERS } =
1270
+ await import("./llm-config.js");
1271
+ sendJSON(res, 200, {
1272
+ ...getLlmConfig(),
1273
+ providers: LLM_PROVIDERS,
1274
+ });
1275
+ } catch (e) {
1276
+ sendJSON(res, 500, { error: e.message });
1277
+ }
1278
+ return;
1279
+ }
1280
+
1281
+ if (req.method === "POST" && routePath === "/api/llm-config") {
1282
+ try {
1283
+ const body = await readBody(req);
1284
+ const { updateLlmConfig, getLlmConfig, LLM_PROVIDERS } =
1285
+ await import("./llm-config.js");
1286
+ const result = updateLlmConfig({
1287
+ provider: body.provider,
1288
+ model: body.model,
1289
+ url: body.url,
1290
+ apiKey: body.apiKey,
1291
+ });
1292
+ sendJSON(res, 200, { ...result, providers: LLM_PROVIDERS });
1293
+ } catch (e) {
1294
+ sendJSON(res, 400, { error: e.message });
1295
+ }
1296
+ return;
1297
+ }
1298
+
1299
+ // GET /api/llm-test — 直接测试 LLM API 连通性
1300
+ if (req.method === "GET" && routePath === "/api/llm-test") {
1301
+ try {
1302
+ const { callLLM } = await import("./tag-service.js");
1303
+ const result = await callLLM("Say hello in exactly one word.", 0.1);
1304
+ sendJSON(res, 200, {
1305
+ ok: result.length > 0,
1306
+ reply: result || "(空)",
1307
+ replyLen: result.length,
1308
+ });
1309
+ } catch (e) {
1310
+ sendJSON(res, 500, { ok: false, error: e.message });
1311
+ }
1312
+ return;
1313
+ }
1314
+
1256
1315
  if (req.method === "POST" && routePath === "/api/attach-stuck/restore") {
1257
1316
  try {
1258
1317
  const body = await readBody(req);
@@ -1502,23 +1561,31 @@ export function startWatchServer(
1502
1561
  return;
1503
1562
  }
1504
1563
 
1505
- // GET /api/tags?status=&country=&limit=&offset=
1564
+ // GET /api/tags?status=&country=&search=&limit=&offset=
1506
1565
  if (req.method === "GET" && routePath === "/api/tags") {
1507
1566
  const status = params.status || null;
1508
1567
  const country = params.country || null;
1568
+ const search = params.search || null;
1509
1569
  const limit = Math.min(parseInt(params.limit) || 200, 500);
1510
1570
  const offset = parseInt(params.offset) || 0;
1511
1571
  const upperCountry = country ? country.toUpperCase() : null;
1572
+ const searchTerm = search ? search.trim().toLowerCase() : null;
1512
1573
 
1513
1574
  let tags;
1514
1575
  if (status) {
1515
- tags = store.getTagsByStatus(status, limit, offset, upperCountry);
1576
+ tags = store.getTagsByStatus(
1577
+ status,
1578
+ limit,
1579
+ offset,
1580
+ upperCountry,
1581
+ searchTerm,
1582
+ );
1516
1583
  } else {
1517
- tags = store.getAllTags(limit, offset, upperCountry);
1584
+ tags = store.getAllTags(limit, offset, upperCountry, searchTerm);
1518
1585
  }
1519
1586
 
1520
- // 获取总数(带国家过滤,按 status 返回对应计数)
1521
- const stats = store.getTagStats(upperCountry);
1587
+ // 获取总数(带搜索过滤,按 status 返回对应计数)
1588
+ const stats = store.getTagStats(upperCountry, searchTerm);
1522
1589
  let total;
1523
1590
  if (stats) {
1524
1591
  if (status === "new") total = stats.new;
@@ -1676,6 +1743,35 @@ export function startWatchServer(
1676
1743
  return;
1677
1744
  }
1678
1745
 
1746
+ // POST /api/tags/batch-add-if-new — 批量添加 tag(只插入不存在的,不覆盖已有)
1747
+ if (req.method === "POST" && routePath === "/api/tags/batch-add-if-new") {
1748
+ try {
1749
+ const body = await readBody(req);
1750
+ const { tags, countries, source } = body || {};
1751
+ if (!Array.isArray(tags) || tags.length === 0) {
1752
+ sendJSON(res, 400, { error: "tags 不能为空" });
1753
+ return;
1754
+ }
1755
+ if (!Array.isArray(countries) || countries.length === 0) {
1756
+ sendJSON(res, 400, { error: "countries 不能为空" });
1757
+ return;
1758
+ }
1759
+ const ret = store.insertTagsIfNew(tags, countries, source || "video");
1760
+ if (ret.error) {
1761
+ sendJSON(res, 500, { error: ret.error });
1762
+ return;
1763
+ }
1764
+ sendJSON(res, 200, {
1765
+ ok: true,
1766
+ inserted: ret.inserted,
1767
+ tags: ret.tags,
1768
+ });
1769
+ } catch (e) {
1770
+ sendJSON(res, 500, { error: e.message });
1771
+ }
1772
+ return;
1773
+ }
1774
+
1679
1775
  // GET /api/tags/health?country=ES — 自闭环:获取国家 tag 健康指标
1680
1776
  if (req.method === "GET" && routePath === "/api/tags/health") {
1681
1777
  try {
@@ -2179,7 +2275,9 @@ export function startWatchServer(
2179
2275
 
2180
2276
  if (
2181
2277
  req.method === "GET" &&
2182
- (routePath === "/" || routePath === "/index.html")
2278
+ (routePath === "/" ||
2279
+ routePath === "/index.html" ||
2280
+ routePath === "/settings")
2183
2281
  ) {
2184
2282
  // 设有密码时,检查登录态
2185
2283
  if (PASSWORD_ENABLED) {