tt-help-cli-ycl 1.4.150 → 1.4.152

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.
@@ -996,7 +996,10 @@ function escapeJsString(str) {
996
996
  }
997
997
 
998
998
  document.addEventListener("keydown", (e) => {
999
- if (e.key === "Escape") closeAddModal();
999
+ if (e.key === "Escape") {
1000
+ closeAddModal();
1001
+ closeSkuModal();
1002
+ }
1000
1003
  });
1001
1004
 
1002
1005
  document.getElementById("userTable").addEventListener("click", (e) => {
@@ -1527,16 +1530,12 @@ function normalizeCountryInput(str) {
1527
1530
  function countryFlag(code) {
1528
1531
  if (!/^[A-Za-z]{2}$/.test(code)) return "🏳️";
1529
1532
  return String.fromCodePoint(
1530
- ...[...code.toUpperCase()].map(
1531
- (c) => 0x1f1e6 + c.charCodeAt(0) - 65,
1532
- ),
1533
+ ...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65),
1533
1534
  );
1534
1535
  }
1535
1536
 
1536
1537
  function targetLocationsDirty() {
1537
- return (
1538
- _targetLocations.join(",") !== _targetLocationsSaved.join(",")
1539
- );
1538
+ return _targetLocations.join(",") !== _targetLocationsSaved.join(",");
1540
1539
  }
1541
1540
 
1542
1541
  function updateTargetLocationsStatus() {
@@ -1625,10 +1624,7 @@ function bindLocChipDrag(grid) {
1625
1624
  if (_dragLoc == null || chip.dataset.loc === _dragLoc) return;
1626
1625
  const rect = chip.getBoundingClientRect();
1627
1626
  const before = e.clientX < rect.left + rect.width / 2;
1628
- moveTargetLocation(
1629
- _dragLoc,
1630
- Number(chip.dataset.idx) + (before ? 0 : 1),
1631
- );
1627
+ moveTargetLocation(_dragLoc, Number(chip.dataset.idx) + (before ? 0 : 1));
1632
1628
  });
1633
1629
  });
1634
1630
  }
@@ -1652,7 +1648,9 @@ function removeTargetLocation(loc) {
1652
1648
  }
1653
1649
 
