e9939ef9dc
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>
2511 lines
99 KiB
JavaScript
2511 lines
99 KiB
JavaScript
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("&", "&")
|
||
.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();
|