/** * 电商套图工作台 - Side Panel * * 布局:顶栏 + 目标平台 * ├─ 左 01 商品信息 | 右 02 采集图片(两列等高,采集阶段) * ├─ 03 出图方案(整行:方案列表 + AI 智能规划 + 风格 + 一键生成) * └─ 04 生成结果(整行) * * 流程:采集页面 → 编辑/勾选 → AI 规划或用默认方案 → 一键生成 → 导出 ZIP */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { App as AntApp, ConfigProvider, Popover, Progress } from 'antd'; import { SettingOutlined, DownloadOutlined, ThunderboltOutlined } from '@ant-design/icons'; import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import { buildGeneratePayload, suiteZipUrl, DEFAULT_PLAN, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, type PlanItem, type SuiteInfo, } from '../../src/api/client'; import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; /** 数据来源 → 展示含义 */ const SOURCE_LABELS: Record = { state: '页面数据', ssr: '页面数据', jsonld: '结构化数据', api: '站内接口', dom: 'DOM解析', mixed: '混合来源', }; /** 平台 → 展示名 */ const PLATFORM_LABELS: Record = { ozon: 'Ozon', '1688': '1688', taobao: '淘宝/天猫', }; /** 在当前活动 tab 执行采集(content script 已把入口挂到 window) */ async function scanActiveTab(): Promise { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) throw new Error('未找到活动标签页'); const [res] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => (window as any).__SuiteCollector?.scan?.() ?? null, }); const r = (res?.result ?? null) as ScanResult | null; if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试'); return r; } function send(action: string, payload: Record): Promise { return new Promise((resolve, reject) => { chrome.runtime.sendMessage({ action, ...payload }, (res: { ok: boolean; data?: T; error?: string }) => { if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); if (!res?.ok) return reject(new Error(res?.error || '后台请求失败')); resolve(res.data as T); }); }); } // ── 小组件 ───────────────────────────────────────────────────────────────── const Section: React.FC<{ no: string; title: string; extra?: React.ReactNode; children: React.ReactNode }> = ({ no, title, extra, children }) => (
{no} {title} {extra && {extra}}
{children}
); const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
{children}
); /** 数量加减器 */ const Stepper: React.FC<{ value: number; onChange: (v: number) => void }> = ({ value, onChange }) => ( {value} ); const App: React.FC = () => { const { modal } = AntApp.useApp(); // 采集 const [scanning, setScanning] = useState(false); const [result, setResult] = useState(null); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [titleEdit, setTitleEdit] = useState(''); const [descEdit, setDescEdit] = useState(''); const [paramsOpen, setParamsOpen] = useState(false); // 服务端 const [settings, setSettings] = useState({ baseUrl: 'http://127.0.0.1:3300', token: '' }); // 出图方案 const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('cn'); const [styleSet, setStyleSet] = useState(1); const [plan, setPlan] = useState(DEFAULT_PLAN.map(p => ({ ...p }))); const [planSource, setPlanSource] = useState<'default' | 'ai'>('default'); const [planSummary, setPlanSummary] = useState(''); const [planning, setPlanning] = useState(false); // 生成 const [suite, setSuite] = useState(null); const [generating, setGenerating] = useState(false); const pollRef = useRef | null>(null); // 图片放大预览 const [previewUrl, setPreviewUrl] = useState(null); useEffect(() => { loadSettings().then(setSettings); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); const price = result?.texts.find(t => t.kind === 'price')?.content ?? ''; const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? ''; const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? []; const handleScan = async () => { setScanning(true); try { const r = await scanActiveTab(); setResult(r); setSuite(null); setParamsOpen(false); // 默认全选主图 + SKU 图(SKU 图带规格名,AI 规划的 variant 绑定要用) const keys = new Set(r.images.filter(i => i.groupKey === 'main' || i.groupKey === 'sku').map(i => i.key)); setSelectedKeys(keys); setTitleEdit(r.texts.find(t => t.kind === 'title')?.content ?? ''); setDescEdit(r.texts.find(t => t.kind === 'desc')?.content ?? ''); } catch (e) { modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } finally { setScanning(false); } }; const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; const pollSuite = useCallback((suiteId: string) => { stopPolling(); pollRef.current = setInterval(async () => { try { const s = await send('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId }); setSuite(s); if (['done', 'partial', 'failed'].includes(s.status)) { stopPolling(); setGenerating(false); if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: '可重试或更换风格重新生成', okText: '知道了' }); if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' }); } } catch (e) { stopPolling(); setGenerating(false); } }, 3000); }, [settings.baseUrl, settings.token, modal]); /** 当前编辑后的文本素材(规划与生成共用) */ const editedTexts = () => { if (!result) return []; const orig = (kind: string) => result.texts.find(t => t.kind === kind); const texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }> = []; const title = titleEdit || orig('title')?.content || ''; const desc = descEdit || orig('desc')?.content || ''; if (title) texts.push({ kind: 'title', content: title }); if (orig('price')?.content) texts.push({ kind: 'price', content: orig('price')!.content }); if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content }); if ((orig('params')?.pairs ?? []).length) texts.push({ kind: 'params', content: '', pairs: orig('params')!.pairs }); if (orig('selling_point')?.content) texts.push({ kind: 'selling_point', content: orig('selling_point')!.content }); if (desc) texts.push({ kind: 'desc', content: desc }); return texts; }; /** AI 智能规划出图方案 */ const handlePlan = async () => { if (!result) return modal.warning({ title: '请先采集商品页' }); setPlanning(true); try { const skuVariants = Array.from(new Set( result.images.filter(i => i.groupKey === 'sku' && i.variantName).map(i => i.variantName!) )); const data = await send<{ summary: string; items: PlanItem[] }>('planSuite', { baseUrl: settings.baseUrl, token: settings.token, payload: { texts: editedTexts(), sku_variants: skuVariants, image_stats: result.stats, platform, }, }); setPlan(data.items); setPlanSource('ai'); setPlanSummary(data.summary); const total = data.items.reduce((s, i) => s + i.count, 0); modal.info({ title: 'AI 方案已生成', content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`, okText: '好的', }); } catch (e) { modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } finally { setPlanning(false); } }; const totalPlanned = plan.reduce((s, i) => s + i.count, 0); const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0; const suiteTotal = suite?.images.length ?? totalPlanned; const handleGenerate = () => { if (!result) return; if (selectedKeys.size === 0) return modal.warning({ title: '请先在采集图片区勾选参考图' }); if (totalPlanned === 0) return modal.warning({ title: '出图方案的张数都是 0' }); const spec = PLATFORM_SPECS[platform]; modal.confirm({ title: '生成电商套图', content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`, okText: '开始生成', cancelText: '取消', onOk: async () => { setGenerating(true); setSuite(null); try { const payload = buildGeneratePayload( result, selectedKeys, { title: titleEdit, desc: descEdit }, { style_set: styleSet, plan: plan.filter(p => p.count > 0), platform }, ); const { suite_id } = await send<{ suite_id: string }>('generateSuite', { baseUrl: settings.baseUrl, token: settings.token, payload, }); pollSuite(suite_id); } catch (e) { setGenerating(false); modal.error({ title: '提交失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } }, }); }; const handleExport = () => { if (!suite) return; chrome.tabs.create({ url: suiteZipUrl(settings.baseUrl, suite.id) }); }; const toggleKey = (key: string) => { const next = new Set(selectedKeys); if (next.has(key)) next.delete(key); else next.add(key); setSelectedKeys(next); }; const groupImages = (groupKey: string): ImageMaterial[] => result?.images.filter(i => i.groupKey === groupKey) ?? []; const toggleGroup = (groupKey: string, on: boolean) => { const next = new Set(selectedKeys); groupImages(groupKey).forEach(i => on ? next.add(i.key) : next.delete(i.key)); setSelectedKeys(next); }; const setPlanCount = (idx: number, count: number) => { setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p)); }; /** 源站图防盗链时的兜底:走服务端图片代理 */ const proxied = (u: string) => `${settings.baseUrl.replace(/\/$/, '')}/api/proxy-image?url=${encodeURIComponent(u)}`; /** 缩略图三级降级:thumbUrl → 原图 → 服务端代理 */ const onThumbError = (e: React.SyntheticEvent, url: string) => { const el = e.currentTarget; if (el.dataset.step === '1') { el.dataset.step = '2'; el.src = url; } else if (el.dataset.step === '2') { el.dataset.step = '3'; el.src = proxied(url); } }; const settingsPopup = (
setSettings({ ...settings, baseUrl: e.target.value })} onBlur={() => saveSettings(settings)} /> setSettings({ ...settings, token: e.target.value })} onBlur={() => saveSettings(settings)} />
); return (
{/* ── 顶栏 ── */}

电商套图工作台

商品采集 · 套图生成 · 一键导出
{/* ── 目标平台切换(决定文案语言 + 图片比例)── */}
目标平台
{PLATFORM_OPTIONS.map(p => ( setPlatform(p.value)} >{p.label} ))}
{PLATFORM_SPECS[platform].lang}文案 · {PLATFORM_SPECS[platform].ratio} 图片
{/* ── 采集区:左信息 / 右图片,两列等高 ── */}
{!result ? (
点击右上角「快速采集」抓取当前商品页
) : ( <> setTitleEdit(e.target.value)} />
{paramPairs.length > 0 && (
{paramsOpen && ( {paramPairs.slice(0, 30).map((p, i) => ( ))}
{p.key}{p.value}
)}
)}