/** * 电商套图工作台 - 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, Select } from 'antd'; import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons'; import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import { cleanFilename } from '../../src/collector/url'; import { buildGeneratePayload, downloadSuiteZip, uploadImage, exportImages, DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, 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: '淘宝/天猫', }; /** 支持的站点(用于判断刷新是否有意义) */ const SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i; /** 页内悬浮面板模式(作为 iframe 嵌在商品页里运行);独立 Side Panel 打开时为 false */ const IN_PAGE = window.self !== window.top; /** 通知宿主页收起悬浮面板 */ function closePanel(): void { window.parent.postMessage({ type: 'sc-panel-close' }, '*'); } /** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */ async function tryScan(tabId: number): Promise { const [res] = await chrome.scripting.executeScript({ target: { tabId }, func: () => (window as any).__SuiteCollector?.scan?.() ?? null, }); return (res?.result ?? null) as ScanResult | null; } /** 等待 tab 加载完成(需在 reload 前注册监听),超时放行 */ function waitForTabComplete(tabId: number, timeoutMs = 30_000): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs); function listener(id: number, info: chrome.tabs.TabChangeInfo) { if (id === tabId && info.status === 'complete') { clearTimeout(timer); chrome.tabs.onUpdated.removeListener(listener); resolve(); } } chrome.tabs.onUpdated.addListener(listener); }); } 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; className?: string; }> = ({ no, title, extra, children, className }) => (
{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 [retrying, setRetrying] = useState(false); const [result, setResult] = useState(null); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [titleEdit, setTitleEdit] = useState(''); const [priceEdit, setPriceEdit] = useState(''); const [dimsEdit, setDimsEdit] = useState<{ l: string; w: string; h: string }>({ l: '', w: '', h: '' }); const [weightEdit, setWeightEdit] = useState(''); const [descEdit, setDescEdit] = useState(''); const [paramsOpen, setParamsOpen] = useState(false); // 手动上传图片(补充参考图,独立「upload」分组) const [uploadedImages, setUploadedImages] = useState([]); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const uploadSeqRef = useRef(0); // 服务端 const [settings, setSettings] = useState({ baseUrl: 'http://127.0.0.1:3300', token: '', watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 } }); // 出图方案 const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon'); const [styleSet, setStyleSet] = useState(1); /** 生图模型(默认 gpt-image-2-vip,走 RightAPI provider) */ const [model, setModel] = useState('gpt-image-2-vip'); /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ const [stylePrompts, setStylePrompts] = useState>({}); /** 生图要求(最高优先级,强制约束,覆盖其他设定) */ const [requirements, setRequirements] = useState(''); 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 [planThenGenerate, setPlanThenGenerate] = useState(false); // 生成 const [suite, setSuite] = useState(null); const [generating, setGenerating] = useState(false); // 导出采集图片 const [exporting, setExporting] = useState(false); // 导出生成结果 ZIP const [exportingZip, setExportingZip] = useState(false); const pollRef = useRef | null>(null); /** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */ const planSeqRef = useRef(0); // 图片放大预览(画廊:可左右切换) const [preview, setPreview] = useState<{ list: string[]; index: number } | null>(null); const openPreview = (list: string[], index: number) => { if (list.length === 0) return; setPreview({ list, index: Math.max(0, Math.min(index, list.length - 1)) }); }; const previewPrev = () => setPreview(p => p && { ...p, index: (p.index - 1 + p.list.length) % p.list.length }); const previewNext = () => setPreview(p => p && { ...p, index: (p.index + 1) % p.list.length }); useEffect(() => { loadSettings().then(setSettings); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); // 预览态的键盘导航:← → 切换,Esc 关闭 useEffect(() => { if (!preview) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') previewPrev(); else if (e.key === 'ArrowRight') previewNext(); else if (e.key === 'Escape') setPreview(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [preview]); // 页内面板模式:Esc 收起面板(预览打开时优先关预览,由上面的监听处理) useEffect(() => { if (!IN_PAGE) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !preview) closePanel(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [preview]); const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? ''; const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? ''; const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? ''; const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? []; const handleScan = () => { // 生成中:二次确认(后台任务会继续完成,但本面板停止跟踪) if (generating) { modal.confirm({ title: '有正在生成的任务', content: '重新采集将清空当前采集结果与出图方案,并停止在本面板跟踪正在生成的任务(已提交的后台生成会继续完成,但此处将看不到进度和导出入口)。确定要重新采集吗?', okText: '继续采集', cancelText: '取消', onOk: () => doScan(), }); return; } doScan(); }; const doScan = async () => { // 规划中:直接重置规划并继续采集(递增序号,迟到的规划响应会被丢弃) if (planning) { planSeqRef.current++; setPlanning(false); } // 确认路径下停止对旧生成任务的本地跟踪(后台任务继续,不受影响) if (generating) { stopPolling(); setGenerating(false); } setScanning(true); setRetrying(false); try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) throw new Error('未找到活动标签页'); // 首次尝试 let r = await tryScan(tab.id); // 失败:支持站点上大概率是内容脚本未就绪 → 自动刷新重试一次(仅一次,防死循环) if (!r) { if (!SUPPORTED_URL_RE.test(tab.url ?? '')) { throw new Error('当前页面不是支持的商品详情页(支持 Ozon / 1688 / 淘宝 / 天猫)'); } setRetrying(true); const waitLoaded = waitForTabComplete(tab.id); // 先注册监听再刷新,避免错过事件 await chrome.tabs.reload(tab.id); await waitLoaded; await new Promise(res => setTimeout(res, 1500)); // 给内容脚本注入留时间 r = await tryScan(tab.id); if (!r) { throw new Error('采集失败:已自动刷新重试仍无法采集,请确认当前页面是商品详情页后手动重试'); } } 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 ?? ''); setPriceEdit(r.texts.find(t => t.kind === 'price')?.content ?? ''); // 从参数表初始化尺寸/重量(编辑后随生成/规划请求覆盖回参数) const pairs = r.texts.find(t => t.kind === 'params')?.pairs ?? []; const dimPair = pairs.find(p => /尺寸|长宽高/i.test(p.key) && (String(p.value).match(/\d+(\.\d+)?/g) ?? []).length >= 3); const nums = dimPair ? (String(dimPair.value).match(/\d+(\.\d+)?/g) ?? []) : []; setDimsEdit(nums.length >= 3 ? { l: nums[0], w: nums[1], h: nums[2] } : { l: '', w: '', h: '' }); const weightPair = pairs.find(p => /重量/i.test(p.key)); setWeightEdit(weightPair ? String(weightPair.value) : ''); } catch (e) { modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } finally { setScanning(false); setRetrying(false); } }; const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; const pollSuite = useCallback((suiteId: string) => { stopPolling(); const startedAt = Date.now(); let failCount = 0; // 连续轮询失败计数(容忍瞬时抖动,连续 3 次才判定后端不可达) let timeoutWarned = false; pollRef.current = setInterval(async () => { try { const s = await send('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId }); setSuite(s); failCount = 0; // 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断(如后端重启) const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000; if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) { timeoutWarned = true; modal.warning({ title: '任务耗时异常', content: '生成耗时远超预期,任务可能已中断。可稍等片刻观察,或刷新页面后重新生成。', okText: '知道了', }); } if (['done', 'partial', 'failed'].includes(s.status)) { stopPolling(); setGenerating(false); if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: s.error || '可重试或更换风格重新生成', okText: '知道了' }); if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' }); } } catch (e) { failCount += 1; if (failCount >= 3) { stopPolling(); setGenerating(false); modal.error({ title: '无法连接后端', content: '连续查询生成进度失败,已停止跟踪。若刚重启过后端(./start.command),任务已被中断,请重新生成。', okText: '知道了', }); } } }, 3000); }, [settings.baseUrl, settings.token, modal]); /** 当前编辑后的文本素材(规划与生成共用;尺寸/重量编辑值覆盖回参数表) */ const editedTexts = () => { if (!result) return []; const orig = (kind: string) => result.texts.find(t => t.kind === kind); let pairs = [...(orig('params')?.pairs ?? [])]; // 尺寸/重量覆盖:先移除旧的同义项,再写入编辑值(有值才写) const dropRe = [/尺寸|长宽高/i, /重量/i]; pairs = pairs.filter(p => !dropRe.some(re => re.test(p.key))); const { l, w, h } = dimsEdit; if (l || w || h) pairs.push({ key: '产品尺寸', value: `${l || '?'}×${w || '?'}×${h || '?'}` }); if (weightEdit) pairs.push({ key: '重量', value: weightEdit }); 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 (priceEdit || orig('price')?.content) texts.push({ kind: 'price', content: priceEdit || orig('price')!.content }); if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content }); if (orig('sales')?.content) texts.push({ kind: 'sales', content: orig('sales')!.content }); if (orig('shop')?.content) texts.push({ kind: 'shop', content: orig('shop')!.content }); if (pairs.length) texts.push({ kind: 'params', content: '', 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; }; /** 提交生成任务(planOverride:刚规划出来的方案,避免闭包读到旧 state) */ const startGenerate = async (planOverride?: PlanItem[]): Promise => { if (!result) return; if (selectedKeys.size === 0) { modal.warning({ title: '请先在采集图片区勾选参考图' }); return; } const activePlan = (planOverride ?? plan).filter(p => p.count > 0); if (activePlan.length === 0) { modal.warning({ title: '出图方案的张数都是 0' }); return; } setGenerating(true); setSuite(null); try { const payload = buildGeneratePayload( allImages, selectedKeys, editedTexts(), { style_set: styleSet, style_prompt: currentStylePrompt, requirements: requirements.trim() || null, plan: activePlan, platform, model, // 水印开启才下发,关闭时不带(服务端按无水印处理) watermark: settings.watermark.enabled ? settings.watermark : undefined }, ); 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: '知道了' }); } }; /** AI 智能规划出图方案;勾选「规划并生成」时规划完成自动开始生成 */ const handlePlan = async () => { if (!result) return modal.warning({ title: '请先采集商品页' }); const seq = ++planSeqRef.current; 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, requirements: requirements.trim() || null, }, }); if (seq !== planSeqRef.current) return; // 规划已被重新采集重置,丢弃过期响应 setPlan(data.items); setPlanSource('ai'); setPlanSummary(data.summary); if (planThenGenerate) { await startGenerate(data.items); // 规划完成 → 直接生成(用刚返回的方案,不弹确认) } else { const total = data.items.reduce((s, i) => s + i.count, 0); modal.info({ title: 'AI 方案已生成', content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`, okText: '好的', }); } } catch (e) { if (seq !== planSeqRef.current) return; // 已重置,过期错误不弹窗 modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } finally { if (seq === planSeqRef.current) setPlanning(false); } }; const totalPlanned = plan.reduce((s, i) => s + i.count, 0); const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0; // 分母用后端返回的计划总张数:images 是逐张落库的,生成中 length 永远等于已完成数 const suiteTotal = suite?.total ?? totalPlanned; /** 当前风格生效的提示词:用户改写值 > 该风格默认值 */ const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? ''; 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}),模型「{model}」,风格「 {STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考图{' '} {selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。
), okText: '开始生成', cancelText: '取消', onOk: () => startGenerate(), }); }; const handleExport = async () => { if (!suite) return; setExportingZip(true); try { const blob = await downloadSuiteZip(settings.baseUrl, settings.token, suite.id); const objectUrl = URL.createObjectURL(blob); try { await chrome.downloads.download({ url: objectUrl, filename: `${cleanFilename(productTitle) || '套图'}.zip`, saveAs: false, }); } finally { // 延迟释放,给下载任务足够时间读取 blob setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); } } catch (e) { modal.error({ title: '导出失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } finally { setExportingZip(false); } }; const toggleKey = (key: string) => { const next = new Set(selectedKeys); if (next.has(key)) next.delete(key); else next.add(key); setSelectedKeys(next); }; /** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */ const allImages: ImageMaterial[] = result ? [...result.images, ...uploadedImages] : uploadedImages; /** 导出文件名的商品标题(编辑态优先,回退采集标题) */ const productTitle = titleEdit.trim() || result?.texts.find(t => t.kind === 'title')?.content?.trim() || ''; const groupImages = (groupKey: string): ImageMaterial[] => allImages.filter(i => i.groupKey === groupKey); /** 下载采集图片:只打包已勾选的图片(后端按分组名建文件夹),前端经 chrome.downloads 落盘 */ const handleExportImages = async () => { const imgs = allImages.filter(i => i.type === 'img' && selectedKeys.has(i.key)); if (imgs.length === 0) { modal.warning({ title: '请先勾选要下载的图片' }); return; } setExporting(true); try { const blob = await exportImages(settings.baseUrl, settings.token, { title: productTitle, images: imgs.map(i => ({ url: i.url, groupName: i.groupName, variantName: i.variantName ?? null, key: i.key, })), }); const objectUrl = URL.createObjectURL(blob); try { await chrome.downloads.download({ url: objectUrl, filename: `${cleanFilename(productTitle) || '采集图片'}.zip`, saveAs: false, }); } finally { // 延迟释放,给下载任务足够时间读取 blob setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); } } catch (e) { modal.error({ title: '导出失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' }); } finally { setExporting(false); } }; /** 预览用的全量图序列(主图→SKU→详情→上传,与展示顺序一致) */ const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const) .flatMap(g => groupImages(g).map(i => i.url)); /** 生成结果的预览序列(仅成功的图) */ const resultPreviewList: string[] = suite ? suite.images.filter(i => i.status === 'ok').map(i => i.url) : []; 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)); }; /** 全部方案是否都启用(任一为 0 即视为未全选,复选框随方案状态联动) */ const planAllEnabled = plan.every(p => p.count >= 1); /** 批量开关:勾选 → 全部至少 1 张(用户已设 >1 的保留原数量);取消勾选 → 全部 0,方便单独勾选一种方案 */ const togglePlanAll = (on: boolean) => { setPlan(prev => prev.map(p => on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 })); }; /** 点击方案行:勾选(0 → 1)/ 取消勾选(>0 → 0),配合"全部方案"开关单独选一种 */ const togglePlanRow = (idx: number) => { setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p)); }; /** 手动上传:点击触发隐藏的 file input */ const handleUpload = () => fileRef.current?.click(); const onFilesChange = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []); e.target.value = ''; // 清空以便再次选择同名文件 if (files.length === 0) return; setUploading(true); const added: ImageMaterial[] = []; for (const f of files) { try { const { url } = await uploadImage(settings.baseUrl, settings.token, f); added.push({ key: `upload-${String(++uploadSeqRef.current).padStart(3, '0')}`, groupKey: 'upload', groupName: '手动上传', url, thumbUrl: url, index: added.length, type: 'img', }); } catch (err) { modal.error({ title: '上传失败', content: `${f.name}:${err instanceof Error ? err.message : String(err)}`, okText: '知道了' }); } } if (added.length > 0) { setUploadedImages(prev => [...prev, ...added]); setSelectedKeys(prev => { const next = new Set(prev); added.forEach(a => next.add(a.key)); return next; }); modal.success({ title: `已上传 ${added.length} 张图片` }); } setUploading(false); }; /** 源站图防盗链时的兜底:走服务端图片代理 */ 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 patchWatermark = (patch: Partial) => { const next = { ...settings, watermark: { ...settings.watermark, ...patch } }; setSettings(next); saveSettings(next); }; const settingsPopup = (
setSettings({ ...settings, baseUrl: e.target.value })} onBlur={() => saveSettings(settings)} /> setSettings({ ...settings, token: e.target.value })} onBlur={() => saveSettings(settings)} />
{settings.watermark.enabled && ( <>
patchWatermark({ type: 'image' })}>图片 patchWatermark({ type: 'text' })}>文字
{settings.watermark.type === 'text' && ( setSettings({ ...settings, watermark: { ...settings.watermark, text: e.target.value } })} onBlur={() => saveSettings(settings)} /> )} setSettings({ ...settings, watermark: { ...settings.watermark, opacity: Number(e.target.value) || 1 } })} onBlur={() => saveSettings(settings)} />
位置固定右下角;重新生成后生效,已生成的图不变
)}
); return (
{/* ── 顶栏 ──(页内模式的收起 ✕ 按钮在宿主层渲染:要骑在面板边缘、一半在面板外, iframe 会裁剪内容,按钮必须在 iframe 外;Esc 关闭仍走 closePanel) */}

电商套图工作台

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