Files
mosen e9939ef9dc Initial release with production deploy config.
Add GitAtlas deploy assets, DATA_FILE support for isolated server data, and port documentation for sourcing.simosen.cn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 00:52:31 +08:00

2511 lines
99 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => `<li>${escapeHtml(action)}</li>`).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]) => `<div class="metric-row"><span>${escapeHtml(track)}</span><strong>${count}</strong></div>`)
.join("");
return `
<section class="module-hero">
<div>
<p class="eyebrow">Buyer Command</p>
<h3>今日选品采购指挥台</h3>
<p>先把直播间能跑量的款筛出来,再用 1688 货源做同款/近似款匹配,最后推进拿样和采购。</p>
</div>
<div class="hero-command">
<span class="hero-chip">客单价 ¥100 内</span>
<span class="hero-chip gold">直播跑量优先</span>
<button class="button primary" type="button" data-switch-view="capture">开始采集</button>
<button class="button secondary" type="button" data-switch-view="decisions">看采购决策</button>
</div>
</section>
<section class="workflow-strip" aria-label="采购流程">
${workflow}
</section>
<div class="panel-grid">
<section class="panel">
<h3>今日买手动作</h3>
<ul class="action-list">${nextActions || "<li>先采集一批候选款,系统会自动给下一步建议。</li>"}</ul>
</section>
<section class="panel">
<h3>跑量价格带</h3>
<div class="mini-stats">
${miniStat("≤10", dashboard.priceBands.under10)}
${miniStat("10-30", dashboard.priceBands.from10To30)}
${miniStat("30-100", dashboard.priceBands.from30To100)}
${miniStat(">100", dashboard.priceBands.over100)}
</div>
</section>
<section class="panel">
<h3>内部赛道分布</h3>
<div class="metric-list">${tracks || "<p class='muted'>暂无赛道数据</p>"}</div>
</section>
</div>
<div class="panel-grid decision-grid">
<section class="panel">
<h3>低价跑量款</h3>
<div class="compact-list">${lowPriceItems || "<p class='muted'>暂无 10 元以内候选,继续从 1688 补货源。</p>"}</div>
</section>
<section class="panel">
<h3>利润健康款</h3>
<div class="compact-list">${profitItems || "<p class='muted'>暂无利润健康款,优先补进货价和直播价。</p>"}</div>
</section>
<section class="panel panel-risk">
<h3>高风险提醒</h3>
<div class="compact-list">${riskItems || "<p class='muted'>当前暂无明显高风险话术。</p>"}</div>
</section>
</div>
<section class="panel">
<h3>高分机会</h3>
<div class="compact-list">${topItems || "<p class='muted'>暂无候选</p>"}</div>
</section>
`;
}
function renderSampleQueue() {
const cards = state.sampleItems.map(renderPipelineItem).join("");
return `
<section class="panel">
<div class="panel-head">
<h3>拿样测试</h3>
<p>建议拿样、问供应商、已拿样和直播测试中的款都会集中在这里。这里是直播验证前的推进台。</p>
</div>
<div class="pipeline-list">${cards || "<p class='muted'>暂无拿样任务,可以先在候选库批量标记“问供应商”。</p>"}</div>
</section>
`;
}
function renderStyleGroups() {
const groups = state.groups
.map(
(group) => `
<article class="group-card">
<div>
<p class="eyebrow">${escapeHtml(group.track)} · ${group.items.length} 条线索</p>
<h3>${escapeHtml(group.title)}</h3>
<div class="group-summary">
<span>平台:${escapeHtml((group.platforms || []).join(" / ") || "待确认")}</span>
<span>最低货源:${money(group.minCost)}</span>
<span>竞品高价:${money(group.maxCompetitorPrice)}</span>
<span>建议直播价:${money(group.suggestedRetailPrice)}</span>
<strong>机会分 ${escapeHtml(String(group.opportunityScore))}</strong>
</div>
<p class="muted">最优供应商:${escapeHtml(group.bestSupply?.supplier || group.bestSupply?.shop || group.bestSupply?.platform || "待确认")}</p>
</div>
<div class="group-items">
${group.items.slice(0, 4).map(renderCompactItem).join("")}
</div>
</article>
`,
)
.join("");
return `
<section class="panel">
<div class="panel-head">
<h3>款式匹配</h3>
<p>系统按赛道和关键词自动合并同款/近似款,用来把淘宝、小红书竞品和 1688 货源放在一起比较。</p>
</div>
<div class="group-list">${groups || "<p class='muted'>还没有足够相似款,继续采集竞品和 1688 货源。</p>"}</div>
</section>
`;
}
function renderCaptureCenter() {
const rules = state.rules || { captureHints: [] };
const recent = recentCaptureItems()
.slice(0, 6)
.map(renderCompactItem)
.join("");
const hints = (rules.captureHints || [])
.map((hint) => `<li>${escapeHtml(hint)}</li>`)
.join("");
return `
<section class="module-hero capture-hero">
<div>
<p class="eyebrow">Capture Hub</p>
<h3>采集中心</h3>
<p>浏览器插件负责抓取当前页面的图片、链接、价格、供应商和可见销量话术,系统负责清洗、打分和归类。</p>
</div>
<div class="hero-command">
<span class="hero-chip">图片 / 链接 / 价格</span>
<button class="button primary" type="button" data-switch-view="plugin">配置插件</button>
<button class="button secondary" type="button" data-clear-filters>清空筛选</button>
</div>
</section>
<div class="capture-command">
<section class="panel">
<h3>采集顺序</h3>
<div class="capture-steps">
${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)}
</div>
</section>
<section class="panel">
<h3>采集提示</h3>
<ul class="action-list">${hints || "<li>优先采集主图清晰、价格明确、评论/销量可见的页面。</li>"}</ul>
</section>
<section class="panel">
<h3>最近采集</h3>
<div class="compact-list">${recent || "<p class='muted'>暂无采集线索。</p>"}</div>
</section>
</div>
`;
}
function renderLibraryHeader({ eyebrow, title, body, count, actionLabel, action }) {
const actionAttr = action === "clear-filters" ? "data-clear-filters" : action === "source-1688" ? "data-source-1688" : "";
return `
<section class="module-hero library-hero">
<div>
<p class="eyebrow">${escapeHtml(eyebrow)}</p>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(body)}</p>
</div>
<div class="hero-command">
<strong>${escapeHtml(String(count))}</strong>
<span>当前结果</span>
<button class="button secondary" type="button" ${actionAttr}>${escapeHtml(actionLabel)}</button>
</div>
</section>
`;
}
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 `
<section class="module-hero decision-hero">
<div>
<p class="eyebrow">Purchase Board</p>
<h3>采购决策</h3>
<p>把高分机会、利润模型、风险提醒和拿样状态放在一张决策表里,适合第一批 100 元以内跑量款快速定款。</p>
</div>
<div class="decision-kpis">
${miniStat("可跑量", ready)}
${miniStat("建议拿样", sample)}
${miniStat("高风险", risky)}
</div>
</section>
<section class="panel decision-panel">
<div class="panel-head">
<h3>优先采购候选</h3>
<p>先看已匹配货源、利润可跑、风险较低的款;误选的款可以多选后批量清理。</p>
</div>
<div class="decision-table">${rows || "<p class='muted'>暂无可进入采购决策的款,继续采集竞品和 1688 货源。</p>"}</div>
</section>
`;
}
function renderDecisionRow(item) {
return `
<button class="decision-row" type="button" data-open-id="${escapeAttr(item.id)}">
<span class="decision-main">
<strong>${escapeHtml(item.title || "未命名候选款")}</strong>
<small>${escapeHtml(item.platform)} · ${escapeHtml(item.track || "未分类")} · ${escapeHtml(statusLabel(item.status))}</small>
</span>
<span>${escapeHtml(String(item.score || 0))} 分</span>
<span>${money(item.price)}${money(item.profit?.targetPrice)}</span>
<span>${escapeHtml(item.profit?.recommendation || "待测算")}</span>
</button>
`;
}
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 `
<section class="module-hero">
<div>
<p class="eyebrow">Capture Plugin</p>
<h3>采集插件</h3>
<p>插件会把浏览器页面里的图片、链接、价格和供应商信息提交到 SaaS 工作台。</p>
</div>
</section>
<section class="panel settings-empty">
<div>
<p class="eyebrow">Plugin Access</p>
<h3>先下载浏览器插件,再登录配置 Token</h3>
<p class="muted">插件安装包已经随系统提供。创建第一个账号后,当前本地候选库会自动归到这个账号,插件也可以用 Token 往云端系统提交采集数据。</p>
</div>
<div class="settings-empty-actions">
<a class="button primary" href="${escapeAttr(installUrl)}" download>下载插件安装包</a>
<button class="button secondary" type="button" data-open-auth-gate>创建 SaaS 账号</button>
</div>
</section>
`;
}
const tokenList = state.pluginTokens.map(renderPluginTokenCard).join("");
return `
<section class="module-hero">
<div>
<p class="eyebrow">Capture Plugin</p>
<h3>采集插件</h3>
<p>插件填入服务地址和 Token 后,就能把淘宝、小红书、1688 的图片、链接、价格提交到你的账号库。插件采集会消耗账号积分,多个 Token 共用同一个余额。</p>
</div>
<div class="hero-command">
<span class="hero-chip gold">余额 ${escapeHtml(String(state.user.credits || 0))} 积分</span>
<button class="button primary" type="button" id="createPluginTokenBtn">生成插件 Token</button>
</div>
</section>
<div class="settings-grid admin-layout">
<section class="panel settings-panel extension-package-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Extension Package</p>
<h3>浏览器采集插件安装包</h3>
<p>下载后解压,在 Chrome 扩展管理里开启开发者模式,再加载解压后的文件夹。</p>
</div>
</div>
<div class="extension-package-card">
<div>
<span>推荐版本</span>
<strong>${escapeHtml(version)}</strong>
<small>${escapeHtml(fileName)}</small>
</div>
<a class="button primary" href="${escapeAttr(installUrl)}" download>下载插件安装包</a>
</div>
<ol class="extension-install-steps">
<li>下载并解压安装包。</li>
<li>打开 <code>chrome://extensions</code>。</li>
<li>开启开发者模式,选择“加载已解压的扩展程序”。</li>
<li>把服务地址和插件 Token 填入插件设置。</li>
</ol>
</section>
<section class="panel settings-panel">
<div class="plugin-config">
<label>
<span>服务地址</span>
<input readonly value="${escapeAttr(location.origin)}" />
</label>
<label>
<span>新 Token</span>
<input id="latestPluginToken" readonly value="${escapeAttr(state.lastPluginToken || "生成后只显示一次")}" />
</label>
<button class="button secondary" type="button" id="copyPluginConfigBtn">复制配置</button>
</div>
</section>
<section class="panel settings-panel">
<div class="panel-head">
<div>
<h3>已创建 Token</h3>
<p>Token 只用于插件提交采集数据,不开放读取你的选品库。新版本生成的 Token 会保存在列表里,可随时复制配置;历史旧 Token 没有明文时需要重新生成。</p>
</div>
</div>
<div class="token-list">${tokenList || "<p class='muted'>还没有插件 Token。</p>"}</div>
</section>
</div>
`;
}
function renderPluginTokenCard(token) {
const disabled = token.status === "disabled";
const nextStatus = disabled ? "active" : "disabled";
const actionLabel = disabled ? "启用" : "停用";
const canCopyToken = Boolean(token.token);
return `
<article class="token-card ${disabled ? "is-disabled" : ""}">
<div class="token-card-main">
<span class="status-dot ${disabled ? "danger" : "success"}"></span>
<div>
<strong>${escapeHtml(token.name || "浏览器采集插件")}</strong>
<small>${escapeHtml(pluginStatusLabel(token.status))} · 创建 ${escapeHtml(formatDateTime(token.createdAt))}</small>
</div>
</div>
<div class="token-card-stats">
<div><span>采集次数</span><strong>${escapeHtml(String(token.usageCount || 0))}</strong></div>
<div><span>最近使用</span><strong>${escapeHtml(formatDateTime(token.lastUsedAt))}</strong></div>
</div>
<div class="token-card-actions">
<button class="button secondary compact-button" type="button" data-copy-token="${escapeAttr(token.id)}" ${canCopyToken ? "" : "disabled"}>复制 Token</button>
<button class="button secondary compact-button" type="button" data-copy-token-config="${escapeAttr(token.id)}" ${canCopyToken ? "" : "disabled"}>复制配置</button>
<button class="button secondary compact-button" type="button" data-plugin-status="${escapeAttr(token.id)}:${nextStatus}">${actionLabel}</button>
<button class="button danger compact-button" type="button" data-plugin-delete="${escapeAttr(token.id)}">删除</button>
</div>
</article>
`;
}
function renderAdminCenter() {
if (!isAdmin()) {
return `
<section class="module-hero">
<div>
<p class="eyebrow">Admin Center</p>
<h3>管理中心</h3>
<p>只有管理员可以查看用户、插件 Token 使用情况和账号状态。</p>
</div>
</section>
<section class="panel settings-empty">
<div>
<h3>当前账号不是管理员</h3>
<p class="muted">请使用管理员账号登录后再进入管理中心。</p>
</div>
<button class="button secondary" type="button" data-switch-view="account">返回账号设置</button>
</section>
`;
}
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 `
<section class="module-hero admin-hero">
<div>
<p class="eyebrow">Admin Center</p>
<h3>管理中心</h3>
<p>管理 SaaS 用户、账号状态和插件采集入口。管理员可以停用异常账号,查看每个买手沉淀了多少选品线索和采集 Token。</p>
</div>
<div class="hero-command">
<button class="button secondary" type="button" data-refresh-admin>刷新用户</button>
<button class="button primary" type="button" data-switch-view="plugin">管理插件 Token</button>
</div>
</section>
<section class="admin-kpi-grid" aria-label="平台管理统计">
${adminKpi("注册用户", users.length, "已进入 SaaS 的买手账号")}
${adminKpi("活跃账号", activeUsers, "可登录并使用系统")}
${adminKpi("已停用", disabledUsers, "无法登录或继续采集")}
${adminKpi("插件 Token", `${activePluginTokens}/${pluginTokens}`, "启用 / 全部")}
${adminKpi("账户积分", totalCredits, "所有用户当前余额")}
${adminKpi("AI Tokens", totalAiTokens, "用户 AI 分析可用余额")}
</section>
<section class="panel admin-center-panel ai-engine-panel">
<div class="panel-head">
<div>
<p class="eyebrow">AI Engine</p>
<h3>大模型能力配置</h3>
<p>管理员配置模型厂商、OpenAI 兼容代理、API Key 和提示词;用户只看到可用 AI 功能和自己的 AI token 余额,不接触密钥。</p>
</div>
<span class="admin-sync-state">${aiSettings.enabled ? "已启用" : "未启用"}${aiSettings.apiKeyMasked ? ` · ${escapeHtml(aiSettings.apiKeyMasked)}` : ""}</span>
</div>
<div class="ai-settings-grid">
<label class="ai-toggle-card">
<input id="aiEnabledInput" type="checkbox" ${aiSettings.enabled ? "checked" : ""} />
<span>
<strong>启用 AI 分析</strong>
<em>启用后用户可在商品详情中消耗 AI tokens 获取采购建议。</em>
</span>
</label>
${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")}
</div>
<div class="ai-prompt-grid">
${aiTextarea("aiPromptItemInput", "商品分析提示词", aiSettings.prompts?.itemAnalysis || "")}
${aiTextarea("aiPromptProcurementInput", "采购报告提示词", aiSettings.prompts?.procurementReport || "")}
${aiTextarea("aiPromptRulesInput", "规则生成提示词", aiSettings.prompts?.ruleGeneration || "")}
</div>
<div class="ai-admin-actions">
<button class="button primary" type="button" id="saveAiSettingsBtn">保存 AI 配置</button>
<button class="button secondary" type="button" id="testAiSettingsBtn">测试连接</button>
<span>${escapeHtml(state.aiAdminMessage || "支持 OpenAI 兼容 / 代理网关,Base URL 到 /v1 即可。")}</span>
</div>
</section>
<section class="panel admin-center-panel">
<div class="panel-head">
<div>
<h3>兑换码充值</h3>
<p>管理员生成兑换码,用户在账号页兑换后增加采集积分或 AI tokens。多个插件 Token 共用采集积分,AI 分析共用 AI token 余额。</p>
</div>
</div>
<div class="credit-code-form">
<label>
<span>采集积分</span>
<input id="creditAmountInput" type="number" min="0" step="1" value="100" />
</label>
<label>
<span>AI Tokens</span>
<input id="aiTokenAmountInput" type="number" min="0" step="1" value="0" />
</label>
<label>
<span>备注</span>
<input id="creditNoteInput" placeholder="例如:首充 100 积分 + 50000 AI tokens" />
</label>
<button class="button primary" type="button" id="createCreditCodeBtn">生成兑换码</button>
</div>
<div class="credit-code-list">
${creditRows || "<p class='muted'>还没有兑换码。</p>"}
</div>
</section>
<section class="panel admin-center-panel">
<div class="panel-head">
<div>
<h3>用户管理</h3>
<p>${state.adminLoading ? "正在同步用户列表..." : "停用账号后,该用户已有登录态和插件 Token 都会被后端拦截。"}</p>
</div>
<span class="admin-sync-state">${state.adminError ? escapeHtml(state.adminError) : state.adminLoading ? "同步中" : `${users.length} 个账号`}</span>
</div>
<div class="admin-user-table">
${rows || renderAdminEmptyState()}
</div>
</section>
`;
}
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 `
<article class="credit-code-row ${redeemed ? "is-disabled" : ""}">
<div>
<strong>${escapeHtml(code.code)}</strong>
<small>${escapeHtml(code.note || "无备注")} · ${escapeHtml(formatDateTime(code.createdAt))}</small>
</div>
<span class="role-chip ${redeemed ? "danger" : "success"}">${redeemed ? "已兑换" : "可兑换"}</span>
<strong>${escapeHtml(parts.join(" / ") || "0 额度")}</strong>
<button class="button secondary compact-button" type="button" data-copy-credit-code="${escapeAttr(code.code)}">复制</button>
</article>
`;
}
function renderAdminUserRow(user) {
const isSelf = user.id === state.user?.id;
const disabled = user.status === "disabled";
const actionStatus = disabled ? "active" : "disabled";
const actionLabel = disabled ? "启用" : "停用";
return `
<article class="admin-user-row ${disabled ? "is-disabled" : ""}">
<div class="admin-user-main">
<span class="status-dot ${disabled ? "danger" : "success"}"></span>
<div>
<strong>${escapeHtml(user.name || user.email || "未命名用户")}</strong>
<small>${escapeHtml(user.email || "")}</small>
</div>
</div>
<div class="admin-user-meta">
<span class="role-chip ${user.role === "admin" ? "gold" : ""}">${escapeHtml(roleLabel(user.role))}</span>
<span class="role-chip ${disabled ? "danger" : "success"}">${escapeHtml(userStatusLabel(user.status))}</span>
</div>
<div class="admin-user-counts">
<div><span>线索</span><strong>${escapeHtml(String(user.itemCount || 0))}</strong></div>
<div><span>Token</span><strong>${escapeHtml(String(user.activePluginTokenCount || 0))}/${escapeHtml(String(user.pluginTokenCount || 0))}</strong></div>
<div><span>积分</span><strong>${escapeHtml(String(user.credits || 0))}</strong></div>
<div><span>AI</span><strong>${escapeHtml(String(user.aiTokens || 0))}</strong></div>
</div>
<div class="admin-user-time">
<span>创建 ${escapeHtml(formatDateTime(user.createdAt))}</span>
<span>更新 ${escapeHtml(formatDateTime(user.updatedAt || user.createdAt))}</span>
</div>
<button class="button ${disabled ? "secondary" : "danger"} compact-button" type="button" data-user-status="${escapeAttr(user.id)}:${actionStatus}" ${isSelf ? "disabled" : ""}>
${isSelf ? "当前账号" : actionLabel}
</button>
</article>
`;
}
function aiInput(id, label, value, placeholder = "", type = "text", step = "1") {
return `
<label>
<span>${escapeHtml(label)}</span>
<input id="${escapeAttr(id)}" type="${escapeAttr(type)}" ${type === "number" ? `step="${escapeAttr(step)}"` : ""} value="${escapeAttr(value ?? "")}" placeholder="${escapeAttr(placeholder)}" />
</label>
`;
}
function aiTextarea(id, label, value) {
return `
<label class="ai-prompt-label">
<span>${escapeHtml(label)}</span>
<textarea id="${escapeAttr(id)}" rows="5">${escapeHtml(value || "")}</textarea>
</label>
`;
}
function adminKpi(label, value, hint) {
return `
<article class="admin-kpi-card">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(String(value))}</strong>
<em>${escapeHtml(hint)}</em>
</article>
`;
}
function renderAdminEmptyState() {
if (state.adminLoading) {
return `
<div class="admin-empty-state">
<span>饰</span>
<strong>正在加载用户</strong>
<p>同步账号、插件 Token 和选品线索统计。</p>
</div>
`;
}
return `
<div class="admin-empty-state">
<span>饰</span>
<strong>暂无用户数据</strong>
<p>创建账号后,用户会出现在这里。</p>
</div>
`;
}
function renderRuleSettings() {
if (!state.user) {
return `
<section class="module-hero">
<div>
<p class="eyebrow">Rule Studio</p>
<h3>规则配置</h3>
<p>这里管理赛道关键词、风险词、采集提示、自动标签和第一批跑量利润模型。</p>
</div>
</section>
<section class="panel settings-empty">
<div>
<p class="eyebrow">Rule Studio</p>
<h3>登录后编辑评分和采集规则</h3>
<p class="muted">规则会影响后续采集的赛道识别、风险提醒、利润模型和自动标签。</p>
</div>
<button class="button primary" type="button" data-open-auth-gate>创建 SaaS 账号</button>
</section>
`;
}
const rules = state.rules || { tracks: [], riskKeywords: [], profitModel: {}, captureHints: [], autoTags: [] };
const trackRows = (rules.tracks || []).map(renderTrackRuleRow).join("");
const profitModel = rules.profitModel || {};
return `
<section class="module-hero rule-hero">
<div>
<p class="eyebrow">Rule Studio</p>
<h3>选品评分规则工作室</h3>
<p>把系统判断“能不能跑量、该不该拿样、有没有风险”的规则集中在这里维护。先调赛道识别,再调利润模型,最后补风险词和采集提示。</p>
</div>
<div class="hero-command rule-save-card" aria-label="规则保存状态">
<div>
<span class="hero-chip">赛道 ${escapeHtml(String((rules.tracks || []).length))}</span>
<span class="hero-chip gold">跑量价 ≤ ${money(profitModel.maxRunningPrice)}</span>
</div>
<p>修改后会影响后续采集评分、风险提醒和拿样建议。</p>
<button class="button primary" type="button" id="saveRulesBtn">保存规则</button>
</div>
</section>
<section class="rule-summary-grid" aria-label="规则概览">
${ruleSummaryCard("赛道识别", `${(rules.tracks || []).length}`, "影响耳饰、项链、藏式、发饰等内部赛道归类。")}
${ruleSummaryCard("风险词库", `${(rules.riskKeywords || []).length}`, "影响高风险提醒和详情页话术避坑。", "danger")}
${ruleSummaryCard("采集提示", `${(rules.captureHints || []).length}`, "指导插件采集时优先保留哪些页面线索。")}
${ruleSummaryCard("自动标签", `${(rules.autoTags || []).length}`, "用于批量清洗和直播选品筛选。", "gold")}
</section>
<div class="rules-workbench">
<section class="panel settings-panel rule-panel rule-panel-tracks">
<div class="panel-head rule-panel-head">
<div>
<p class="eyebrow">Track Matching</p>
<h3>赛道关键词</h3>
<p>用关键词把采集款自动归入藏式/民族风、耳饰、项链、发饰等赛道。关键词越贴近直播搜索词,后续筛选越准。</p>
</div>
<button class="button secondary compact-button" type="button" id="addTrackRuleBtn">新增赛道</button>
</div>
<div id="trackRulesEditor" class="track-rules-editor">${trackRows}</div>
</section>
<aside class="rules-side-stack">
<section class="panel settings-panel rule-panel profit-panel">
<div class="rule-panel-head compact">
<div>
<p class="eyebrow">Profit Model</p>
<h3>第一批跑量利润模型</h3>
<p>这些数字会影响“可跑量 / 可测试 / 暂不做”的利润判断。</p>
</div>
</div>
<div class="profit-rule-grid">
${numberInput("shippingFee", "默认快递费", profitModel.shippingFee)}
${numberInput("packagingFee", "默认包装费", profitModel.packagingFee)}
${numberInput("platformFeeRate", "平台费率", profitModel.platformFeeRate, "0.01")}
${numberInput("promoFee", "直播让利", profitModel.promoFee)}
${numberInput("maxRunningPrice", "跑量最高售价", profitModel.maxRunningPrice)}
</div>
</section>
<section class="panel settings-panel rule-panel risk-panel">
<div class="rule-panel-head compact">
<div>
<p class="eyebrow">Risk Library</p>
<h3>风险词</h3>
<p>每行一个,系统会在标题、备注和话术里提示风险。</p>
</div>
</div>
<label class="rule-textarea-label">
<span>风险词,每行一个</span>
<textarea id="riskKeywordsInput" rows="8">${escapeHtml((rules.riskKeywords || []).join("\n"))}</textarea>
</label>
</section>
<section class="panel settings-panel rule-panel helper-panel">
<div class="dual-rule-grid">
<label class="rule-textarea-label">
<span>采集提示,每行一个</span>
<textarea id="captureHintsInput" rows="5">${escapeHtml((rules.captureHints || []).join("\n"))}</textarea>
</label>
<label class="rule-textarea-label">
<span>自动标签,每行一个</span>
<textarea id="autoTagsInput" rows="5">${escapeHtml((rules.autoTags || []).join("\n"))}</textarea>
</label>
</div>
</section>
</aside>
</div>
`;
}
function ruleSummaryCard(label, value, hint, tone = "") {
return `
<article class="rule-summary-card ${tone ? `is-${tone}` : ""}">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
<p>${escapeHtml(hint)}</p>
</article>
`;
}
function renderTrackRuleRow(track, index) {
return `
<div class="track-rule-row" data-track-row>
<div class="track-rule-index">
<span>${escapeHtml(String(index + 1).padStart(2, "0"))}</span>
</div>
<label>
<span>赛道名</span>
<input data-track-name value="${escapeAttr(track.name || "")}" />
</label>
<label>
<span>关键词</span>
<input data-track-keywords value="${escapeAttr((track.keywords || []).join(""))}" />
</label>
<button class="button secondary compact-button" type="button" data-remove-track="${index}">移除</button>
</div>
`;
}
function profileQuickAction(label, body, action, tone = "") {
return `
<button class="profile-action-card ${tone ? `is-${tone}` : ""}" type="button" data-switch-view="${escapeAttr(action)}">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(body)}</strong>
</button>
`;
}
function profileStat(label, value, hint = "", tone = "") {
return `
<article class="profile-stat ${tone ? `is-${tone}` : ""}">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(String(value))}</strong>
${hint ? `<em>${escapeHtml(hint)}</em>` : ""}
</article>
`;
}
function profileInfo(label, value) {
return `
<div class="profile-info-row">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(String(value || "待确认"))}</strong>
</div>
`;
}
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 `
<section class="profile-dashboard">
<section class="profile-identity-panel">
<div class="profile-avatar" aria-hidden="true">${escapeHtml(accountInitial)}</div>
<div class="profile-identity-copy">
<p class="eyebrow">Account Center</p>
<h3>${escapeHtml(userName)}</h3>
<p>${escapeHtml(userEmail)}</p>
<div class="profile-badges">
<span>${escapeHtml(role)}</span>
<span>${escapeHtml(status)}</span>
<span>${escapeHtml(accountMode)}</span>
</div>
</div>
<div class="profile-primary-actions" aria-label="账号快捷操作">
<button class="button primary" type="button" data-switch-view="plugin">配置 Token</button>
<button class="button secondary" type="button" data-switch-view="${isAdmin() ? "admin" : "rules"}">${isAdmin() ? "管理用户" : "评分规则"}</button>
</div>
</section>
<section class="profile-stat-grid" aria-label="账户关键指标">
${profileStat("账户积分", credits, "插件采集共用余额", "gold")}
${profileStat("AI Tokens", aiTokens, aiReady ? "AI 分析可用额度" : "AI 引擎待管理员启用", "jade")}
${profileStat("插件 Token", tokenCount, "可停用 / 可复制", "jade")}
${profileStat("账号角色", role, "权限和管理范围")}
${profileStat("账号状态", status, "当前使用状态")}
</section>
<div class="profile-content-grid">
<section class="panel profile-panel account-service-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Workspace</p>
<h3>工作空间信息</h3>
<p>账号、插件目录、服务地址和数据文件都在这里查看。</p>
</div>
</div>
<div class="profile-info-list">
${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 || "模型"}` : "管理员未启用")}
</div>
</section>
<section class="panel profile-panel profile-action-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Shortcuts</p>
<h3>常用入口</h3>
<p>按真实工作顺序进入管理、插件、规则和采集。</p>
</div>
</div>
<div class="profile-action-grid">
${isAdmin() ? profileQuickAction("管理中心", "用户 / 积分 / 兑换码", "admin", "primary") : ""}
${profileQuickAction("采集插件", "服务地址 / Token / 更新", "plugin")}
${profileQuickAction("规则配置", "赛道 / 利润 / 风险词", "rules", "gold")}
${profileQuickAction("采集中心", "继续整理候选款", "capture")}
</div>
</section>
</div>
${
state.user
? `
<section class="credit-redeem-panel profile-credit-panel">
<div class="credit-copy">
<p class="eyebrow">Recharge</p>
<h3>兑换采集积分 / AI Tokens</h3>
<p class="muted">输入管理员发放的兑换码,为当前账号充值采集积分或 AI tokens。插件每保存 1 条线索消耗 1 积分,AI 分析按模型返回 tokens 计费。</p>
</div>
<div class="credit-redeem-form">
<input id="creditCodeInput" autocomplete="off" inputmode="text" placeholder="CDX-XXXX-XXXX-XXXX" />
<button class="button primary" type="button" id="redeemCreditBtn">兑换</button>
</div>
</section>
`
: ""
}
</section>
`;
}
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
? `<img class="item-image" src="${escapeAttr(item.image)}" alt="${escapeAttr(item.title || "商品图片")}" loading="lazy" referrerpolicy="no-referrer" />`
: `<div class="item-placeholder"><span>饰</span><strong>待补商品图</strong></div>`;
const tags = [...(item.tags || []), ...(item.autoTags || [])]
.slice(0, 4)
.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`)
.join("");
const supplier = item.supplier || item.shop || (item.platform === "1688" ? "待确认厂家" : "竞品店铺");
const priceLabel = item.platform === "1688" ? "拿样价" : "竞品价";
return `
<article class="item-card ${selected ? "selected" : ""}" data-id="${escapeAttr(item.id)}" data-risk="${escapeAttr(item.riskLevel || "low")}" data-profit="${escapeAttr(item.profit?.recommendation || "待测算")}">
<label class="select-box">
<input class="item-select" type="checkbox" data-action="select-item" data-id="${escapeAttr(item.id)}" aria-label="选择 ${escapeAttr(item.title || "未命名候选款")}" ${selected ? "checked" : ""} />
<span>选择</span>
</label>
${image}
<div class="item-body">
<div class="item-meta">
<span class="pill">${escapeHtml(item.platform)}</span>
<span class="pill ${decisionClass}">${escapeHtml(item.decision)} · ${escapeHtml(String(item.score || 0))}</span>
</div>
<h3 class="item-title">${escapeHtml(item.title || "未命名候选款")}</h3>
<div class="signal-row">
<span class="signal ${profitClass}">${escapeHtml(item.profit?.recommendation || "待测算")}</span>
<span class="signal ${riskClass}">${riskText}</span>
${item.platform === "1688" ? '<span class="signal source">1688 货源</span>' : ""}
</div>
<div class="supplier-line">
<span>${escapeHtml(supplier)}</span>
<strong>${escapeHtml(priceLabel)} ${money(item.price)}</strong>
</div>
<div class="tag-row">${tags}</div>
<div class="item-facts">
<div class="fact"><span>赛道</span><strong>${escapeHtml(item.track || "未分类")}</strong></div>
<div class="fact"><span>建议直播价</span><strong>${money(item.profit?.targetPrice)}</strong></div>
<div class="fact"><span>毛利</span><strong>${money(item.profit?.grossProfit)}</strong></div>
<div class="fact"><span>状态</span><strong>${escapeHtml(statusLabel(item.status))}</strong></div>
</div>
<div class="card-actions">
<button type="button" data-action="detail">详情</button>
<a href="${escapeAttr(item.url || "#")}" target="_blank" rel="noreferrer">打开原页</a>
</div>
</div>
</article>
`;
}
function renderCompactItem(item) {
return `
<button class="compact-item" type="button" data-open-id="${escapeAttr(item.id)}">
<span>${escapeHtml(item.title || "未命名候选款")}</span>
<strong>${escapeHtml(String(item.score || 0))} · ${money(item.price)}</strong>
</button>
`;
}
function renderPipelineItem(item) {
return `
<article class="pipeline-item">
<div>
<h4>${escapeHtml(item.title || "未命名候选款")}</h4>
<p>${escapeHtml(item.platform)} · ${escapeHtml(item.track)} · ${money(item.price)}${money(item.profit?.targetPrice)}</p>
</div>
<div class="pipeline-actions">
<button type="button" class="button secondary" data-open-id="${escapeAttr(item.id)}">详情</button>
<button type="button" class="button secondary" data-quick-status="${escapeAttr(item.id)}:asking_supplier">问供应商</button>
<button type="button" class="button secondary" data-quick-status="${escapeAttr(item.id)}:ordered_sample">已拿样</button>
<button type="button" class="button secondary" data-quick-status="${escapeAttr(item.id)}:live_testing">直播测试</button>
</div>
</article>
`;
}
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) => `<li>${escapeHtml(reason)}</li>`).join("");
const risks = (item.risks || []).map((risk) => `<li>${escapeHtml(risk)}</li>`).join("");
const aiResult = state.aiAnalysisByItem[item.id] || null;
const pitch = item.livePitch || {};
const talkingPoints = (pitch.talkingPoints || []).map((point) => `<li>${escapeHtml(point)}</li>`).join("");
const scenes = (pitch.scenes || []).map((scene) => `<span class="tag">${escapeHtml(scene)}</span>`).join("");
const questions = (pitch.supplierQuestions || []).map((question) => `<li>${escapeHtml(question)}</li>`).join("");
const image = item.image
? `<img src="${escapeAttr(item.image)}" alt="${escapeAttr(item.title || "商品图片")}" referrerpolicy="no-referrer" />`
: `<div class="item-placeholder">无图片</div>`;
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 = `
<div class="dialog-grid">
<div class="dialog-media">
${image}
<div class="dialog-quick-signals">
<span class="signal ${profitClass}">${escapeHtml(item.profit?.recommendation || "待测算")}</span>
<span class="signal ${riskClass}">${escapeHtml(item.riskLevel === "high" ? "高风险" : item.riskLevel === "medium" ? "有风险" : "低风险")}</span>
<span class="signal source">${escapeHtml(statusLabel(item.status))}</span>
</div>
</div>
<div class="dialog-fields">
<div>
<p class="eyebrow">${escapeHtml(item.platform)} · ${escapeHtml(item.track)}</p>
<h2>${escapeHtml(item.title || "未命名候选款")}</h2>
</div>
<div class="item-facts">
<div class="fact"><span>综合评分</span><strong>${escapeHtml(String(item.score || 0))}</strong></div>
<div class="fact"><span>建议动作</span><strong>${escapeHtml(item.decision || "待判断")}</strong></div>
<div class="fact"><span>进货价</span><strong>${money(item.price)}</strong></div>
<div class="fact"><span>建议直播价</span><strong>${money(item.profit?.targetPrice)}</strong></div>
<div class="fact"><span>预计毛利</span><strong>${money(item.profit?.grossProfit)}</strong></div>
<div class="fact"><span>毛利率</span><strong>${percent(item.profit?.grossMarginRate)}</strong></div>
</div>
<div class="detail-section">
<div class="section-title">
<strong>利润模型</strong>
<span>${escapeHtml(item.profit?.recommendation || "待测算")}</span>
</div>
<div class="profit-breakdown">
<div><span>总成本</span><strong>${money(item.profit?.totalCost)}</strong></div>
<div><span>快递</span><strong>${money(item.profit?.shippingFee)}</strong></div>
<div><span>包装</span><strong>${money(item.profit?.packagingFee)}</strong></div>
<div><span>让利</span><strong>${money(item.profit?.promoFee)}</strong></div>
</div>
</div>
<label>
<span>处理状态</span>
<select id="dialogStatus">${statusOptions(item.status)}</select>
</label>
<label>
<span>人工标签</span>
<input id="dialogTags" value="${escapeAttr((item.tags || []).join(""))}" placeholder="例如:低价跑量,直播待测,藏式主推" />
</label>
<label>
<span>备注</span>
<textarea id="dialogNotes" rows="4" placeholder="例如:先拿样 3 件,问耳针材质,确认是否现货">${escapeHtml(item.notes || "")}</textarea>
</label>
<div class="detail-section ai-detail-section">
<div class="section-title">
<strong>AI 采购参谋</strong>
<span>${escapeHtml(aiStatusLabel())}</span>
</div>
${renderAiAnalysisBlock(aiResult)}
<div class="ai-detail-actions">
<button id="runAiAnalysisBtn" class="button primary" type="button" ${canRunAiAnalysis() ? "" : "disabled"}>AI 分析商品</button>
<span>${escapeHtml(aiUsageHint())}</span>
</div>
</div>
<div class="detail-section">
<div class="section-title">
<strong>直播卖点</strong>
<span>${escapeHtml(pitch.audience || "直播间用户")}</span>
</div>
<ul class="reason-list">${talkingPoints || "<li>暂无</li>"}</ul>
<div class="tag-row">${scenes}</div>
<p class="muted">${escapeHtml(pitch.priceScript || "")}</p>
</div>
<div class="detail-section">
<div class="section-title">
<strong>问供应商</strong>
<span>${escapeHtml(item.supplier || item.shop || "待确认")}</span>
</div>
<ul class="reason-list">${questions || "<li>暂无</li>"}</ul>
</div>
<div class="detail-section">
<div class="section-title">
<strong>评分理由</strong>
<span>${escapeHtml(String(item.score || 0))} 分</span>
</div>
<ul class="reason-list">${reasons || "<li>暂无</li>"}</ul>
</div>
<div class="detail-section risk-section">
<div class="section-title">
<strong>风险提醒</strong>
<span>${escapeHtml(item.riskLevel || "low")}</span>
</div>
<ul class="risk-list">${risks || "<li>暂无</li>"}</ul>
</div>
<div class="topbar-actions">
<button id="saveDetailBtn" class="button primary" type="button">保存状态</button>
<button id="deleteDetailBtn" class="button secondary" type="button">删除</button>
</div>
</div>
</div>
`;
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 `
<div class="ai-empty-state">
<strong>让 AI 帮你复核这条商品</strong>
<p>会基于标题、图片链接、价格、平台、供应商、评分规则和利润模型,输出采购建议、直播卖点、风险和供应商问题。</p>
</div>
`;
}
const analysis = result.analysis || {};
return `
<div class="ai-analysis-result">
<p>${escapeHtml(analysis.summary || "AI 已返回分析结果。")}</p>
${analysis.purchaseAdvice ? `<div><span>采购建议</span><strong>${escapeHtml(analysis.purchaseAdvice)}</strong></div>` : ""}
${renderAiList("直播卖点", analysis.sellingPoints)}
${renderAiList("风险提醒", analysis.risks, "danger")}
${renderAiList("问供应商", analysis.supplierQuestions)}
<small>本次扣费 ${escapeHtml(String(result.billing?.deductedAiTokens || 0))} AI Tokens,剩余 ${escapeHtml(String(result.billing?.remainingAiTokens ?? state.user?.aiTokens ?? 0))}。</small>
</div>
`;
}
function renderAiList(label, values, tone = "") {
const list = Array.isArray(values) ? values.filter(Boolean) : values ? [values] : [];
if (list.length === 0) return "";
return `
<div class="ai-result-list ${tone ? `is-${tone}` : ""}">
<span>${escapeHtml(label)}</span>
<ul>${list.map((value) => `<li>${escapeHtml(value)}</li>`).join("")}</ul>
</div>
`;
}
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]) => `<option value="${key}" ${key === value ? "selected" : ""}>${label}</option>`).join("");
}
function statusLabel(value) {
return {
new: "新采集",
reviewing: "待复核",
asking_supplier: "问供应商",
ordered_sample: "已拿样",
live_testing: "直播测试",
rejected: "已放弃",
}[value] || value || "新采集";
}
function miniStat(label, value) {
return `<div class="mini-stat"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value || 0))}</strong></div>`;
}
function workflowStep(label, value, index) {
return `
<div class="workflow-step">
<span>${escapeHtml(String(index).padStart(2, "0"))}</span>
<strong>${escapeHtml(label)}</strong>
<em>${escapeHtml(String(value || 0))}</em>
</div>
`;
}
function numberInput(key, label, value, step = "1") {
return `
<label>
<span>${escapeHtml(label)}</span>
<input id="profit-${escapeAttr(key)}" type="number" min="0" step="${escapeAttr(step)}" value="${escapeAttr(value ?? "")}" />
</label>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function escapeAttr(value = "") {
return escapeHtml(value).replaceAll("`", "&#096;");
}
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();