tt-help-cli-ycl 1.4.149 → 1.4.151

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.
@@ -182,7 +182,7 @@ function renderStats() {
182
182
  flashEl("statTotal", d.totalUsers);
183
183
  flashEl("statProcessing", d.processingUsers || 0);
184
184
  flashEl("statDone", d.processedUsers);
185
- flashEl("statPending", d.pendingUsers);
185
+ flashEl("statPending", d.pendingUsers, { full: true });
186
186
  flashEl("statError", d.errorUsers);
187
187
  flashEl("statTarget", d.targetUsers, { full: true });
188
188
  flashEl("statPrecise", d.preciseMerchants, { full: true });
@@ -206,7 +206,8 @@ function renderStats() {
206
206
  const pendingTotal = document.getElementById("pendingStatTotal");
207
207
  if (pendingTotal) pendingTotal.textContent = formatStatNum(d.totalUsers);
208
208
  const pendingCount = document.getElementById("pendingStatPending");
209
- if (pendingCount) pendingCount.textContent = formatStatNum(d.pendingUsers);
209
+ if (pendingCount)
210
+ pendingCount.textContent = formatStatNum(d.pendingUsers, { full: true });
210
211
  const pendingUserUpdate = document.getElementById(
211
212
  "pendingStatUserUpdateTasks",
212
213
  );
@@ -222,7 +223,9 @@ function renderStats() {
222
223
  userUpdateTotal.textContent = formatStatNum(d.totalUsers);
223
224
  const userUpdatePending = document.getElementById("userUpdateStatPending");
224
225
  if (userUpdatePending)
225
- userUpdatePending.textContent = formatStatNum(d.pendingUsers);
226
+ userUpdatePending.textContent = formatStatNum(d.pendingUsers, {
227
+ full: true,
228
+ });
226
229
  const userUpdateTasks = document.getElementById(
227
230
  "userUpdateStatUserUpdateTasks",
228
231
  );
@@ -238,7 +241,9 @@ function renderStats() {
238
241
  rawPageRawJobs.textContent = formatStatNum(d.rawJobs || 0);
239
242
  const rawPagePending = document.getElementById("rawPageStatPending");
240
243
  if (rawPagePending)
241
- rawPagePending.textContent = formatStatNum(d.pendingUsers || 0);
244
+ rawPagePending.textContent = formatStatNum(d.pendingUsers || 0, {
245
+ full: true,
246
+ });
242
247
  const rawPageUserUpdate = document.getElementById(
243
248
  "rawPageStatUserUpdateTasks",
244
249
  );
@@ -247,11 +252,11 @@ function renderStats() {
247
252
  document.getElementById("lastUpdate").textContent =
248
253
  "更新于 " + new Date().toLocaleTimeString();
249
254
  document.getElementById("fileMeta").textContent =
250
- formatStatNum(d.processingUsers || 0) +
255
+ formatStatNum(d.processingUsers || 0, { full: true }) +
251
256
  " 处理中, " +
252
- formatStatNum(d.totalUsers) +
257
+ formatStatNum(d.totalUsers, { full: true }) +
253
258
  " 个用户, 待处理 " +
254
- formatStatNum(d.pendingUsers || 0);
259
+ formatStatNum(d.pendingUsers || 0, { full: true });
255
260
 
256
261
  renderCountryChart(d.countryStats);
257
262
  renderSourceChart(d.sourceStats);
@@ -700,6 +705,8 @@ const DEFAULT_LOCATION_OPTIONS = [
700
705
  "PT",
701
706
  "TH",
702
707
  "US",
708
+ "MY",
709
+ "VN",
703
710
  ];
704
711
  let locationOptions = [...DEFAULT_LOCATION_OPTIONS];
705
712
 
@@ -989,7 +996,10 @@ function escapeJsString(str) {
989
996
  }
990
997
 
991
998
  document.addEventListener("keydown", (e) => {
992
- if (e.key === "Escape") closeAddModal();
999
+ if (e.key === "Escape") {
1000
+ closeAddModal();
1001
+ closeSkuModal();
1002
+ }
993
1003
  });
994
1004
 
995
1005
  document.getElementById("userTable").addEventListener("click", (e) => {
@@ -1299,8 +1309,12 @@ function handleRoute() {
1299
1309
  showPrecisePage();
1300
1310
  } else if (hash === "#tags") {
1301
1311
  showTagsPage();
1302
- } else if (hash === "#settings") {
1312
+ } else if (hash === "#settings" || hash.startsWith("#settings/")) {
1303
1313
  showSettingsPage();
1314
+ const sub = hash.startsWith("#settings/")
1315
+ ? hash.slice("#settings/".length)
1316
+ : "";
1317
+ switchSettingsPanel(sub || "llm", false);
1304
1318
  } else if (hash === "#watchdog") {
1305
1319
  showWatchdogPage();
1306
1320
  } else {
@@ -1437,6 +1451,24 @@ function showSettingsPage() {
1437
1451
  loadTargetLocations();
1438
1452
  }
1439
1453
 
1454
+ // 设置页左侧分类切换:panel 对应 settings-nav-item 的 data-panel 与 settingsPanel-<panel> 容器
1455
+ function switchSettingsPanel(panel, updateHash = true) {
1456
+ document
1457
+ .querySelectorAll("#settingsNav .settings-nav-item")
1458
+ .forEach((btn) =>
1459
+ btn.classList.toggle("active", btn.dataset.panel === panel),
1460
+ );
1461
+ document
1462
+ .querySelectorAll(".settings-panel")
1463
+ .forEach((el) =>
1464
+ el.classList.toggle("active", el.id === "settingsPanel-" + panel),
1465
+ );
1466
+ if (updateHash) {
1467
+ const target = panel === "llm" ? "#settings" : "#settings/" + panel;
1468
+ if (window.location.hash !== target) window.location.hash = target;
1469
+ }
1470
+ }
1471
+
1440
1472
  // ========== 目标国家配置(通用 app-config,落盘 ~/.tt-help-config.json) ==========
1441
1473
 
1442
1474
  const AVAILABLE_COUNTRIES = [
@@ -1455,6 +1487,8 @@ const AVAILABLE_COUNTRIES = [
1455
1487
  "PT",
1456
1488
  "TH",
1457
1489
  "US",
1490
+ "MY",
1491
+ "VN",
1458
1492
  "GB",
1459
1493
  "SE",
1460
1494
  "NO",
@@ -1476,6 +1510,11 @@ const AVAILABLE_COUNTRIES = [
1476
1510
  "CO",
1477
1511
  ];
1478
1512
 
1513
+ // ========== 目标国家配置(宫格 + 拖拽排序,位置即领取优先级) ==========
1514
+
1515
+ let _targetLocations = []; // 顺序即领取优先级,拖拽/增删后需点保存才落盘
1516
+ let _targetLocationsSaved = []; // 最近一次加载/保存的快照,用于脏状态提示
1517
+
1479
1518
  function normalizeCountryInput(str) {
1480
1519
  return [
1481
1520
  ...new Set(
@@ -1487,20 +1526,39 @@ function normalizeCountryInput(str) {
1487
1526
  ];
1488
1527
  }
1489
1528
 
1529
+ /** 两位国家代码 → 国旗 emoji */
1530
+ function countryFlag(code) {
1531
+ if (!/^[A-Za-z]{2}$/.test(code)) return "🏳️";
1532
+ return String.fromCodePoint(
1533
+ ...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65),
1534
+ );
1535
+ }
1536
+
1537
+ function targetLocationsDirty() {
1538
+ return _targetLocations.join(",") !== _targetLocationsSaved.join(",");
1539
+ }
1540
+
1541
+ function updateTargetLocationsStatus() {
1542
+ const status = document.getElementById("targetLocationsStatus");
1543
+ if (!status) return;
1544
+ if (targetLocationsDirty()) {
1545
+ status.textContent = `⛔ 有未保存的修改(当前 ${_targetLocations.length} 国,顺序: ${_targetLocations.join(" → ")}),点“保存目标国家”生效`;
1546
+ status.className = "settings-status warn";
1547
+ } else {
1548
+ status.textContent = `✅ 已生效:${_targetLocations.length} 国,位置即领取优先级(越靠前越先领)`;
1549
+ status.className = "settings-status ok";
1550
+ }
1551
+ }
1552
+
1490
1553
  async function loadTargetLocations() {
1491
- const input = document.getElementById("targetLocationsInput");
1492
1554
  const status = document.getElementById("targetLocationsStatus");
1493
- if (!input) return;
1494
1555
  try {
1495
1556
  const res = await fetch("/api/settings/target-locations");
1496
1557
  const data = await res.json();
1497
- const locations = data.locations || [];
1498
- input.value = locations.join(",");
1499
- renderTargetCountryGrid(locations);
1500
- if (status) {
1501
- status.textContent = `当前 ${locations.length} 个国家: ${locations.join(", ")}`;
1502
- status.className = "settings-status ok";
1503
- }
1558
+ _targetLocations = (data.locations || []).map(String);
1559
+ _targetLocationsSaved = [..._targetLocations];
1560
+ renderTargetLocationsGrid();
1561
+ updateTargetLocationsStatus();
1504
1562
  } catch (e) {
1505
1563
  console.error("loadTargetLocations error:", e);
1506
1564
  if (status) {
@@ -1510,47 +1568,151 @@ async function loadTargetLocations() {
1510
1568
  }
1511
1569
  }
1512
1570
 
1513
- function renderTargetCountryGrid(selected) {
1514
- const grid = document.getElementById("targetCountryGrid");
1571
+ function renderTargetLocationsGrid() {
1572
+ const grid = document.getElementById("targetLocationsGrid");
1515
1573
  if (!grid) return;
1516
- const selectedSet = new Set(selected || []);
1517
- grid.innerHTML = AVAILABLE_COUNTRIES.map(
1518
- (loc) =>
1519
- `<button type="button" class="loc-option ${selectedSet.has(loc) ? "active" : ""}" onclick="toggleTargetCountry('${loc}')">${loc}</button>`,
1520
- ).join("");
1574
+ const chips = _targetLocations
1575
+ .map(
1576
+ (loc, i) => `
1577
+ <div class="loc-chip" draggable="true" data-loc="${loc}" data-idx="${i}" title="拖动排序(位置即优先级)">
1578
+ <span class="loc-chip-idx">${i + 1}</span>
1579
+ <span class="loc-chip-flag">${countryFlag(loc)}</span>
1580
+ <span class="loc-chip-code">${loc}</span>
1581
+ <button type="button" class="loc-chip-del" onclick="removeTargetLocation('${loc}')" title="移除 ${loc}">×</button>
1582
+ </div>`,
1583
+ )
1584
+ .join("");
1585
+ grid.innerHTML =
1586
+ chips +
1587
+ `<button type="button" class="loc-chip loc-chip-add" onclick="toggleCountryPicker()" title="添加国家">+</button>`;
1588
+ bindLocChipDrag(grid);
1589
+ renderCountryPicker();
1590
+ }
1591
+
1592
+ // ---- 拖拽排序(HTML5 DnD) ----
1593
+ let _dragLoc = null;
1594
+
1595
+ function bindLocChipDrag(grid) {
1596
+ grid.querySelectorAll(".loc-chip[draggable]").forEach((chip) => {
1597
+ chip.addEventListener("dragstart", (e) => {
1598
+ _dragLoc = chip.dataset.loc;
1599
+ chip.classList.add("dragging");
1600
+ e.dataTransfer.effectAllowed = "move";
1601
+ e.dataTransfer.setData("text/plain", _dragLoc);
1602
+ });
1603
+ chip.addEventListener("dragend", () => {
1604
+ _dragLoc = null;
1605
+ chip.classList.remove("dragging");
1606
+ grid
1607
+ .querySelectorAll(".drop-before,.drop-after")
1608
+ .forEach((el) => el.classList.remove("drop-before", "drop-after"));
1609
+ });
1610
+ chip.addEventListener("dragover", (e) => {
1611
+ e.preventDefault();
1612
+ e.dataTransfer.dropEffect = "move";
1613
+ if (_dragLoc == null || chip.dataset.loc === _dragLoc) return;
1614
+ const rect = chip.getBoundingClientRect();
1615
+ const before = e.clientX < rect.left + rect.width / 2;
1616
+ chip.classList.toggle("drop-before", before);
1617
+ chip.classList.toggle("drop-after", !before);
1618
+ });
1619
+ chip.addEventListener("dragleave", () => {
1620
+ chip.classList.remove("drop-before", "drop-after");
1621
+ });
1622
+ chip.addEventListener("drop", (e) => {
1623
+ e.preventDefault();
1624
+ if (_dragLoc == null || chip.dataset.loc === _dragLoc) return;
1625
+ const rect = chip.getBoundingClientRect();
1626
+ const before = e.clientX < rect.left + rect.width / 2;
1627
+ moveTargetLocation(_dragLoc, Number(chip.dataset.idx) + (before ? 0 : 1));
1628
+ });
1629
+ });
1630
+ }
1631
+
1632
+ /** 把 fromLoc 移动到目标插入位(insertAt 为移除前语义下的目标下标) */
1633
+ function moveTargetLocation(fromLoc, insertAt) {
1634
+ const fromIdx = _targetLocations.indexOf(fromLoc);
1635
+ if (fromIdx === -1) return;
1636
+ _targetLocations.splice(fromIdx, 1);
1637
+ const target = Math.max(0, Math.min(insertAt, _targetLocations.length));
1638
+ _targetLocations.splice(target, 0, fromLoc);
1639
+ renderTargetLocationsGrid();
1640
+ updateTargetLocationsStatus();
1641
+ }
1642
+
1643
+ // ---- 添加 / 删除 ----
1644
+ function removeTargetLocation(loc) {
1645
+ _targetLocations = _targetLocations.filter((l) => l !== loc);
1646
+ renderTargetLocationsGrid();
1647
+ updateTargetLocationsStatus();
1648
+ }
1649
+
1650
+ function addTargetLocation(locRaw) {
1651
+ const loc = String(locRaw || "")
1652
+ .trim()
1653
+ .toUpperCase();
1654
+ if (!/^[A-Z]{2}$/.test(loc)) return false;
1655
+ if (_targetLocations.includes(loc)) return false;
1656
+ _targetLocations.push(loc); // 新增排最后(优先级最低),可拖动调整
1657
+ renderTargetLocationsGrid();
1658
+ updateTargetLocationsStatus();
1659
+ return true;
1660
+ }
1661
+
1662
+ // ---- 添加面板(+展开) ----
1663
+ function toggleCountryPicker() {
1664
+ const picker = document.getElementById("countryPicker");
1665
+ if (!picker) return;
1666
+ picker.style.display = picker.style.display === "none" ? "block" : "none";
1667
+ renderCountryPicker();
1668
+ }
1669
+
1670
+ function renderCountryPicker() {
1671
+ const picker = document.getElementById("countryPicker");
1672
+ if (!picker || picker.style.display === "none") return;
1673
+ const selected = new Set(_targetLocations);
1674
+ const grid = document.getElementById("countryPickerGrid");
1675
+ if (grid) {
1676
+ grid.innerHTML = AVAILABLE_COUNTRIES.filter((loc) => !selected.has(loc))
1677
+ .map(
1678
+ (loc) =>
1679
+ `<button type="button" class="loc-option" onclick="addTargetLocation('${loc}')">${countryFlag(loc)} ${loc}</button>`,
1680
+ )
1681
+ .join("");
1682
+ }
1521
1683
  }
1522
1684
 
1523
- function toggleTargetCountry(loc) {
1524
- const input = document.getElementById("targetLocationsInput");
1685
+ function addCustomCountry() {
1686
+ const input = document.getElementById("customCountryInput");
1525
1687
  if (!input) return;
1526
- let current = normalizeCountryInput(input.value);
1527
- if (current.includes(loc)) {
1528
- current = current.filter((l) => l !== loc);
1688
+ const ok = addTargetLocation(input.value);
1689
+ if (ok) {
1690
+ input.value = "";
1691
+ input.focus();
1529
1692
  } else {
1530
- current.push(loc);
1693
+ input.placeholder = "无效或已存在(两位大写字母)";
1694
+ setTimeout(() => (input.placeholder = "其他代码,如 GB"), 2000);
1531
1695
  }
1532
- input.value = current.join(",");
1533
- renderTargetCountryGrid(current);
1696
+ renderCountryPicker();
1534
1697
  }
1535
1698
 
1699
+ // ---- 保存 / 恢复默认 ----
1536
1700
  async function saveTargetLocations() {
1537
- const input = document.getElementById("targetLocationsInput");
1538
1701
  const status = document.getElementById("targetLocationsStatus");
1539
- if (!input) return;
1540
- const locations = normalizeCountryInput(input.value);
1702
+ if (!status) return;
1541
1703
  status.textContent = "保存中...";
1542
1704
  status.className = "settings-status loading";
1543
1705
  try {
1544
1706
  const res = await fetch("/api/settings/target-locations", {
1545
1707
  method: "POST",
1546
1708
  headers: { "Content-Type": "application/json" },
1547
- body: JSON.stringify({ locations }),
1709
+ body: JSON.stringify({ locations: _targetLocations }),
1548
1710
  });
1549
1711
  const data = await res.json();
1550
- input.value = (data.locations || []).join(",");
1551
- renderTargetCountryGrid(data.locations || []);
1552
- status.textContent = `已保存 ${(data.locations || []).length} 个国家`;
1553
- status.className = "settings-status ok";
1712
+ _targetLocations = (data.locations || []).map(String);
1713
+ _targetLocationsSaved = [..._targetLocations];
1714
+ renderTargetLocationsGrid();
1715
+ updateTargetLocationsStatus();
1554
1716
  } catch (e) {
1555
1717
  console.error("saveTargetLocations error:", e);
1556
1718
  status.textContent = "保存失败";
@@ -1571,13 +1733,10 @@ async function resetTargetLocations() {
1571
1733
  body: JSON.stringify({ locations: [] }),
1572
1734
  });
1573
1735
  const data = await res.json();
1574
- const input = document.getElementById("targetLocationsInput");
1575
- if (input) input.value = (data.locations || []).join(",");
1576
- renderTargetCountryGrid(data.locations || []);
1577
- if (status) {
1578
- status.textContent = `已恢复默认 ${(data.locations || []).length} 个国家`;
1579
- status.className = "settings-status ok";
1580
- }
1736
+ _targetLocations = (data.locations || []).map(String);
1737
+ _targetLocationsSaved = [..._targetLocations];
1738
+ renderTargetLocationsGrid();
1739
+ updateTargetLocationsStatus();
1581
1740
  } catch (e) {
1582
1741
  console.error("resetTargetLocations error:", e);
1583
1742
  if (status) {
@@ -1994,7 +2153,7 @@ function renderPreciseTable() {
1994
2153
  if (!el) return;
1995
2154
  if (!currentPreciseUsers.length) {
1996
2155
  el.innerHTML =
1997
- '<tr><td colspan="12" style="text-align:center;color:#666;padding:20px">暂无精准商家</td></tr>';
2156
+ '<tr><td colspan="13" style="text-align:center;color:#666;padding:20px">暂无精准商家</td></tr>';
1998
2157
  return;
1999
2158
  }
2000
2159
  el.innerHTML = currentPreciseUsers
@@ -2003,6 +2162,10 @@ function renderPreciseTable() {
2003
2162
  const ec = u.ecOver1k ?? 0;
2004
2163
  const maxEc = u.maxEcPlay ?? 0;
2005
2164
  const title = (u.productName || "").replace(/</g, "&lt;");
2165
+ const skus = Array.isArray(u.productSkus) ? u.productSkus : [];
2166
+ const skuCell = skus.length
2167
+ ? `<span class="sku-cell" onclick="openSkuModal(${i})" title="点击查看 ${skus.length} 个 SKU 明细">📦 ${skus.length} · 库${formatNum(u.skuTotalStock || 0)}</span>`
2168
+ : '<span style="color:#555">-</span>';
2006
2169
  return `<tr>
2007
2170
  <td style="text-align:right;color:#666">${i + 1}</td>
2008
2171
  <td><a href="https://www.tiktok.com/@${encodeURIComponent(u.uniqueId)}" target="_blank">@${escapeHtml(u.uniqueId)}</a></td>
@@ -2013,6 +2176,7 @@ function renderPreciseTable() {
2013
2176
  <td style="color:#22c55e">${formatNum(sold)}</td>
2014
2177
  <td style="color:#f87171">${u.productReviewCount ?? 0}</td>
2015
2178
  <td>${u.productRating != null ? u.productRating : "-"}</td>
2179
+ <td>${skuCell}</td>
2016
2180
  <td>${ec}</td>
2017
2181
  <td>${formatNum(maxEc)}</td>
2018
2182
  <td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title || "-"}</td>
@@ -2021,6 +2185,72 @@ function renderPreciseTable() {
2021
2185
  .join("");
2022
2186
  }
2023
2187
 
2188
+ // ── SKU 明细弹窗(精准商家页)──
2189
+ function openSkuModal(idx) {
2190
+ const u = currentPreciseUsers[idx];
2191
+ if (!u) return;
2192
+ const skus = Array.isArray(u.productSkus) ? u.productSkus : [];
2193
+ if (!skus.length) return;
2194
+ let overlay = document.getElementById("skuModalOverlay");
2195
+ if (overlay) overlay.remove();
2196
+ overlay = document.createElement("div");
2197
+ overlay.id = "skuModalOverlay";
2198
+ overlay.className = "modal-overlay";
2199
+ const totalStock = skus.reduce((s, x) => s + (x.stock || 0), 0);
2200
+ const rows = skus
2201
+ .map((s) => {
2202
+ const stock = s.stock ?? null;
2203
+ const stockCls =
2204
+ stock === null
2205
+ ? ""
2206
+ : stock === 0 || s.stockStatus === 2
2207
+ ? "sku-oos"
2208
+ : stock <= 20
2209
+ ? "sku-low"
2210
+ : "sku-ok";
2211
+ const stockText =
2212
+ stock === null
2213
+ ? "-"
2214
+ : stock === 0 || s.stockStatus === 2
2215
+ ? "售罄"
2216
+ : formatNum(stock);
2217
+ return `<tr>
2218
+ <td class="sku-spec" title="${escapeAttr(s.specs || "")}">${escapeHtml(s.specs || "-")}</td>
2219
+ <td class="sku-id" title="${escapeAttr(s.skuId || "")}">${escapeHtml(String(s.skuId || "-").slice(-6))}</td>
2220
+ <td class="${stockCls}">${stockText}</td>
2221
+ <td>${s.stockStatus ?? "-"}</td>
2222
+ </tr>`;
2223
+ })
2224
+ .join("");
2225
+ overlay.innerHTML = `
2226
+ <div class="modal sku-modal">
2227
+ <h3>📦 @${escapeHtml(u.uniqueId)} 的 SKU(${skus.length} 个)</h3>
2228
+ <div class="sku-modal-meta">
2229
+ ${escapeHtml(u.productName || "") ? `<div class="sku-modal-name">${escapeHtml(u.productName)}</div>` : ""}
2230
+ <div>总库存 <b>${formatNum(totalStock)}</b> · 销量 <b style="color:#22c55e">${formatNum(u.productSoldCount ?? 0)}</b> · 评分 <b>${u.productRating ?? "-"}</b></div>
2231
+ ${u.topVideoSeoUrl ? `<a href="${escapeAttr(u.topVideoSeoUrl)}" target="_blank" class="sku-modal-link">🛒 打开商品页</a>` : ""}
2232
+ </div>
2233
+ <div class="sku-table-wrap">
2234
+ <table class="sku-table">
2235
+ <thead><tr><th>规格</th><th>SKU尾号</th><th>库存</th><th>状态</th></tr></thead>
2236
+ <tbody>${rows}</tbody>
2237
+ </table>
2238
+ </div>
2239
+ <div class="btn-row" style="margin-top:14px">
2240
+ <button class="btn-cancel" onclick="closeSkuModal()">关闭</button>
2241
+ </div>
2242
+ </div>`;
2243
+ document.body.appendChild(overlay);
2244
+ overlay.addEventListener("click", (e) => {
2245
+ if (e.target === overlay) closeSkuModal();
2246
+ });
2247
+ }
2248
+
2249
+ function closeSkuModal() {
2250
+ const overlay = document.getElementById("skuModalOverlay");
2251
+ if (overlay) overlay.remove();
2252
+ }
2253
+
2024
2254
  function renderPreciseVideos() {
2025
2255
  const wrap = document.getElementById("preciseVideoWrap");
2026
2256
  if (!wrap) return;
@@ -2037,6 +2267,11 @@ function renderPreciseVideos() {
2037
2267
  const review = u.productReviewCount ?? 0;
2038
2268
  const rating = u.productRating != null ? u.productRating : "-";
2039
2269
  const userLink = `https://www.tiktok.com/@${encodeURIComponent(u.uniqueId)}`;
2270
+ const skus = Array.isArray(u.productSkus) ? u.productSkus : [];
2271
+ const idx = currentPreciseUsers.indexOf(u);
2272
+ const skuBadge = skus.length
2273
+ ? ` · <span class="sku-cell" onclick="event.stopPropagation();openSkuModal(${idx})" title="点击查看 ${skus.length} 个 SKU 明细">📦 SKU ${skus.length} · 库${formatNum(u.skuTotalStock || skus.reduce((s, x) => s + (x.stock || 0), 0))}</span>`
2274
+ : "";
2040
2275
  const tiles = vids
2041
2276
  .reduce((acc, v) => {
2042
2277
  const play = v.playCount || 0;
@@ -2062,7 +2297,7 @@ function renderPreciseVideos() {
2062
2297
  <a class="pm-user" href="${userLink}" target="_blank">@${escapeHtml(u.uniqueId)}</a>
2063
2298
  <span class="pm-nick">${escapeHtml(u.nickname || "-")}</span>
2064
2299
  <span class="pm-country">${u.locationCreated || "-"}</span>
2065
- <span class="pm-stats">粉丝 ${formatNum(u.followerCount || 0)} · 视频 ${u.videoCount || 0} · 销量 ${formatNum(sold)} · 评论 ${review} · 评分 ${rating} · EC视频 ${vids.length}</span>
2300
+ <span class="pm-stats">粉丝 ${formatNum(u.followerCount || 0)} · 视频 ${u.videoCount || 0} · 销量 ${formatNum(sold)} · 评论 ${review} · 评分 ${rating} · EC视频 ${vids.length}${skuBadge}</span>
2066
2301
  </div>
2067
2302
  ${tiles ? `<div class="precise-video-grid">${tiles}</div>` : '<div style="color:#666;font-size:12px;padding:6px 0">该商家暂无 EC 视频数据</div>'}
2068
2303
  </div>`;
@@ -464,6 +464,7 @@
464
464
  <th>销量</th>
465
465
  <th>评论</th>
466
466
  <th>评分</th>
467
+ <th>SKU</th>
467
468
  <th>EC超1k视频数</th>
468
469
  <th>最高EC播放</th>
469
470
  <th>商品</th>
@@ -555,54 +556,73 @@
555
556
  <div class="label">← 返回主页面</div>
556
557
  </div>
557
558
  </div>
558
- <div class="settings-card">
559
- <h3>⚙️ LLM 模型配置</h3>
560
- <p class="settings-hint">切换后立即生效,无需重启服务。配置保存在数据库中。</p>
561
- <div class="settings-form">
562
- <div class="settings-field">
563
- <label>服务商</label>
564
- <select id="settingsProvider" onchange="onProviderChange()"></select>
565
- </div>
566
- <div class="settings-field">
567
- <label>模型</label>
568
- <select id="settingsModel"></select>
569
- </div>
570
- <div class="settings-field">
571
- <label>API 地址</label>
572
- <input type="text" id="settingsUrl" placeholder="https://..." readonly>
573
- <span class="settings-note">选服务商后自动填入,也可手动修改</span>
574
- </div>
575
- <div class="settings-field">
576
- <label>API Key</label>
577
- <div class="settings-key-row">
578
- <input type="password" id="settingsApiKey" placeholder="sk-...">
579
- <button id="settingsToggleKey" onclick="toggleApiKeyVisibility()" title="显示/隐藏">👁</button>
559
+ <div class="settings-layout">
560
+ <div class="settings-nav" id="settingsNav">
561
+ <button type="button" class="settings-nav-item active" data-panel="llm" onclick="switchSettingsPanel('llm')">⚙️
562
+ LLM 模型</button>
563
+ <button type="button" class="settings-nav-item" data-panel="countries"
564
+ onclick="switchSettingsPanel('countries')">🎯 目标国家</button>
565
+ </div>
566
+ <div class="settings-panels">
567
+ <div class="settings-panel active" id="settingsPanel-llm">
568
+ <div class="settings-card">
569
+ <h3>⚙️ LLM 模型配置</h3>
570
+ <p class="settings-hint">切换后立即生效,无需重启服务。配置保存在数据库中。</p>
571
+ <div class="settings-form">
572
+ <div class="settings-field">
573
+ <label>服务商</label>
574
+ <select id="settingsProvider" onchange="onProviderChange()"></select>
575
+ </div>
576
+ <div class="settings-field">
577
+ <label>模型</label>
578
+ <select id="settingsModel"></select>
579
+ </div>
580
+ <div class="settings-field">
581
+ <label>API 地址</label>
582
+ <input type="text" id="settingsUrl" placeholder="https://..." readonly>
583
+ <span class="settings-note">选服务商后自动填入,也可手动修改</span>
584
+ </div>
585
+ <div class="settings-field">
586
+ <label>API Key</label>
587
+ <div class="settings-key-row">
588
+ <input type="password" id="settingsApiKey" placeholder="sk-...">
589
+ <button id="settingsToggleKey" onclick="toggleApiKeyVisibility()" title="显示/隐藏">👁</button>
590
+ </div>
591
+ <span class="settings-note" id="settingsKeyHint"></span>
592
+ </div>
593
+ <div class="settings-status" id="settingsStatus"></div>
594
+ <div class="btn-row">
595
+ <button class="btn-submit" onclick="saveLlmConfig()">保存配置</button>
596
+ </div>
597
+ </div>
580
598
  </div>
581
- <span class="settings-note" id="settingsKeyHint"></span>
582
- </div>
583
- <div class="settings-status" id="settingsStatus"></div>
584
- <div class="btn-row">
585
- <button class="btn-submit" onclick="saveLlmConfig()">保存配置</button>
586
599
  </div>
587
- </div>
588
- </div>
589
- <div class="settings-card">
590
- <h3>🎯 目标国家</h3>
591
- <p class="settings-hint">设置采集的目标国家列表,保存后写入通用配置文件(~/.tt-help-config.json)。当前仅保存配置,后续接入采集流程后生效。</p>
592
- <div class="settings-form">
593
- <div class="settings-field">
594
- <label>目标国家(英文国家代码,逗号分隔,如 CZ,GR,TH)</label>
595
- <input type="text" id="targetLocationsInput" placeholder="CZ,GR,HU,PT,ES,PL,NL,BE,DE,FR,IT,IE,AT,TH,US">
596
- <span class="settings-note">可手动输入,或点击下方国家快速选择。</span>
597
- </div>
598
- <div class="settings-field">
599
- <label>快速选择</label>
600
- <div class="loc-grid" id="targetCountryGrid"></div>
601
- </div>
602
- <div class="settings-status" id="targetLocationsStatus"></div>
603
- <div class="btn-row">
604
- <button class="btn-submit" onclick="saveTargetLocations()">保存目标国家</button>
605
- <button class="btn-cancel" onclick="resetTargetLocations()">恢复默认</button>
600
+ <div class="settings-panel" id="settingsPanel-countries">
601
+ <div class="settings-card">
602
+ <h3>🎯 目标国家</h3>
603
+ <p class="settings-hint">设置采集的目标国家列表及领取优先级,保存后写入通用配置文件(~/.tt-help-config.json),立即生效。列表顺序即任务领取优先级:越靠前越先领取。
604
+ </p>
605
+ <div class="settings-form">
606
+ <div class="settings-field">
607
+ <label>目标国家(拖动卡片排序,位置即领取优先级:越靠前越先领取)</label>
608
+ <div class="loc-chips" id="targetLocationsGrid"></div>
609
+ <div class="country-picker" id="countryPicker" style="display:none">
610
+ <div class="country-picker-title">选择要添加的国家(新加的排最后,可拖动调整优先级)</div>
611
+ <div class="loc-grid" id="countryPickerGrid"></div>
612
+ <div class="custom-loc-row">
613
+ <input type="text" id="customCountryInput" class="custom-loc-input" placeholder="其他代码,如 GB"
614
+ maxlength="2" onkeydown="if(event.key==='Enter')addCustomCountry()">
615
+ <button type="button" class="btn-submit" onclick="addCustomCountry()">添加</button>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ <div class="settings-status" id="targetLocationsStatus"></div>
620
+ <div class="btn-row">
621
+ <button class="btn-submit" onclick="saveTargetLocations()">保存目标国家</button>
622
+ <button class="btn-cancel" onclick="resetTargetLocations()">恢复默认</button>
623
+ </div>
624
+ </div>
625
+ </div>
606
626
  </div>
607
627
  </div>
608
628
  </div>