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 内
直播跑量优先
今日买手动作
${nextActions || "- 先采集一批候选款,系统会自动给下一步建议。
"}
跑量价格带
${miniStat("≤10", dashboard.priceBands.under10)}
${miniStat("10-30", dashboard.priceBands.from10To30)}
${miniStat("30-100", dashboard.priceBands.from30To100)}
${miniStat(">100", dashboard.priceBands.over100)}
低价跑量款
${lowPriceItems || "
暂无 10 元以内候选,继续从 1688 补货源。
"}
利润健康款
${profitItems || "
暂无利润健康款,优先补进货价和直播价。
"}
高风险提醒
${riskItems || "
当前暂无明显高风险话术。
"}
`;
}
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)}
采集提示
${hints || "- 优先采集主图清晰、价格明确、评论/销量可见的页面。
"}
`;
}
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)}
下载插件安装包
- 下载并解压安装包。
- 打开
chrome://extensions。
- 开启开发者模式,选择“加载已解压的扩展程序”。
- 把服务地址和插件 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 即可。")}
用户管理
${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 `
`;
}
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 `
${escapeHtml(accountInitial)}
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, "当前使用状态")}
Workspace
工作空间信息
账号、插件目录、服务地址和数据文件都在这里查看。
${profileInfo("账号", userEmail)}
${profileInfo("角色", role)}
${profileInfo("状态", status)}
${profileInfo("插件目录", "extension")}
${profileInfo("服务地址", location.origin)}
${profileInfo("数据文件", "data/items.json")}
${profileInfo("AI 引擎", aiReady ? `${state.aiStatus.ai.provider || "AI"} · ${state.aiStatus.ai.model || "模型"}` : "管理员未启用")}
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
? `
`
: `饰待补商品图
`;
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
? `
`
: `无图片
`;
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 = `
${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 || "直播间用户")}
${scenes}
${escapeHtml(pitch.priceScript || "")}
问供应商
${escapeHtml(item.supplier || item.shop || "待确认")}
评分理由
${escapeHtml(String(item.score || 0))} 分
风险提醒
${escapeHtml(item.riskLevel || "low")}
`;
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)}
${list.map((value) => `- ${escapeHtml(value)}
`).join("")}
`;
}
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();