1654
1650
  function addTargetLocation(locRaw) {
1655
- const loc = String(locRaw || "").trim().toUpperCase();
1651
+ const loc = String(locRaw || "")
1652
+ .trim()
1653
+ .toUpperCase();
1656
1654
  if (!/^[A-Z]{2}$/.test(loc)) return false;
1657
1655
  if (_targetLocations.includes(loc)) return false;
1658
1656
  _targetLocations.push(loc); // 新增排最后(优先级最低),可拖动调整
@@ -1675,9 +1673,7 @@ function renderCountryPicker() {
1675
1673
  const selected = new Set(_targetLocations);
1676
1674
  const grid = document.getElementById("countryPickerGrid");
1677
1675
  if (grid) {
1678
- grid.innerHTML = AVAILABLE_COUNTRIES.filter(
1679
- (loc) => !selected.has(loc),
1680
- )
1676
+ grid.innerHTML = AVAILABLE_COUNTRIES.filter((loc) => !selected.has(loc))
1681
1677
  .map(
1682
1678
  (loc) =>
1683
1679
  `<button type="button" class="loc-option" onclick="addTargetLocation('${loc}')">${countryFlag(loc)} ${loc}</button>`,
@@ -2157,7 +2153,7 @@ function renderPreciseTable() {
2157
2153
  if (!el) return;
2158
2154
  if (!currentPreciseUsers.length) {
2159
2155
  el.innerHTML =
2160
- '<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>';
2161
2157
  return;
2162
2158
  }
2163
2159
  el.innerHTML = currentPreciseUsers
@@ -2166,6 +2162,10 @@ function renderPreciseTable() {
2166
2162
  const ec = u.ecOver1k ?? 0;
2167
2163
  const maxEc = u.maxEcPlay ?? 0;
2168
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>';
2169
2169
  return `<tr>
2170
2170
  <td style="text-align:right;color:#666">${i + 1}</td>
2171
2171
  <td><a href="https://www.tiktok.com/@${encodeURIComponent(u.uniqueId)}" target="_blank">@${escapeHtml(u.uniqueId)}</a></td>
@@ -2176,6 +2176,7 @@ function renderPreciseTable() {
2176
2176
  <td style="color:#22c55e">${formatNum(sold)}</td>
2177
2177
  <td style="color:#f87171">${u.productReviewCount ?? 0}</td>
2178
2178
  <td>${u.productRating != null ? u.productRating : "-"}</td>
2179
+ <td>${skuCell}</td>
2179
2180
  <td>${ec}</td>
2180
2181
  <td>${formatNum(maxEc)}</td>
2181
2182
  <td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title || "-"}</td>
@@ -2184,6 +2185,72 @@ function renderPreciseTable() {
2184
2185
  .join("");
2185
2186
  }
2186
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
+
2187
2254
  function renderPreciseVideos() {
2188
2255
  const wrap = document.getElementById("preciseVideoWrap");
2189
2256
  if (!wrap) return;
@@ -2200,6 +2267,11 @@ function renderPreciseVideos() {
2200
2267
  const review = u.productReviewCount ?? 0;
2201
2268
  const rating = u.productRating != null ? u.productRating : "-";
2202
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
+ : "";
2203
2275
  const tiles = vids
2204
2276
  .reduce((acc, v) => {
2205
2277
  const play = v.playCount || 0;
@@ -2225,7 +2297,7 @@ function renderPreciseVideos() {
2225
2297
  <a class="pm-user" href="${userLink}" target="_blank">@${escapeHtml(u.uniqueId)}</a>
2226
2298
  <span class="pm-nick">${escapeHtml(u.nickname || "-")}</span>
2227
2299
  <span class="pm-country">${u.locationCreated || "-"}</span>
2228
- <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>
2229
2301
  </div>
2230
2302
  ${tiles ? `<div class="precise-video-grid">${tiles}</div>` : '<div style="color:#666;font-size:12px;padding:6px 0">该商家暂无 EC 视频数据</div>'}
2231
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>
@@ -557,70 +558,74 @@
557
558
  </div>
558
559
  <div class="settings-layout">
559
560
  <div class="settings-nav" id="settingsNav">
560
- <button type="button" class="settings-nav-item active" data-panel="llm" onclick="switchSettingsPanel('llm')">⚙️ LLM 模型</button>
561
- <button type="button" class="settings-nav-item" data-panel="countries" onclick="switchSettingsPanel('countries')">🎯 目标国家</button>
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>
562
565
  </div>
563
566
  <div class="settings-panels">
564
- <div class="settings-panel active" id="settingsPanel-llm">
565
- <div class="settings-card">
566
- <h3>⚙️ LLM 模型配置</h3>
567
- <p class="settings-hint">切换后立即生效,无需重启服务。配置保存在数据库中。</p>
568
- <div class="settings-form">
569
- <div class="settings-field">
570
- <label>服务商</label>
571
- <select id="settingsProvider" onchange="onProviderChange()"></select>
572
- </div>
573
- <div class="settings-field">
574
- <label>模型</label>
575
- <select id="settingsModel"></select>
576
- </div>
577
- <div class="settings-field">
578
- <label>API 地址</label>
579
- <input type="text" id="settingsUrl" placeholder="https://..." readonly>
580
- <span class="settings-note">选服务商后自动填入,也可手动修改</span>
581
- </div>
582
- <div class="settings-field">
583
- <label>API Key</label>
584
- <div class="settings-key-row">
585
- <input type="password" id="settingsApiKey" placeholder="sk-...">
586
- <button id="settingsToggleKey" onclick="toggleApiKeyVisibility()" title="显示/隐藏">👁</button>
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>
587
598
  </div>
588
- <span class="settings-note" id="settingsKeyHint"></span>
589
- </div>
590
- <div class="settings-status" id="settingsStatus"></div>
591
- <div class="btn-row">
592
- <button class="btn-submit" onclick="saveLlmConfig()">保存配置</button>
593
599
  </div>
594
- </div>
595
- </div>
596
- </div>
597
- <div class="settings-panel" id="settingsPanel-countries">
598
- <div class="settings-card">
599
- <h3>🎯 目标国家</h3>
600
- <p class="settings-hint">设置采集的目标国家列表及领取优先级,保存后写入通用配置文件(~/.tt-help-config.json),立即生效。列表顺序即任务领取优先级:越靠前越先领取。</p>
601
- <div class="settings-form">
602
- <div class="settings-field">
603
- <label>目标国家(拖动卡片排序,位置即领取优先级:越靠前越先领取)</label>
604
- <div class="loc-chips" id="targetLocationsGrid"></div>
605
- <div class="country-picker" id="countryPicker" style="display:none">
606
- <div class="country-picker-title">选择要添加的国家(新加的排最后,可拖动调整优先级)</div>
607
- <div class="loc-grid" id="countryPickerGrid"></div>
608
- <div class="custom-loc-row">
609
- <input type="text" id="customCountryInput" class="custom-loc-input" placeholder="其他代码,如 GB" maxlength="2" onkeydown="if(event.key==='Enter')addCustomCountry()">
610
- <button type="button" class="btn-submit" onclick="addCustomCountry()">添加</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>
611
624
  </div>
612
625
  </div>
613
626
  </div>
614
- <div class="settings-status" id="targetLocationsStatus"></div>
615
- <div class="btn-row">
616
- <button class="btn-submit" onclick="saveTargetLocations()">保存目标国家</button>
617
- <button class="btn-cancel" onclick="resetTargetLocations()">恢复默认</button>
618
- </div>
619
627
  </div>
620
628
  </div>
621
- </div>
622
- </div>
623
- </div>
624
629
  </div>
625
630
  <div id="watchdogPage">
626
631
  <div class="stats" style="margin-bottom:16px">
@@ -1730,10 +1730,7 @@ td.user-id:hover {
1730
1730
  color: #999;
1731
1731
  cursor: pointer;
1732
1732
  font-size: 13px;
1733
- transition:
1734
- color 0.15s,
1735
- background 0.15s,
1736
- border-color 0.15s;
1733
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
1737
1734
  }
1738
1735
 
1739
1736
  .settings-nav-item:hover {
@@ -2099,6 +2096,85 @@ td.user-id:hover {
2099
2096
  }
2100
2097
 
2101
2098
  /* ===== 精准商家:视频卡片视图 ===== */
2099
+ /* ── 精准商家页 SKU 列与弹窗 ── */
2100
+ .sku-cell {
2101
+ cursor: pointer;
2102
+ color: #60a5fa;
2103
+ white-space: nowrap;
2104
+ }
2105
+ .sku-cell:hover {
2106
+ text-decoration: underline;
2107
+ }
2108
+ .sku-modal {
2109
+ width: 640px;
2110
+ display: flex;
2111
+ flex-direction: column;
2112
+ max-height: 80vh;
2113
+ }
2114
+ .sku-modal-meta {
2115
+ font-size: 12px;
2116
+ color: #9ca3af;
2117
+ margin: 6px 0 12px;
2118
+ display: flex;
2119
+ flex-direction: column;
2120
+ gap: 4px;
2121
+ }
2122
+ .sku-modal-name {
2123
+ color: #e0e0e0;
2124
+ font-size: 13px;
2125
+ }
2126
+ .sku-modal-link {
2127
+ color: #60a5fa;
2128
+ text-decoration: none;
2129
+ font-size: 12px;
2130
+ }
2131
+ .sku-modal-link:hover {
2132
+ text-decoration: underline;
2133
+ }
2134
+ .sku-table-wrap {
2135
+ overflow: auto;
2136
+ border: 1px solid #2a2a3a;
2137
+ border-radius: 8px;
2138
+ }
2139
+ .sku-table {
2140
+ width: 100%;
2141
+ border-collapse: collapse;
2142
+ font-size: 12px;
2143
+ }
2144
+ .sku-table th {
2145
+ position: sticky;
2146
+ top: 0;
2147
+ background: #14141d;
2148
+ color: #9ca3af;
2149
+ text-align: left;
2150
+ padding: 8px 10px;
2151
+ border-bottom: 1px solid #2a2a3a;
2152
+ }
2153
+ .sku-table td {
2154
+ padding: 6px 10px;
2155
+ border-bottom: 1px solid #22222e;
2156
+ color: #d1d5db;
2157
+ }
2158
+ .sku-spec {
2159
+ max-width: 320px;
2160
+ overflow: hidden;
2161
+ text-overflow: ellipsis;
2162
+ white-space: nowrap;
2163
+ }
2164
+ .sku-id {
2165
+ color: #6b7280;
2166
+ font-family: monospace;
2167
+ }
2168
+ .sku-ok {
2169
+ color: #4ade80;
2170
+ }
2171
+ .sku-low {
2172
+ color: #fbbf24;
2173
+ }
2174
+ .sku-oos {
2175
+ color: #f87171;
2176
+ }
2177
+
2102
2178
  .precise-list-header {
2103
2179
  display: flex;
2104
2180
  align-items: center;
@@ -592,6 +592,9 @@ export function startWatchServer(
592
592
  "CREATE INDEX IF NOT EXISTS idx_videos_user_update ON videos(user_update_count)",
593
593
  // product-fill 领任务查询的部分索引:只索引候选行,避免全表扫描 164 万行
594
594
  "CREATE INDEX IF NOT EXISTS idx_jobs_product_fill ON jobs(claimed_at, updated_at) WHERE top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND (product_name IS NULL OR product_name = '') AND product_rating IS NULL AND product_sold_count IS NULL",
595
+ // precheck 候选查询部分索引:预缩候选集且天然按 refresh_time 有序,
596
+ // 消除 TEMP B-TREE 排序(76 万行实测 ~1s → 毫秒级)
597
+ "CREATE INDEX IF NOT EXISTS idx_jobs_precheck ON jobs(refresh_time) WHERE tt_seller = 1 AND verified = 0 AND (pending_refresh IS NULL OR pending_refresh = 0)",
595
598
  ];
596
599
  for (const sql of ensureIdx) {
597
600
  try {
@@ -1607,6 +1610,17 @@ export function startWatchServer(
1607
1610
  productReviewCount: u.productReviewCount ?? null,
1608
1611
  productSoldCount: u.productSoldCount ?? null,
1609
1612
  productName: u.productName || null,
1613
+ skuCount: u.skuCount || 0,
1614
+ skuTotalStock: u.skuTotalStock || 0,
1615
+ productSkus: (() => {
1616
+ try {
1617
+ return Array.isArray(u.productSkus)
1618
+ ? u.productSkus
1619
+ : JSON.parse(u.productSkus || "[]");
1620
+ } catch {
1621
+ return [];
1622
+ }
1623
+ })(),
1610
1624
  ecOver1k: u.ecOver1k || 0,
1611
1625
  maxEcPlay: u.maxEcPlay || null,
1612
1626
  recentEcVideos: (() => {