const TOKEN_KEY = "productSourcing.sessionToken"; const GRID_VIEWS = new Set(["capture", "competitors", "suppliers", "testing", "decisions"]); const FILTERABLE_VIEWS = new Set(["capture", "competitors", "suppliers", "testing", "decisions"]); const SELECTABLE_VIEWS = new Set(["capture", "competitors", "suppliers", "testing", "decisions"]); const STATS_VIEWS = new Set(["overview", "capture", "competitors", "suppliers", "matching", "testing", "decisions"]); const EXPORTABLE_VIEWS = new Set(["overview", "capture", "competitors", "suppliers", "matching", "testing", "decisions"]); const VIEW_LABELS = { overview: "首页总览", capture: "采集中心", competitors: "竞品库", suppliers: "货源库", matching: "款式匹配", testing: "拿样测试", decisions: "采购决策", admin: "管理中心", plugin: "采集插件", rules: "规则配置", account: "账号设置", }; const FILTER_LABELS = { q: "关键词", platform: "平台", track: "赛道", decision: "建议动作", status: "状态", tag: "标签", priceBand: "价格带", source: "货源", risk: "风险", }; const DEFAULT_EXTENSION_PACKAGE = { name: "选品采购采集器", recommendedVersion: "0.0.0", installUrl: "/downloads/product-sourcing-capture-extension.zip", fileName: "product-sourcing-capture-extension.zip", reloadRequiredForUnpacked: true, }; const state = { view: "overview", authRequired: false, extensionInfo: DEFAULT_EXTENSION_PACKAGE, sessionToken: localStorage.getItem(TOKEN_KEY) || "", user: null, rules: null, pluginTokens: [], adminUsers: [], creditCodes: [], aiSettings: null, aiStatus: null, aiAnalysisByItem: {}, adminLoading: false, adminError: "", aiAdminMessage: "", lastPluginToken: "", lastPluginTokenId: "", locked: false, landingOpen: false, items: [], dashboard: null, groups: [], sampleItems: [], selectedIds: new Set(), bulkDeletePending: false, filters: { q: "", platform: "", track: "", decision: "", status: "", tag: "", priceBand: "", source: "", risk: "", }, }; const els = { appShell: document.querySelector(".app-shell"), authGate: document.querySelector("#authGate"), authDialog: document.querySelector("#authDialog"), authDialogTitle: document.querySelector("#authDialogTitle"), authDialogText: document.querySelector("#authDialogText"), authSwitchText: document.querySelector("#authSwitchText"), authSwitchButton: document.querySelector("#authSwitchButton"), openAuthPage: document.querySelector("#openAuthPageBtn"), landingBack: document.querySelector("#landingBackBtn"), registerForm: document.querySelector("#registerForm"), loginForm: document.querySelector("#loginForm"), authMessage: document.querySelector("#authMessage"), sessionBadge: document.querySelector("#sessionBadge"), logout: document.querySelector("#logoutBtn"), statsGrid: document.querySelector(".stats-grid"), grid: document.querySelector("#itemsGrid"), empty: document.querySelector("#emptyState"), refresh: document.querySelector("#refreshBtn"), search: document.querySelector("#searchInput"), mobileSearch: document.querySelector("#mobileSearchInput"), platform: document.querySelector("#platformSelect"), track: document.querySelector("#trackSelect"), decision: document.querySelector("#decisionSelect"), status: document.querySelector("#statusSelect"), tag: document.querySelector("#tagInput"), priceBand: document.querySelector("#priceBandSelect"), source: document.querySelector("#sourceSelect"), risk: document.querySelector("#riskSelect"), mobileFilter: document.querySelector("#mobileFilterBtn"), mobileAuth: document.querySelector("#mobileAuthBtn"), mobileRefresh: document.querySelector("#mobileRefreshBtn"), mobileViewTitle: document.querySelector("#mobileViewTitle"), filterDialog: document.querySelector("#filterDialog"), filterToolbar: document.querySelector("#filterToolbar"), filterContext: document.querySelector("#filterContextText"), filterMode: document.querySelector("#filterModeLabel"), filterResultCount: document.querySelector("#filterResultCount"), filterActive: document.querySelector("#filterActiveText"), filterSheetBody: document.querySelector(".filter-sheet-body"), clearFilters: document.querySelector("#clearFiltersBtn"), dialog: document.querySelector("#itemDialog"), dialogContent: document.querySelector("#dialogContent"), statTotal: document.querySelector("#statTotal"), statSample: document.querySelector("#statSample"), stat1688: document.querySelector("#stat1688"), statTibetan: document.querySelector("#statTibetan"), workbench: document.querySelector("#workbenchPanel"), bulkToolbar: document.querySelector("#bulkToolbar"), bulkStatusText: document.querySelector("#bulkStatusText"), selectedCount: document.querySelector("#selectedCount"), selectVisible: document.querySelector("#selectVisibleBtn"), clearSelection: document.querySelector("#clearSelectionBtn"), deleteSelected: document.querySelector("#deleteSelectedBtn"), confirmDelete: document.querySelector("#confirmDeleteBtn"), cancelDelete: document.querySelector("#cancelDeleteBtn"), tagSelected: document.querySelector("#tagSelectedBtn"), mobileStatusMenu: document.querySelector("#mobileStatusMenuBtn"), statusActionDialog: document.querySelector("#statusActionDialog"), emptyPrimary: document.querySelector("#emptyPrimaryBtn"), emptySecondary: document.querySelector("#emptySecondaryBtn"), }; async function init() { try { const health = await fetchJson("/api/health", { skipAuth: true }); state.authRequired = Boolean(health.authRequired); state.extensionInfo = { ...DEFAULT_EXTENSION_PACKAGE, ...(health.extension || {}) }; if (state.sessionToken) { try { const me = await fetchJson("/api/me"); state.user = me.user; } catch { clearSession(); } } if (!state.user) { setAppLocked(true); return; } setAppLocked(false); await loadConfig(); await loadItems(); } catch { state.extensionInfo = DEFAULT_EXTENSION_PACKAGE; markAuthReady(); els.empty.hidden = false; els.empty.querySelector("p").textContent = "服务未启动,请在项目目录运行 npm start。"; } } async function loadConfig() { if (!state.user) { state.rules = null; state.pluginTokens = []; state.adminUsers = []; state.creditCodes = []; state.aiSettings = null; state.aiStatus = null; return; } const [mePayload, rulesPayload, tokensPayload, aiStatusPayload] = await Promise.all([ fetchJson("/api/me"), fetchJson("/api/rules"), fetchJson("/api/plugin-tokens"), fetchJson("/api/ai/status"), ]); state.user = mePayload.user || state.user; state.rules = rulesPayload.rules; state.pluginTokens = tokensPayload.pluginTokens || []; state.aiStatus = aiStatusPayload || null; } async function loadAdminUsers() { if (!isAdmin()) { state.adminUsers = []; state.creditCodes = []; state.aiSettings = null; state.adminError = ""; state.adminLoading = false; return; } state.adminLoading = true; state.adminError = ""; render(); try { const [usersPayload, creditCodesPayload, aiSettingsPayload] = await Promise.all([ fetchJson("/api/admin/users"), fetchJson("/api/admin/credit-codes"), fetchJson("/api/admin/ai-settings"), ]); state.adminUsers = usersPayload.users || []; state.creditCodes = creditCodesPayload.creditCodes || []; state.aiSettings = aiSettingsPayload.aiSettings || null; } catch (error) { state.adminError = error.message || "管理中心加载失败"; } finally { state.adminLoading = false; render(); } } async function loadItems() { if (state.locked) return; const params = new URLSearchParams(); for (const [key, value] of Object.entries(state.filters)) { if (value) params.set(key, value); } const [itemsPayload, dashboardPayload, groupsPayload, samplePayload] = await Promise.all([ fetchJson(`/api/items?${params.toString()}`), fetchJson("/api/dashboard"), fetchJson("/api/style-groups"), fetchJson("/api/sample-queue"), ]); state.items = itemsPayload.items || []; state.dashboard = dashboardPayload.dashboard || null; state.groups = groupsPayload.groups || []; state.sampleItems = samplePayload.items || []; pruneSelection(); render(); } function render() { if (state.locked) return; if (state.view === "admin" && !isAdmin()) state.view = "account"; document.body.dataset.view = state.view; renderChrome(); renderStats(); renderWorkbench(); renderBulkToolbar(); const items = visibleItems(); const showGrid = GRID_VIEWS.has(state.view); const emptyCopy = getEmptyStateCopy(); els.statsGrid.hidden = !STATS_VIEWS.has(state.view); els.filterToolbar.hidden = !FILTERABLE_VIEWS.has(state.view); els.mobileFilter.hidden = !FILTERABLE_VIEWS.has(state.view); renderFilterBench(items); els.empty.hidden = items.length > 0 || !showGrid; if (emptyCopy) { els.empty.querySelector("h3").textContent = emptyCopy.title; els.empty.querySelector("p").textContent = emptyCopy.body; els.emptyPrimary.textContent = emptyCopy.primaryLabel; els.emptyPrimary.dataset.emptyPrimaryView = emptyCopy.primaryView; els.emptySecondary.hidden = !emptyCopy.secondaryLabel; els.emptySecondary.textContent = emptyCopy.secondaryLabel || ""; } els.grid.hidden = !showGrid; els.grid.innerHTML = visibleItems().map(renderCard).join(""); bindCardActions(); } function renderChrome() { const roleLabel = state.user?.role === "admin" ? "管理员" : "买手"; const isFilterableView = FILTERABLE_VIEWS.has(state.view); els.sessionBadge.textContent = state.user ? `${state.user.name || state.user.email} · ${roleLabel}` : "本地模式"; if (els.mobileViewTitle) els.mobileViewTitle.textContent = VIEW_LABELS[state.view] || "首页总览"; els.logout.hidden = !state.user; els.openAuthPage.hidden = false; els.openAuthPage.textContent = state.user ? "我的账号" : "创建账号/登录"; els.mobileAuth.hidden = Boolean(state.user); if (els.mobileSearch) { els.mobileSearch.closest(".mobile-search-field")?.toggleAttribute("hidden", !isFilterableView); } document.querySelectorAll("[data-export-csv]").forEach((button) => { button.hidden = !EXPORTABLE_VIEWS.has(state.view); }); els.landingBack.hidden = !state.user; document.querySelectorAll(".admin-only").forEach((element) => { element.hidden = !isAdmin(); }); if (state.view === "admin" && !isAdmin()) { state.view = "account"; document.body.dataset.view = state.view; syncActiveNav("account"); } } function renderStats() { const totals = state.dashboard?.totals; els.statTotal.textContent = totals?.items ?? state.items.length; els.statSample.textContent = totals?.suggestedSample ?? state.items.filter((item) => item.decision === "拿样").length; els.stat1688.textContent = totals?.suppliers1688 ?? state.items.filter((item) => item.platform === "1688").length; els.statTibetan.textContent = totals?.styleGroups ?? state.groups.length; } function renderFilterBench(items) { if (!FILTERABLE_VIEWS.has(state.view)) return; const activeFilters = Object.entries(state.filters).filter(([, value]) => Boolean(value)); const context = { capture: "先看最近采集进来的图片、链接、价格和平台线索,快速清掉误采和不适合直播的款。", competitors: "筛淘宝、小红书、天猫竞品,优先找热度可见、镜头表现强、直播话术安全的 100 元内饰品。", suppliers: "筛 1688 货源,重点看同款/近似款、拿样价、供应商信誉、混批和售后保障。", testing: "筛问供应商、已拿样和直播测试中的款,把验证进度从线索推进到采购判断。", decisions: "筛高分、可跑量、风险低的采购候选,误选项可以多选后批量清理。", }; els.filterContext.textContent = context[state.view] || "按平台、赛道、状态、价格和风险快速缩小候选款。"; els.filterMode.textContent = VIEW_LABELS[state.view] || "当前视图"; els.filterResultCount.textContent = String(items.length); els.filterActive.textContent = activeFilters.length === 0 ? "未启用筛选" : activeFilters .map(([key, value]) => `${FILTER_LABELS[key] || key}:${filterValueLabel(key, value)}`) .slice(0, 3) .join(" / "); } function renderWorkbench() { if (state.view === "overview") { els.workbench.hidden = false; els.workbench.innerHTML = renderOverview(); bindWorkbenchActions(); return; } if (state.view === "capture") { els.workbench.hidden = false; els.workbench.innerHTML = renderCaptureCenter(); bindWorkbenchActions(); return; } if (state.view === "competitors") { els.workbench.hidden = false; els.workbench.innerHTML = renderLibraryHeader({ eyebrow: "Competitor Desk", title: "竞品库", body: "淘宝、小红书、天猫等直播竞品集中在这里清洗。优先找搜索热、直播容易讲、100 元以内能跑量的款。", count: visibleItems().length, actionLabel: "清空筛选", action: "clear-filters", }); bindWorkbenchActions(); return; } if (state.view === "suppliers") { els.workbench.hidden = false; els.workbench.innerHTML = renderLibraryHeader({ eyebrow: "1688 Supply", title: "货源库", body: "只看 1688 货源,重点比较同款式、低拿样价、供应商信誉、混批和售后保障。", count: visibleItems().length, actionLabel: "只看 1688", action: "source-1688", }); bindWorkbenchActions(); return; } if (state.view === "matching") { els.workbench.hidden = false; els.workbench.innerHTML = renderStyleGroups(); bindWorkbenchActions(); return; } if (state.view === "testing") { els.workbench.hidden = false; els.workbench.innerHTML = renderSampleQueue(); bindWorkbenchActions(); return; } if (state.view === "decisions") { els.workbench.hidden = false; els.workbench.innerHTML = renderProcurementDecisions(); bindWorkbenchActions(); return; } if (state.view === "admin") { els.workbench.hidden = false; els.workbench.innerHTML = renderAdminCenter(); bindAdminActions(); return; } if (state.view === "plugin") { els.workbench.hidden = false; els.workbench.innerHTML = renderPluginSettings(); bindSettingsActions(); return; } if (state.view === "rules") { els.workbench.hidden = false; els.workbench.innerHTML = renderRuleSettings(); bindSettingsActions(); return; } if (state.view === "account") { els.workbench.hidden = false; els.workbench.innerHTML = renderProfile(); bindProfileActions(); return; } els.workbench.hidden = true; els.workbench.innerHTML = ""; } function renderOverview() { const dashboard = state.dashboard; if (!dashboard) return ""; const nextActions = dashboard.nextActions.map((action) => `
  • ${escapeHtml(action)}
  • `).join(""); const topItems = dashboard.topItems.map(renderCompactItem).join(""); const lowPriceItems = (dashboard.lowPriceItems || []).slice(0, 6).map(renderCompactItem).join(""); const riskItems = (dashboard.riskItems || []).slice(0, 6).map(renderCompactItem).join(""); const profitItems = (dashboard.profitHealthyItems || []).slice(0, 6).map(renderCompactItem).join(""); const workflow = [ ["采集线索", dashboard.totals.items], ["清洗竞品", competitorItems().length], ["匹配货源", dashboard.totals.suppliers1688], ["拿样测试", dashboard.totals.samplePipeline], ["采购决策", procurementCandidates().length], ] .map(([label, value], index) => workflowStep(label, value, index + 1)) .join(""); const tracks = Object.entries(dashboard.byTrack || {}) .sort((a, b) => b[1] - a[1]) .map(([track, count]) => `
    ${escapeHtml(track)}${count}
    `) .join(""); return `

    Buyer Command

    今日选品采购指挥台

    先把直播间能跑量的款筛出来,再用 1688 货源做同款/近似款匹配,最后推进拿样和采购。

    客单价 ¥100 内 直播跑量优先
    ${workflow}

    今日买手动作

    跑量价格带

    ${miniStat("≤10", dashboard.priceBands.under10)} ${miniStat("10-30", dashboard.priceBands.from10To30)} ${miniStat("30-100", dashboard.priceBands.from30To100)} ${miniStat(">100", dashboard.priceBands.over100)}

    内部赛道分布

    ${tracks || "

    暂无赛道数据

    "}

    低价跑量款

    ${lowPriceItems || "

    暂无 10 元以内候选,继续从 1688 补货源。

    "}

    利润健康款

    ${profitItems || "

    暂无利润健康款,优先补进货价和直播价。

    "}

    高风险提醒

    ${riskItems || "

    当前暂无明显高风险话术。

    "}

    高分机会

    ${topItems || "

    暂无候选

    "}
    `; } function renderSampleQueue() { const cards = state.sampleItems.map(renderPipelineItem).join(""); return `

    拿样测试

    建议拿样、问供应商、已拿样和直播测试中的款都会集中在这里。这里是直播验证前的推进台。

    ${cards || "

    暂无拿样任务,可以先在候选库批量标记“问供应商”。

    "}
    `; } function renderStyleGroups() { const groups = state.groups .map( (group) => `

    ${escapeHtml(group.track)} · ${group.items.length} 条线索

    ${escapeHtml(group.title)}

    平台:${escapeHtml((group.platforms || []).join(" / ") || "待确认")} 最低货源:${money(group.minCost)} 竞品高价:${money(group.maxCompetitorPrice)} 建议直播价:${money(group.suggestedRetailPrice)} 机会分 ${escapeHtml(String(group.opportunityScore))}

    最优供应商:${escapeHtml(group.bestSupply?.supplier || group.bestSupply?.shop || group.bestSupply?.platform || "待确认")}

    ${group.items.slice(0, 4).map(renderCompactItem).join("")}
    `, ) .join(""); return `

    款式匹配

    系统按赛道和关键词自动合并同款/近似款,用来把淘宝、小红书竞品和 1688 货源放在一起比较。

    ${groups || "

    还没有足够相似款,继续采集竞品和 1688 货源。

    "}
    `; } function renderCaptureCenter() { const rules = state.rules || { captureHints: [] }; const recent = recentCaptureItems() .slice(0, 6) .map(renderCompactItem) .join(""); const hints = (rules.captureHints || []) .map((hint) => `
  • ${escapeHtml(hint)}
  • `) .join(""); return `

    Capture Hub

    采集中心

    浏览器插件负责抓取当前页面的图片、链接、价格、供应商和可见销量话术,系统负责清洗、打分和归类。

    图片 / 链接 / 价格

    采集顺序

    ${workflowStep("淘宝直播竞品", competitorItems().filter((item) => item.platform === "taobao" || item.platform === "tmall").length, 1)} ${workflowStep("小红书种草款", competitorItems().filter((item) => item.platform === "xiaohongshu").length, 2)} ${workflowStep("1688 同款货源", supplierItems().length, 3)}

    采集提示

    最近采集

    ${recent || "

    暂无采集线索。

    "}
    `; } function renderLibraryHeader({ eyebrow, title, body, count, actionLabel, action }) { const actionAttr = action === "clear-filters" ? "data-clear-filters" : action === "source-1688" ? "data-source-1688" : ""; return `

    ${escapeHtml(eyebrow)}

    ${escapeHtml(title)}

    ${escapeHtml(body)}

    ${escapeHtml(String(count))} 当前结果
    `; } function renderProcurementDecisions() { const candidates = procurementCandidates(); const rows = candidates.slice(0, 10).map(renderDecisionRow).join(""); const ready = candidates.filter((item) => item.profit?.recommendation === "可跑量").length; const sample = candidates.filter((item) => item.decision === "拿样").length; const risky = candidates.filter((item) => item.riskLevel === "high").length; return `

    Purchase Board

    采购决策

    把高分机会、利润模型、风险提醒和拿样状态放在一张决策表里,适合第一批 100 元以内跑量款快速定款。

    ${miniStat("可跑量", ready)} ${miniStat("建议拿样", sample)} ${miniStat("高风险", risky)}

    优先采购候选

    先看已匹配货源、利润可跑、风险较低的款;误选的款可以多选后批量清理。

    ${rows || "

    暂无可进入采购决策的款,继续采集竞品和 1688 货源。

    "}
    `; } function renderDecisionRow(item) { return ` `; } function renderPluginSettings() { const extensionInfo = state.extensionInfo || DEFAULT_EXTENSION_PACKAGE; const installUrl = extensionInfo.installUrl || DEFAULT_EXTENSION_PACKAGE.installUrl; const fileName = extensionInfo.fileName || DEFAULT_EXTENSION_PACKAGE.fileName; const version = extensionInfo.recommendedVersion || "0.0.0"; if (!state.user) { return `

    Capture Plugin

    采集插件

    插件会把浏览器页面里的图片、链接、价格和供应商信息提交到 SaaS 工作台。

    Plugin Access

    先下载浏览器插件,再登录配置 Token

    插件安装包已经随系统提供。创建第一个账号后,当前本地候选库会自动归到这个账号,插件也可以用 Token 往云端系统提交采集数据。

    下载插件安装包
    `; } const tokenList = state.pluginTokens.map(renderPluginTokenCard).join(""); return `

    Capture Plugin

    采集插件

    插件填入服务地址和 Token 后,就能把淘宝、小红书、1688 的图片、链接、价格提交到你的账号库。插件采集会消耗账号积分,多个 Token 共用同一个余额。

    余额 ${escapeHtml(String(state.user.credits || 0))} 积分

    Extension Package

    浏览器采集插件安装包

    下载后解压,在 Chrome 扩展管理里开启开发者模式,再加载解压后的文件夹。

    推荐版本 ${escapeHtml(version)} ${escapeHtml(fileName)}
    下载插件安装包
    1. 下载并解压安装包。
    2. 打开 chrome://extensions
    3. 开启开发者模式,选择“加载已解压的扩展程序”。
    4. 把服务地址和插件 Token 填入插件设置。

    已创建 Token

    Token 只用于插件提交采集数据,不开放读取你的选品库。新版本生成的 Token 会保存在列表里,可随时复制配置;历史旧 Token 没有明文时需要重新生成。

    ${tokenList || "

    还没有插件 Token。

    "}
    `; } function renderPluginTokenCard(token) { const disabled = token.status === "disabled"; const nextStatus = disabled ? "active" : "disabled"; const actionLabel = disabled ? "启用" : "停用"; const canCopyToken = Boolean(token.token); return `
    ${escapeHtml(token.name || "浏览器采集插件")} ${escapeHtml(pluginStatusLabel(token.status))} · 创建 ${escapeHtml(formatDateTime(token.createdAt))}
    采集次数${escapeHtml(String(token.usageCount || 0))}
    最近使用${escapeHtml(formatDateTime(token.lastUsedAt))}
    `; } function renderAdminCenter() { if (!isAdmin()) { return `

    Admin Center

    管理中心

    只有管理员可以查看用户、插件 Token 使用情况和账号状态。

    当前账号不是管理员

    请使用管理员账号登录后再进入管理中心。

    `; } const users = state.adminUsers || []; const activeUsers = users.filter((user) => user.status !== "disabled").length; const disabledUsers = users.filter((user) => user.status === "disabled").length; const pluginTokens = users.reduce((sum, user) => sum + Number(user.pluginTokenCount || 0), 0); const activePluginTokens = users.reduce((sum, user) => sum + Number(user.activePluginTokenCount || 0), 0); const totalCredits = users.reduce((sum, user) => sum + Number(user.credits || 0), 0); const totalAiTokens = users.reduce((sum, user) => sum + Number(user.aiTokens || 0), 0); const rows = users.map(renderAdminUserRow).join(""); const creditRows = (state.creditCodes || []).map(renderCreditCodeRow).join(""); const aiSettings = state.aiSettings || {}; return `

    Admin Center

    管理中心

    管理 SaaS 用户、账号状态和插件采集入口。管理员可以停用异常账号,查看每个买手沉淀了多少选品线索和采集 Token。

    ${adminKpi("注册用户", users.length, "已进入 SaaS 的买手账号")} ${adminKpi("活跃账号", activeUsers, "可登录并使用系统")} ${adminKpi("已停用", disabledUsers, "无法登录或继续采集")} ${adminKpi("插件 Token", `${activePluginTokens}/${pluginTokens}`, "启用 / 全部")} ${adminKpi("账户积分", totalCredits, "所有用户当前余额")} ${adminKpi("AI Tokens", totalAiTokens, "用户 AI 分析可用余额")}

    AI Engine

    大模型能力配置

    管理员配置模型厂商、OpenAI 兼容代理、API Key 和提示词;用户只看到可用 AI 功能和自己的 AI token 余额,不接触密钥。

    ${aiSettings.enabled ? "已启用" : "未启用"}${aiSettings.apiKeyMasked ? ` · ${escapeHtml(aiSettings.apiKeyMasked)}` : ""}
    ${aiInput("aiProviderInput", "模型厂商", aiSettings.provider || "openai-compatible", "deepseek / openai / qwen / proxy")} ${aiInput("aiBaseUrlInput", "Base URL", aiSettings.baseUrl || "", "https://api.deepseek.com/v1")} ${aiInput("aiModelInput", "模型名称", aiSettings.model || "", "deepseek-chat / gpt-4o-mini / qwen-plus")} ${aiInput("aiApiKeyInput", "API Key", "", aiSettings.apiKeyMasked ? `已配置:${aiSettings.apiKeyMasked},留空则不修改` : "sk-...", "password")} ${aiInput("aiTemperatureInput", "温度", aiSettings.temperature ?? 0.2, "0.2", "number", "0.1")} ${aiInput("aiMaxOutputInput", "最大输出 tokens", aiSettings.maxOutputTokens ?? 900, "900", "number", "1")} ${aiInput("aiTimeoutInput", "超时毫秒", aiSettings.timeoutMs ?? 20000, "20000", "number", "1000")} ${aiInput("aiTokenUnitCostInput", "计费倍率", aiSettings.tokenUnitCost ?? 1, "1", "number", "1")}
    ${aiTextarea("aiPromptItemInput", "商品分析提示词", aiSettings.prompts?.itemAnalysis || "")} ${aiTextarea("aiPromptProcurementInput", "采购报告提示词", aiSettings.prompts?.procurementReport || "")} ${aiTextarea("aiPromptRulesInput", "规则生成提示词", aiSettings.prompts?.ruleGeneration || "")}
    ${escapeHtml(state.aiAdminMessage || "支持 OpenAI 兼容 / 代理网关,Base URL 到 /v1 即可。")}

    兑换码充值

    管理员生成兑换码,用户在账号页兑换后增加采集积分或 AI tokens。多个插件 Token 共用采集积分,AI 分析共用 AI token 余额。

    ${creditRows || "

    还没有兑换码。

    "}

    用户管理

    ${state.adminLoading ? "正在同步用户列表..." : "停用账号后,该用户已有登录态和插件 Token 都会被后端拦截。"}

    ${state.adminError ? escapeHtml(state.adminError) : state.adminLoading ? "同步中" : `${users.length} 个账号`}
    ${rows || renderAdminEmptyState()}
    `; } function renderCreditCodeRow(code) { const redeemed = code.status === "redeemed"; const parts = []; if (Number(code.credits || 0) > 0) parts.push(`${Number(code.credits || 0)} 采集积分`); if (Number(code.aiTokens || 0) > 0) parts.push(`${Number(code.aiTokens || 0)} AI Tokens`); return `
    ${escapeHtml(code.code)} ${escapeHtml(code.note || "无备注")} · ${escapeHtml(formatDateTime(code.createdAt))}
    ${redeemed ? "已兑换" : "可兑换"} ${escapeHtml(parts.join(" / ") || "0 额度")}
    `; } function renderAdminUserRow(user) { const isSelf = user.id === state.user?.id; const disabled = user.status === "disabled"; const actionStatus = disabled ? "active" : "disabled"; const actionLabel = disabled ? "启用" : "停用"; return `
    ${escapeHtml(user.name || user.email || "未命名用户")} ${escapeHtml(user.email || "")}
    ${escapeHtml(roleLabel(user.role))} ${escapeHtml(userStatusLabel(user.status))}
    线索${escapeHtml(String(user.itemCount || 0))}
    Token${escapeHtml(String(user.activePluginTokenCount || 0))}/${escapeHtml(String(user.pluginTokenCount || 0))}
    积分${escapeHtml(String(user.credits || 0))}
    AI${escapeHtml(String(user.aiTokens || 0))}
    创建 ${escapeHtml(formatDateTime(user.createdAt))} 更新 ${escapeHtml(formatDateTime(user.updatedAt || user.createdAt))}
    `; } function aiInput(id, label, value, placeholder = "", type = "text", step = "1") { return ` `; } function aiTextarea(id, label, value) { return ` `; } function adminKpi(label, value, hint) { return `
    ${escapeHtml(label)} ${escapeHtml(String(value))} ${escapeHtml(hint)}
    `; } function renderAdminEmptyState() { if (state.adminLoading) { return `
    正在加载用户

    同步账号、插件 Token 和选品线索统计。

    `; } return `
    暂无用户数据

    创建账号后,用户会出现在这里。

    `; } function renderRuleSettings() { if (!state.user) { return `

    Rule Studio

    规则配置

    这里管理赛道关键词、风险词、采集提示、自动标签和第一批跑量利润模型。

    Rule Studio

    登录后编辑评分和采集规则

    规则会影响后续采集的赛道识别、风险提醒、利润模型和自动标签。

    `; } const rules = state.rules || { tracks: [], riskKeywords: [], profitModel: {}, captureHints: [], autoTags: [] }; const trackRows = (rules.tracks || []).map(renderTrackRuleRow).join(""); const profitModel = rules.profitModel || {}; return `

    Rule Studio

    选品评分规则工作室

    把系统判断“能不能跑量、该不该拿样、有没有风险”的规则集中在这里维护。先调赛道识别,再调利润模型,最后补风险词和采集提示。

    赛道 ${escapeHtml(String((rules.tracks || []).length))} 跑量价 ≤ ${money(profitModel.maxRunningPrice)}

    修改后会影响后续采集评分、风险提醒和拿样建议。

    ${ruleSummaryCard("赛道识别", `${(rules.tracks || []).length} 组`, "影响耳饰、项链、藏式、发饰等内部赛道归类。")} ${ruleSummaryCard("风险词库", `${(rules.riskKeywords || []).length} 个`, "影响高风险提醒和详情页话术避坑。", "danger")} ${ruleSummaryCard("采集提示", `${(rules.captureHints || []).length} 条`, "指导插件采集时优先保留哪些页面线索。")} ${ruleSummaryCard("自动标签", `${(rules.autoTags || []).length} 个`, "用于批量清洗和直播选品筛选。", "gold")}

    Track Matching

    赛道关键词

    用关键词把采集款自动归入藏式/民族风、耳饰、项链、发饰等赛道。关键词越贴近直播搜索词,后续筛选越准。

    ${trackRows}
    `; } function ruleSummaryCard(label, value, hint, tone = "") { return `
    ${escapeHtml(label)} ${escapeHtml(value)}

    ${escapeHtml(hint)}

    `; } function renderTrackRuleRow(track, index) { return `
    ${escapeHtml(String(index + 1).padStart(2, "0"))}
    `; } function profileQuickAction(label, body, action, tone = "") { return ` `; } function profileStat(label, value, hint = "", tone = "") { return `
    ${escapeHtml(label)} ${escapeHtml(String(value))} ${hint ? `${escapeHtml(hint)}` : ""}
    `; } function profileInfo(label, value) { return `
    ${escapeHtml(label)} ${escapeHtml(String(value || "待确认"))}
    `; } function renderProfile() { const userName = state.user?.name || state.user?.email?.split("@")[0] || "未登录买手"; const userEmail = state.user?.email || "未登录"; const role = state.user ? roleLabel(state.user.role) : "本地模式"; const status = state.user ? userStatusLabel(state.user.status) : "未启用"; const credits = Number(state.user?.credits || 0); const aiTokens = Number(state.user?.aiTokens || 0); const aiReady = Boolean(state.aiStatus?.ai?.enabled); const tokenCount = state.pluginTokens.length; const accountMode = state.user ? "SaaS" : "本地单人"; const accountInitial = (userName || "饰").slice(0, 1).toUpperCase(); return `

    Account Center

    ${escapeHtml(userName)}

    ${escapeHtml(userEmail)}

    ${escapeHtml(role)} ${escapeHtml(status)} ${escapeHtml(accountMode)}
    ${profileStat("账户积分", credits, "插件采集共用余额", "gold")} ${profileStat("AI Tokens", aiTokens, aiReady ? "AI 分析可用额度" : "AI 引擎待管理员启用", "jade")} ${profileStat("插件 Token", tokenCount, "可停用 / 可复制", "jade")} ${profileStat("账号角色", role, "权限和管理范围")} ${profileStat("账号状态", status, "当前使用状态")}

    Shortcuts

    常用入口

    按真实工作顺序进入管理、插件、规则和采集。

    ${isAdmin() ? profileQuickAction("管理中心", "用户 / 积分 / 兑换码", "admin", "primary") : ""} ${profileQuickAction("采集插件", "服务地址 / Token / 更新", "plugin")} ${profileQuickAction("规则配置", "赛道 / 利润 / 风险词", "rules", "gold")} ${profileQuickAction("采集中心", "继续整理候选款", "capture")}
    ${ state.user ? `

    Recharge

    兑换采集积分 / AI Tokens

    输入管理员发放的兑换码,为当前账号充值采集积分或 AI tokens。插件每保存 1 条线索消耗 1 积分,AI 分析按模型返回 tokens 计费。

    ` : "" }
    `; } function renderBulkToolbar() { const selectedCount = state.selectedIds.size; const hasSelection = selectedCount > 0; els.bulkToolbar.hidden = !hasSelection || !SELECTABLE_VIEWS.has(state.view); els.bulkToolbar.classList.toggle("confirming", state.bulkDeletePending); els.bulkStatusText.textContent = state.bulkDeletePending ? "确认删除" : "已选"; els.selectedCount.textContent = selectedCount; els.selectVisible.hidden = state.bulkDeletePending; els.clearSelection.hidden = state.bulkDeletePending; els.deleteSelected.hidden = state.bulkDeletePending; els.confirmDelete.hidden = !state.bulkDeletePending; els.cancelDelete.hidden = !state.bulkDeletePending; els.mobileStatusMenu.hidden = state.bulkDeletePending; els.deleteSelected.disabled = !hasSelection; els.clearSelection.disabled = !hasSelection; els.tagSelected.disabled = !hasSelection; els.mobileStatusMenu.disabled = !hasSelection; els.confirmDelete.disabled = !hasSelection; document.querySelectorAll("[data-bulk-status]").forEach((button) => { button.disabled = !hasSelection; button.hidden = state.bulkDeletePending; }); els.tagSelected.hidden = state.bulkDeletePending; els.selectVisible.disabled = visibleItems().length === 0 || selectedCount === visibleItems().length; } function renderCard(item) { const decisionClass = item.decision === "拿样" ? "sample" : item.decision === "继续观察" ? "watch" : "reject"; const selected = state.selectedIds.has(item.id); const riskClass = item.riskLevel === "high" ? "danger" : item.riskLevel === "medium" ? "warning" : "success"; const riskText = item.riskLevel === "high" ? "高风险" : item.riskLevel === "medium" ? "有风险" : "低风险"; const profitClass = item.profit?.recommendation === "可跑量" ? "success" : item.profit?.recommendation === "可测试" ? "warning" : "danger"; const image = item.image ? `${escapeAttr(item.title || ` : `
    待补商品图
    `; const tags = [...(item.tags || []), ...(item.autoTags || [])] .slice(0, 4) .map((tag) => `${escapeHtml(tag)}`) .join(""); const supplier = item.supplier || item.shop || (item.platform === "1688" ? "待确认厂家" : "竞品店铺"); const priceLabel = item.platform === "1688" ? "拿样价" : "竞品价"; return `
    ${image}
    ${escapeHtml(item.platform)} ${escapeHtml(item.decision)} · ${escapeHtml(String(item.score || 0))}

    ${escapeHtml(item.title || "未命名候选款")}

    ${escapeHtml(item.profit?.recommendation || "待测算")} ${riskText} ${item.platform === "1688" ? '1688 货源' : ""}
    ${escapeHtml(supplier)} ${escapeHtml(priceLabel)} ${money(item.price)}
    ${tags}
    赛道${escapeHtml(item.track || "未分类")}
    建议直播价${money(item.profit?.targetPrice)}
    毛利${money(item.profit?.grossProfit)}
    状态${escapeHtml(statusLabel(item.status))}
    打开原页
    `; } function renderCompactItem(item) { return ` `; } function renderPipelineItem(item) { return `

    ${escapeHtml(item.title || "未命名候选款")}

    ${escapeHtml(item.platform)} · ${escapeHtml(item.track)} · ${money(item.price)} → ${money(item.profit?.targetPrice)}

    `; } function bindCardActions() { els.grid.querySelectorAll("[data-action='select-item']").forEach((checkbox) => { checkbox.addEventListener("change", () => setItemSelected(checkbox.dataset.id, checkbox.checked)); }); els.grid.querySelectorAll("[data-action='detail']").forEach((button) => { button.addEventListener("click", () => { const card = button.closest(".item-card"); const item = findItem(card.dataset.id); if (item) showDetail(item); }); }); } function bindWorkbenchActions() { els.workbench.querySelectorAll("[data-switch-view]").forEach((button) => { button.addEventListener("click", () => setView(button.dataset.switchView)); }); els.workbench.querySelectorAll("[data-clear-filters]").forEach((button) => { button.addEventListener("click", clearFilters); }); els.workbench.querySelectorAll("[data-source-1688]").forEach((button) => { button.addEventListener("click", () => { state.filters.source = "only1688"; els.source.value = "only1688"; clearSelection(); loadItems(); }); }); els.workbench.querySelectorAll("[data-open-id]").forEach((button) => { button.addEventListener("click", () => { const item = findItem(button.dataset.openId); if (item) showDetail(item); }); }); els.workbench.querySelectorAll("[data-quick-status]").forEach((button) => { button.addEventListener("click", async () => { const [id, status] = button.dataset.quickStatus.split(":"); await updateItem(id, { status }); await loadItems(); }); }); } function bindSettingsActions() { els.workbench.querySelector("[data-open-auth-gate]")?.addEventListener("click", () => { openAuthPage(); }); els.workbench.querySelector("#addTrackRuleBtn")?.addEventListener("click", () => { state.rules.tracks.push({ name: "新赛道", keywords: ["关键词"] }); render(); }); els.workbench.querySelectorAll("[data-remove-track]").forEach((button) => { button.addEventListener("click", () => { state.rules.tracks.splice(Number(button.dataset.removeTrack), 1); render(); }); }); els.workbench.querySelector("#saveRulesBtn")?.addEventListener("click", saveRulesFromEditor); els.workbench.querySelector("#createPluginTokenBtn")?.addEventListener("click", createTokenFromEditor); els.workbench.querySelector("#copyPluginConfigBtn")?.addEventListener("click", copyPluginConfig); els.workbench.querySelectorAll("[data-copy-token]").forEach((button) => { button.addEventListener("click", () => { copyPluginToken(button.dataset.copyToken).catch((error) => alert(error.message || "复制 Token 失败")); }); }); els.workbench.querySelectorAll("[data-copy-token-config]").forEach((button) => { button.addEventListener("click", () => { copyPluginToken(button.dataset.copyTokenConfig, true).catch((error) => alert(error.message || "复制配置失败")); }); }); els.workbench.querySelectorAll("[data-plugin-status]").forEach((button) => { button.addEventListener("click", () => { const [id, status] = button.dataset.pluginStatus.split(":"); updatePluginTokenStatus(id, status).catch((error) => alert(error.message || "更新 Token 失败")); }); }); els.workbench.querySelectorAll("[data-plugin-delete]").forEach((button) => { button.addEventListener("click", () => { deletePluginToken(button.dataset.pluginDelete).catch((error) => alert(error.message || "删除 Token 失败")); }); }); } function bindAdminActions() { bindWorkbenchActions(); els.workbench.querySelector("[data-refresh-admin]")?.addEventListener("click", () => { loadAdminUsers().catch((error) => alert(error.message || "刷新用户失败")); }); els.workbench.querySelectorAll("[data-user-status]").forEach((button) => { button.addEventListener("click", () => { const [id, status] = button.dataset.userStatus.split(":"); updateAdminUserStatus(id, status).catch((error) => alert(error.message || "更新用户状态失败")); }); }); els.workbench.querySelector("#createCreditCodeBtn")?.addEventListener("click", () => { createCreditCodeFromAdmin().catch((error) => alert(error.message || "生成兑换码失败")); }); els.workbench.querySelector("#saveAiSettingsBtn")?.addEventListener("click", () => { saveAiSettingsFromAdmin().catch((error) => { state.aiAdminMessage = error.message || "保存 AI 配置失败"; render(); }); }); els.workbench.querySelector("#testAiSettingsBtn")?.addEventListener("click", () => { testAiSettingsFromAdmin().catch((error) => { state.aiAdminMessage = error.message || "AI 连接测试失败"; render(); }); }); els.workbench.querySelectorAll("[data-copy-credit-code]").forEach((button) => { button.addEventListener("click", () => { copyText(button.dataset.copyCreditCode).catch((error) => alert(error.message || "复制兑换码失败")); }); }); } function bindProfileActions() { bindWorkbenchActions(); els.workbench.querySelectorAll("[data-export-csv]").forEach((button) => { button.addEventListener("click", exportCsv); }); els.workbench.querySelector("#redeemCreditBtn")?.addEventListener("click", () => { redeemCreditCodeFromProfile().catch((error) => alert(error.message || "兑换失败")); }); } function showDetail(item) { const reasons = (item.reasons || []).map((reason) => `
  • ${escapeHtml(reason)}
  • `).join(""); const risks = (item.risks || []).map((risk) => `
  • ${escapeHtml(risk)}
  • `).join(""); const aiResult = state.aiAnalysisByItem[item.id] || null; const pitch = item.livePitch || {}; const talkingPoints = (pitch.talkingPoints || []).map((point) => `
  • ${escapeHtml(point)}
  • `).join(""); const scenes = (pitch.scenes || []).map((scene) => `${escapeHtml(scene)}`).join(""); const questions = (pitch.supplierQuestions || []).map((question) => `
  • ${escapeHtml(question)}
  • `).join(""); const image = item.image ? `${escapeAttr(item.title || ` : `
    无图片
    `; const riskClass = item.riskLevel === "high" ? "danger" : item.riskLevel === "medium" ? "warning" : "success"; const profitClass = item.profit?.recommendation === "可跑量" ? "success" : item.profit?.recommendation === "可测试" ? "warning" : "danger"; els.dialogContent.innerHTML = `
    ${image}
    ${escapeHtml(item.profit?.recommendation || "待测算")} ${escapeHtml(item.riskLevel === "high" ? "高风险" : item.riskLevel === "medium" ? "有风险" : "低风险")} ${escapeHtml(statusLabel(item.status))}

    ${escapeHtml(item.platform)} · ${escapeHtml(item.track)}

    ${escapeHtml(item.title || "未命名候选款")}

    综合评分${escapeHtml(String(item.score || 0))}
    建议动作${escapeHtml(item.decision || "待判断")}
    进货价${money(item.price)}
    建议直播价${money(item.profit?.targetPrice)}
    预计毛利${money(item.profit?.grossProfit)}
    毛利率${percent(item.profit?.grossMarginRate)}
    利润模型 ${escapeHtml(item.profit?.recommendation || "待测算")}
    总成本${money(item.profit?.totalCost)}
    快递${money(item.profit?.shippingFee)}
    包装${money(item.profit?.packagingFee)}
    让利${money(item.profit?.promoFee)}
    AI 采购参谋 ${escapeHtml(aiStatusLabel())}
    ${renderAiAnalysisBlock(aiResult)}
    ${escapeHtml(aiUsageHint())}
    直播卖点 ${escapeHtml(pitch.audience || "直播间用户")}
      ${talkingPoints || "
    • 暂无
    • "}
    ${scenes}

    ${escapeHtml(pitch.priceScript || "")}

    问供应商 ${escapeHtml(item.supplier || item.shop || "待确认")}
      ${questions || "
    • 暂无
    • "}
    评分理由 ${escapeHtml(String(item.score || 0))} 分
      ${reasons || "
    • 暂无
    • "}
    风险提醒 ${escapeHtml(item.riskLevel || "low")}
      ${risks || "
    • 暂无
    • "}
    `; if (!els.dialog.open) els.dialog.showModal(); document.querySelector("#saveDetailBtn").addEventListener("click", async () => { await updateItem(item.id, { status: document.querySelector("#dialogStatus").value, tags: splitTags(document.querySelector("#dialogTags").value), notes: document.querySelector("#dialogNotes").value, }); els.dialog.close(); await loadItems(); }); document.querySelector("#deleteDetailBtn").addEventListener("click", async () => { if (!confirm("确认删除这条候选款?")) return; await fetchJson(`/api/items/${encodeURIComponent(item.id)}`, { method: "DELETE" }); state.selectedIds.delete(item.id); els.dialog.close(); await loadItems(); }); document.querySelector("#runAiAnalysisBtn")?.addEventListener("click", async () => { await runAiAnalysisForItem(item.id); const freshItem = findItem(item.id) || item; showDetail(freshItem); }); } function canRunAiAnalysis() { return Boolean(state.aiStatus?.ai?.enabled && Number(state.user?.aiTokens || 0) > 0); } function aiStatusLabel() { if (!state.aiStatus?.ai?.enabled) return "管理员未启用"; if (Number(state.user?.aiTokens || 0) <= 0) return "AI Tokens 不足"; return `${state.aiStatus.ai.model || "AI"} · 余额 ${Number(state.user?.aiTokens || 0)}`; } function aiUsageHint() { if (!state.aiStatus?.ai?.enabled) return "请管理员先在管理中心配置模型。"; if (Number(state.user?.aiTokens || 0) <= 0) return "请先在账号页兑换 AI token 充值码。"; return `按实际模型用量扣费,当前倍率 ${state.aiStatus.ai.tokenUnitCost || 1}。`; } function renderAiAnalysisBlock(result) { if (!result) { return `
    让 AI 帮你复核这条商品

    会基于标题、图片链接、价格、平台、供应商、评分规则和利润模型,输出采购建议、直播卖点、风险和供应商问题。

    `; } const analysis = result.analysis || {}; return `

    ${escapeHtml(analysis.summary || "AI 已返回分析结果。")}

    ${analysis.purchaseAdvice ? `
    采购建议${escapeHtml(analysis.purchaseAdvice)}
    ` : ""} ${renderAiList("直播卖点", analysis.sellingPoints)} ${renderAiList("风险提醒", analysis.risks, "danger")} ${renderAiList("问供应商", analysis.supplierQuestions)} 本次扣费 ${escapeHtml(String(result.billing?.deductedAiTokens || 0))} AI Tokens,剩余 ${escapeHtml(String(result.billing?.remainingAiTokens ?? state.user?.aiTokens ?? 0))}。
    `; } function renderAiList(label, values, tone = "") { const list = Array.isArray(values) ? values.filter(Boolean) : values ? [values] : []; if (list.length === 0) return ""; return `
    ${escapeHtml(label)}
    `; } async function runAiAnalysisForItem(itemId) { const button = document.querySelector("#runAiAnalysisBtn"); if (button) { button.disabled = true; button.textContent = "AI 分析中..."; } try { const payload = await fetchJson("/api/ai/analyze", { method: "POST", body: { type: "item_analysis", itemId }, }); state.aiAnalysisByItem[itemId] = payload; state.user = { ...state.user, aiTokens: payload.billing?.remainingAiTokens ?? state.user?.aiTokens ?? 0, }; await loadConfig(); } catch (error) { alert(error.status === 402 ? "AI Tokens 不足,请先兑换充值码。" : error.message || "AI 分析失败"); } } function setItemSelected(id, selected) { if (!id) return; if (selected) state.selectedIds.add(id); else state.selectedIds.delete(id); state.bulkDeletePending = false; render(); } function selectVisibleItems() { visibleItems().forEach((item) => state.selectedIds.add(item.id)); state.bulkDeletePending = false; render(); } function clearSelection() { state.selectedIds.clear(); state.bulkDeletePending = false; render(); } function requestBulkDelete() { if (state.selectedIds.size === 0) return; state.bulkDeletePending = true; render(); } async function confirmBulkDelete() { const ids = Array.from(state.selectedIds); if (ids.length === 0) return; await fetchJson("/api/items/bulk-delete", { method: "POST", body: { ids }, }); ids.forEach((id) => state.selectedIds.delete(id)); state.bulkDeletePending = false; await loadItems(); } async function bulkUpdateSelected(patch = {}, addTags = []) { const ids = Array.from(state.selectedIds); if (ids.length === 0) return; await fetchJson("/api/items/bulk-update", { method: "POST", body: { ids, patch, addTags }, }); await loadItems(); } function cancelBulkDelete() { state.bulkDeletePending = false; render(); } function pruneSelection() { const ids = new Set([...state.items, ...state.sampleItems].map((item) => item.id)); for (const id of state.selectedIds) { if (!ids.has(id)) state.selectedIds.delete(id); } } async function updateItem(id, patch) { return fetchJson(`/api/items/${encodeURIComponent(id)}`, { method: "PATCH", body: patch, }); } async function saveRulesFromEditor() { const tracks = Array.from(els.workbench.querySelectorAll("[data-track-row]")) .map((row) => ({ name: row.querySelector("[data-track-name]").value.trim(), keywords: splitTags(row.querySelector("[data-track-keywords]").value), })) .filter((track) => track.name && track.keywords.length > 0); const rules = { tracks, riskKeywords: linesFrom("#riskKeywordsInput"), captureHints: linesFrom("#captureHintsInput"), autoTags: linesFrom("#autoTagsInput"), profitModel: { shippingFee: numberFrom("#profit-shippingFee"), packagingFee: numberFrom("#profit-packagingFee"), platformFeeRate: numberFrom("#profit-platformFeeRate"), promoFee: numberFrom("#profit-promoFee"), maxRunningPrice: numberFrom("#profit-maxRunningPrice"), }, }; const payload = await fetchJson("/api/rules", { method: "PUT", body: { rules } }); state.rules = payload.rules; await loadItems(); } async function createTokenFromEditor() { const payload = await fetchJson("/api/plugin-tokens", { method: "POST", body: { name: "Chrome 云采集插件" }, }); state.lastPluginToken = payload.token; state.lastPluginTokenId = payload.pluginToken?.id || ""; await loadConfig(); render(); } async function updatePluginTokenStatus(id, status) { await fetchJson(`/api/plugin-tokens/${encodeURIComponent(id)}`, { method: "PATCH", body: { status }, }); await loadConfig(); render(); } async function deletePluginToken(id) { if (!confirm("确认删除这个插件 Token?删除后浏览器插件需要重新生成 Token 才能采集。")) return; await fetchJson(`/api/plugin-tokens/${encodeURIComponent(id)}`, { method: "DELETE" }); await loadConfig(); render(); } async function updateAdminUserStatus(id, status) { await fetchJson(`/api/admin/users/${encodeURIComponent(id)}/status`, { method: "PATCH", body: { status }, }); await loadAdminUsers(); } async function createCreditCodeFromAdmin() { const credits = Number(els.workbench.querySelector("#creditAmountInput")?.value || 0); const aiTokens = Number(els.workbench.querySelector("#aiTokenAmountInput")?.value || 0); const note = els.workbench.querySelector("#creditNoteInput")?.value || ""; const payload = await fetchJson("/api/admin/credit-codes", { method: "POST", body: { credits, aiTokens, note }, }); await copyText(payload.code); await loadAdminUsers(); } async function saveAiSettingsFromAdmin() { const body = { enabled: Boolean(els.workbench.querySelector("#aiEnabledInput")?.checked), provider: els.workbench.querySelector("#aiProviderInput")?.value || "", baseUrl: els.workbench.querySelector("#aiBaseUrlInput")?.value || "", model: els.workbench.querySelector("#aiModelInput")?.value || "", apiKey: els.workbench.querySelector("#aiApiKeyInput")?.value || "", temperature: numberFrom("#aiTemperatureInput"), maxOutputTokens: numberFrom("#aiMaxOutputInput"), timeoutMs: numberFrom("#aiTimeoutInput"), tokenUnitCost: numberFrom("#aiTokenUnitCostInput"), prompts: { itemAnalysis: els.workbench.querySelector("#aiPromptItemInput")?.value || "", procurementReport: els.workbench.querySelector("#aiPromptProcurementInput")?.value || "", ruleGeneration: els.workbench.querySelector("#aiPromptRulesInput")?.value || "", }, }; const payload = await fetchJson("/api/admin/ai-settings", { method: "PUT", body }); state.aiSettings = payload.aiSettings; state.aiAdminMessage = "AI 配置已保存"; await loadConfig(); render(); } async function testAiSettingsFromAdmin() { state.aiAdminMessage = "正在测试 AI 连接..."; render(); const payload = await fetchJson("/api/admin/ai-settings/test", { method: "POST", body: {} }); state.aiAdminMessage = `连接正常 · ${payload.ai?.model || "模型"} · ${payload.usage?.totalTokens || 0} tokens`; render(); } async function redeemCreditCodeFromProfile() { const input = els.workbench.querySelector("#creditCodeInput"); const code = input?.value || ""; const payload = await fetchJson("/api/credits/redeem", { method: "POST", body: { code }, }); state.user = payload.user; if (input) input.value = ""; await loadConfig(); render(); } async function copyPluginConfig() { const text = `API_BASE=${location.origin}\nPLUGIN_TOKEN=${state.lastPluginToken || ""}`; await copyText(text); const tokenInput = document.querySelector("#latestPluginToken"); if (tokenInput) tokenInput.value = state.lastPluginToken || "已复制服务地址,生成 Token 后再复制完整配置"; } async function copyPluginToken(id, includeApiBase = false) { const token = state.pluginTokens.find((candidate) => candidate.id === id)?.token; if (!token) { alert("这个旧 Token 没有保存明文。请重新生成一个 Token 后复制,并删除不用的旧 Token。"); return; } const text = includeApiBase ? `API_BASE=${location.origin}\nPLUGIN_TOKEN=${token}` : token; await copyText(text); const tokenInput = document.querySelector("#latestPluginToken"); if (tokenInput) tokenInput.value = token; } async function copyText(text) { const value = String(text || ""); if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; } const textarea = document.createElement("textarea"); textarea.value = value; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.append(textarea); textarea.select(); document.execCommand("copy"); textarea.remove(); } async function exportCsv() { const response = await apiFetch("/api/export.csv"); if (!response.ok) throw new Error("导出失败"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "product-sourcing-captures.csv"; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function recentCaptureItems() { return [...state.items].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); } function competitorItems() { return state.items.filter((item) => item.platform !== "1688"); } function supplierItems() { return state.items.filter((item) => item.platform === "1688"); } function procurementCandidates() { const sampleIds = new Set(state.sampleItems.map((item) => item.id)); return state.items .filter( (item) => sampleIds.has(item.id) || item.decision === "拿样" || item.profit?.recommendation === "可跑量" || item.score >= 70, ) .sort( (a, b) => Number(b.profit?.recommendation === "可跑量") - Number(a.profit?.recommendation === "可跑量") || Number(b.decision === "拿样") - Number(a.decision === "拿样") || (b.score || 0) - (a.score || 0), ); } function visibleItems() { if (state.view === "capture") return recentCaptureItems().slice(0, 24); if (state.view === "competitors") return competitorItems(); if (state.view === "suppliers") return supplierItems(); if (state.view === "testing") return state.sampleItems; if (state.view === "decisions") return procurementCandidates(); return []; } function getEmptyStateCopy() { return { capture: { title: "还没有采集线索", body: "先配置浏览器插件,然后在淘宝、小红书或 1688 页面点击采集当前页。", primaryLabel: "配置采集插件", primaryView: "plugin", secondaryLabel: "清空筛选", }, competitors: { title: "竞品库还没有结果", body: "去淘宝或小红书搜索饰品关键词,采集直播热款、搜索结果和评论/销量可见的页面。", primaryLabel: "去采集中心", primaryView: "capture", secondaryLabel: "清空筛选", }, suppliers: { title: "货源库还没有 1688 数据", body: "去 1688 搜同款或近似款,采集主图、链接、拿样价、厂家和保障信息。", primaryLabel: "配置插件", primaryView: "plugin", secondaryLabel: "清空筛选", }, testing: { title: "还没有拿样测试款", body: "在竞品库或采购决策里勾选款式,批量标记问供应商或已拿样。", primaryLabel: "看采购决策", primaryView: "decisions", secondaryLabel: "清空筛选", }, decisions: { title: "暂无采购决策候选", body: "继续补充竞品和 1688 货源,系统会把高分、可跑量、建议拿样的款集中到这里。", primaryLabel: "去竞品库", primaryView: "competitors", secondaryLabel: "清空筛选", }, }[state.view]; } function filterValueLabel(key, value) { if (key === "platform") { return { xiaohongshu: "小红书", taobao: "淘宝", tmall: "天猫", 1688: "1688", other: "其他" }[value] || value; } if (key === "status") return statusLabel(value); if (key === "priceBand") { return { under10: "10 元以内", "10to30": "10-30 元", "30to100": "30-100 元", over100: "100 元以上", unknown: "待确认", }[value] || value; } if (key === "source") return { only1688: "只看 1688", non1688: "竞品平台" }[value] || value; if (key === "risk") return { any: "有风险提醒", high: "高风险", low: "低风险" }[value] || value; return value; } function findItem(id) { return [...state.items, ...state.sampleItems, ...state.groups.flatMap((group) => group.items || [])].find((item) => item.id === id); } function setView(view, button) { if (view === "admin" && !isAdmin()) view = "account"; state.view = view; syncActiveNav(view, button); clearSelection(); if (view === "admin") { loadAdminUsers(); return; } render(); } function setDecisionFilter(value, button) { state.view = "competitors"; state.filters.decision = value; els.decision.value = value; syncActiveNav("competitors", button); clearSelection(); loadItems(); } function syncActiveNav(view, activeButton) { document.querySelectorAll(".nav-button").forEach((item) => item.classList.remove("active")); document.querySelectorAll(".mobile-tab").forEach((item) => item.classList.remove("active")); if (activeButton?.classList.contains("nav-button")) activeButton.classList.add("active"); const mobileTarget = document.querySelector(`[data-mobile-view="${view}"]`); if (mobileTarget) mobileTarget.classList.add("active"); const desktopTarget = document.querySelector(`.nav-button[data-view="${view}"]`); if (!activeButton?.classList.contains("nav-button") && desktopTarget) desktopTarget.classList.add("active"); } function openFilterDialog() { els.filterSheetBody.innerHTML = ""; els.filterSheetBody.append( ...filterControlLabels(), ); els.filterDialog.showModal(); } function restoreDesktopFilters() { if (window.matchMedia("(min-width: 901px)").matches && els.filterSheetBody.contains(els.search)) { els.filterToolbar.append(...filterControlLabels()); } } function filterControlLabels() { return [ els.search.closest("label"), els.platform.closest("label"), els.track.closest("label"), els.decision.closest("label"), els.status.closest("label"), els.tag.closest("label"), els.priceBand.closest("label"), els.source.closest("label"), els.risk.closest("label"), ].filter(Boolean); } function clearFilters() { state.filters.q = ""; state.filters.platform = ""; state.filters.track = ""; state.filters.decision = ""; state.filters.status = ""; state.filters.tag = ""; state.filters.priceBand = ""; state.filters.source = ""; state.filters.risk = ""; els.search.value = ""; els.mobileSearch.value = ""; els.platform.value = ""; els.track.value = ""; els.decision.value = ""; els.status.value = ""; els.tag.value = ""; els.priceBand.value = ""; els.source.value = ""; els.risk.value = ""; loadItems(); } function setKeywordFilter(value) { const query = value.trim(); state.filters.q = query; state.bulkDeletePending = false; if (els.search.value !== query) els.search.value = query; if (els.mobileSearch.value !== query) els.mobileSearch.value = query; clearTimeout(setKeywordFilter._timer); setKeywordFilter._timer = setTimeout(loadItems, 180); } function setAppLocked(locked) { state.locked = locked; state.landingOpen = locked; renderAuthShell(); els.statsGrid.hidden = locked; els.filterToolbar.hidden = locked; els.workbench.hidden = locked; els.grid.hidden = locked; els.empty.hidden = true; els.bulkToolbar.hidden = true; els.logout.hidden = !state.user; renderChrome(); markAuthReady(); } function renderAuthShell() { const showLanding = state.locked || state.landingOpen; els.appShell.classList.toggle("auth-locked", showLanding); els.authGate.hidden = !showLanding; els.landingBack.hidden = !state.user; document.body.classList.toggle("landing-open", showLanding); } function markAuthReady() { document.body.classList.remove("auth-pending"); } function openAuthPage() { if (state.user) { setView("account"); return; } state.landingOpen = true; renderAuthShell(); els.authGate.scrollIntoView({ behavior: "smooth", block: "start" }); } function openAuthDialog(mode = "login") { setAuthMode(mode); setAuthMessage(""); els.authDialog.dataset.mode = mode === "register" ? "register" : "login"; els.authDialog.showModal(); } function closeAuthDialog() { els.authDialog.close(); } function setAuthMode(mode = "login") { const isRegister = mode === "register"; const normalizedMode = isRegister ? "register" : "login"; els.authDialog.dataset.mode = normalizedMode; els.registerForm.hidden = !isRegister; els.loginForm.hidden = isRegister; els.authDialogTitle.textContent = isRegister ? "创建账号" : "登录工作台"; els.authDialogText.textContent = isRegister ? "创建账号后会接管当前本地采集数据,并隔离插件 Token 和规则配置。" : "登录后进入你的直播饰品选品采购工作台。"; els.authSwitchText.textContent = isRegister ? "已经有账号?" : "还没有账号?"; els.authSwitchButton.textContent = isRegister ? "登录工作台" : "创建账号"; els.authSwitchButton.dataset.authSwitch = isRegister ? "login" : "register"; } function closeAuthPage() { if (state.locked) return; state.landingOpen = false; renderAuthShell(); } async function handleAuthSubmit(event, mode) { event.preventDefault(); const form = event.currentTarget; const formData = new FormData(form); setAuthMessage("正在处理..."); try { const payload = await fetchJson(`/api/auth/${mode}`, { skipAuth: true, method: "POST", body: Object.fromEntries(formData.entries()), }); setSession(payload.token, payload.user); state.authRequired = true; state.landingOpen = false; closeAuthDialog(); renderAuthShell(); setAppLocked(false); await loadConfig(); await loadItems(); form.reset(); setAuthMessage(""); } catch (error) { setAuthMessage(error.message || "登录失败"); } } function setSession(token, user) { state.sessionToken = token; state.user = user; localStorage.setItem(TOKEN_KEY, token); } function clearSession() { state.sessionToken = ""; state.user = null; state.rules = null; state.pluginTokens = []; state.adminUsers = []; state.creditCodes = []; state.aiSettings = null; state.aiStatus = null; state.aiAnalysisByItem = {}; state.adminLoading = false; state.adminError = ""; state.aiAdminMessage = ""; state.lastPluginToken = ""; state.lastPluginTokenId = ""; localStorage.removeItem(TOKEN_KEY); } function logout() { clearSession(); setAppLocked(true); } function setAuthMessage(message) { els.authMessage.textContent = message; } async function apiFetch(url, options = {}) { const headers = { ...(options.headers || {}) }; if (options.body !== undefined && !(options.body instanceof FormData)) headers["Content-Type"] = "application/json"; if (!options.skipAuth && state.sessionToken) headers.Authorization = `Bearer ${state.sessionToken}`; const response = await fetch(url, { ...options, headers, body: options.body === undefined || options.body instanceof FormData ? options.body : JSON.stringify(options.body), }); if (response.status === 401 && !options.skipAuth) { clearSession(); setAppLocked(true); } return response; } async function fetchJson(url, options = {}) { const response = await apiFetch(url, options); const contentType = response.headers.get("content-type") || ""; const payload = contentType.includes("application/json") ? await response.json() : await response.text(); if (!response.ok) { const error = new Error(payload?.error || `请求失败:${url}`); error.status = response.status; throw error; } return payload; } function statusOptions(value) { const options = [ ["new", "新采集"], ["reviewing", "待复核"], ["asking_supplier", "问供应商"], ["ordered_sample", "已拿样"], ["live_testing", "直播测试"], ["rejected", "已放弃"], ]; return options.map(([key, label]) => ``).join(""); } function statusLabel(value) { return { new: "新采集", reviewing: "待复核", asking_supplier: "问供应商", ordered_sample: "已拿样", live_testing: "直播测试", rejected: "已放弃", }[value] || value || "新采集"; } function miniStat(label, value) { return `
    ${escapeHtml(label)}${escapeHtml(String(value || 0))}
    `; } function workflowStep(label, value, index) { return `
    ${escapeHtml(String(index).padStart(2, "0"))} ${escapeHtml(label)} ${escapeHtml(String(value || 0))}
    `; } function numberInput(key, label, value, step = "1") { return ` `; } function money(value) { if (value == null || Number.isNaN(Number(value))) return "待确认"; return `¥${Number(value).toFixed(2)}`; } function percent(value) { if (value == null || Number.isNaN(Number(value))) return "待确认"; return `${Math.round(Number(value) * 100)}%`; } function splitTags(value = "") { return String(value) .split(/[,,\n]+/) .map((tag) => tag.trim()) .filter(Boolean); } function linesFrom(selector) { return splitTags(els.workbench.querySelector(selector)?.value || ""); } function numberFrom(selector) { const value = Number(els.workbench.querySelector(selector)?.value); return Number.isFinite(value) ? value : null; } function formatDate(value) { if (!value) return "刚刚"; return new Date(value).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }); } function formatDateTime(value) { if (!value) return "从未"; return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } function isAdmin() { return state.user?.role === "admin"; } function roleLabel(value) { return value === "admin" ? "管理员" : "买手"; } function userStatusLabel(value) { return value === "disabled" ? "已停用" : "活跃"; } function pluginStatusLabel(value) { return value === "disabled" ? "已停用" : "启用中"; } function escapeHtml(value = "") { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeAttr(value = "") { return escapeHtml(value).replaceAll("`", "`"); } document.querySelectorAll(".nav-button").forEach((button) => { button.addEventListener("click", () => { if (button.dataset.view) setView(button.dataset.view, button); }); }); document.querySelectorAll(".mobile-tab").forEach((button) => { button.addEventListener("click", () => { const view = button.dataset.mobileView; if (view) setView(view, button); }); }); document.querySelectorAll("[data-export-csv]").forEach((button) => { button.addEventListener("click", () => exportCsv().catch((error) => alert(error.message || "导出失败"))); }); els.registerForm.addEventListener("submit", (event) => handleAuthSubmit(event, "register")); els.loginForm.addEventListener("submit", (event) => handleAuthSubmit(event, "login")); document.querySelectorAll("[data-auth-mode]").forEach((button) => { button.addEventListener("click", () => openAuthDialog(button.dataset.authMode || "login")); }); els.authSwitchButton.addEventListener("click", () => setAuthMode(els.authSwitchButton.dataset.authSwitch || "login")); document.querySelector("[data-auth-close]").addEventListener("click", closeAuthDialog); els.openAuthPage.addEventListener("click", openAuthPage); els.mobileAuth.addEventListener("click", openAuthPage); els.landingBack.addEventListener("click", closeAuthPage); els.logout.addEventListener("click", logout); els.refresh.addEventListener("click", loadItems); els.mobileRefresh.addEventListener("click", loadItems); els.mobileFilter.addEventListener("click", openFilterDialog); els.clearFilters.addEventListener("click", clearFilters); els.emptyPrimary.addEventListener("click", () => { const targetView = els.emptyPrimary.dataset.emptyPrimaryView; if (targetView) setView(targetView); }); els.emptySecondary.addEventListener("click", clearFilters); els.filterDialog.addEventListener("close", restoreDesktopFilters); window.addEventListener("resize", restoreDesktopFilters); els.selectVisible.addEventListener("click", selectVisibleItems); els.clearSelection.addEventListener("click", clearSelection); els.deleteSelected.addEventListener("click", requestBulkDelete); els.confirmDelete.addEventListener("click", () => { confirmBulkDelete().catch((error) => { alert(error.message || "批量删除失败"); state.bulkDeletePending = false; render(); }); }); els.cancelDelete.addEventListener("click", cancelBulkDelete); els.tagSelected.addEventListener("click", () => { const tag = prompt("给已选款添加什么标签?例如:低价跑量、藏式主推、直播待测"); if (!tag) return; bulkUpdateSelected({}, splitTags(tag)).catch((error) => alert(error.message || "批量加标签失败")); }); els.mobileStatusMenu.addEventListener("click", () => { if (state.selectedIds.size === 0) return; els.statusActionDialog.showModal(); }); document.querySelectorAll("[data-bulk-status]").forEach((button) => { button.addEventListener("click", () => { bulkUpdateSelected({ status: button.dataset.bulkStatus }).catch((error) => alert(error.message || "批量改状态失败")); }); }); document.querySelectorAll("[data-mobile-bulk-status]").forEach((button) => { button.addEventListener("click", () => { els.statusActionDialog.close(); bulkUpdateSelected({ status: button.dataset.mobileBulkStatus }).catch((error) => alert(error.message || "批量改状态失败")); }); }); els.search.addEventListener("input", () => setKeywordFilter(els.search.value)); els.mobileSearch.addEventListener("input", () => setKeywordFilter(els.mobileSearch.value)); els.platform.addEventListener("change", () => { state.filters.platform = els.platform.value; state.bulkDeletePending = false; loadItems(); }); els.track.addEventListener("change", () => { state.filters.track = els.track.value; state.bulkDeletePending = false; loadItems(); }); els.decision.addEventListener("change", () => { state.filters.decision = els.decision.value; if (!FILTERABLE_VIEWS.has(state.view)) state.view = "competitors"; syncActiveNav(state.view); state.bulkDeletePending = false; clearSelection(); loadItems(); }); els.status.addEventListener("change", () => { state.filters.status = els.status.value; state.bulkDeletePending = false; loadItems(); }); els.tag.addEventListener("input", () => { state.filters.tag = els.tag.value.trim(); state.bulkDeletePending = false; clearTimeout(els.tag._timer); els.tag._timer = setTimeout(loadItems, 180); }); els.priceBand.addEventListener("change", () => { state.filters.priceBand = els.priceBand.value; state.bulkDeletePending = false; loadItems(); }); els.source.addEventListener("change", () => { state.filters.source = els.source.value; state.bulkDeletePending = false; loadItems(); }); els.risk.addEventListener("change", () => { state.filters.risk = els.risk.value; state.bulkDeletePending = false; loadItems(); }); init();