From b57933e9835d8f2cfaa823f1ce6a12ad6a8a863a Mon Sep 17 00:00:00 2001 From: Joey Date: Sun, 16 Aug 2026 17:32:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E5=BC=80=E5=8F=91=20?= =?UTF-8?q?ozon=20=E7=AB=AF=E4=B8=BB=E4=BD=93=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 +- extension/entrypoints/background.ts | 16 + extension/entrypoints/bridge.content.ts | 36 ++ extension/entrypoints/sidepanel/App.tsx | 394 ++++++++++++++++---- extension/entrypoints/sidepanel/index.html | 76 +++- extension/pnpm-workspace.yaml | 7 +- extension/scripts/verify-1688.mjs | 71 ++++ extension/src/api/client.ts | 81 ++-- extension/src/bridge/read-window.ts | 43 +++ extension/src/collector/1688-state.ts | 171 +++++++++ extension/src/collector/dom.ts | 31 ++ extension/src/collector/merge.ts | 119 ++++++ extension/src/collector/platforms/1688.ts | 118 ++++++ extension/src/collector/platforms/ozon.ts | 160 ++++++++ extension/src/collector/platforms/taobao.ts | 86 +++++ extension/src/collector/scan.ts | 319 +--------------- extension/src/collector/ssr-builder.ts | 20 +- extension/src/collector/taobao-mtop.ts | 191 ++++++++++ extension/src/collector/taobao-state.ts | 137 +++++++ extension/src/collector/text.ts | 21 ++ extension/src/profiles/1688.ts | 40 +- extension/src/profiles/types.ts | 14 +- extension/wxt.config.ts | 5 + server/api/generate.py | 14 +- server/api/suites.py | 9 +- server/db.py | 12 +- server/models.py | 2 + server/schemas.py | 11 +- server/services/generator.py | 40 +- server/services/planner.py | 112 ++++-- server/services/prompt.py | 72 ++-- start.command | 24 ++ 32 files changed, 1960 insertions(+), 512 deletions(-) create mode 100644 extension/entrypoints/bridge.content.ts create mode 100644 extension/scripts/verify-1688.mjs create mode 100644 extension/src/bridge/read-window.ts create mode 100644 extension/src/collector/1688-state.ts create mode 100644 extension/src/collector/merge.ts create mode 100644 extension/src/collector/platforms/1688.ts create mode 100644 extension/src/collector/platforms/ozon.ts create mode 100644 extension/src/collector/platforms/taobao.ts create mode 100644 extension/src/collector/taobao-mtop.ts create mode 100644 extension/src/collector/taobao-state.ts create mode 100755 start.command diff --git a/README.md b/README.md index afe0350..fb53116 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ Chrome 插件 + Python 后端:采集 Ozon / 1688 / 淘宝 / 天猫 商品页 ``` Chrome 插件(WXT + React + antd) Python 后端(FastAPI + SQLite) ┌────────────────────────────┐ ┌──────────────────────────────┐ -│ Side Panel │ │ POST /api/materials │ -│ ① 扫描商品页(四站点) │ ──上传──▶ │ → 落库 + 后台转存图片 │ -│ ② 勾选/编辑素材 │ │ POST /api/products/{id}/suites│ -│ ③ 选风格提交生成 │ ──提交──▶ │ → 套图任务(后台逐张生图) │ -│ ④ 轮询进度 → 导出 ZIP │ ◀─轮询── │ GET /api/suites/{id} │ -└────────────────────────────┘ │ GET /api/suites/{id}/zip │ +│ Side Panel │ │ POST /api/plan │ +│ ① 扫描商品页(四站点) │ ─规划──▶ │ → DeepSeek 出图方案 │ +│ ② 勾选/编辑素材 │ ◀─方案── │ POST /api/generate(无状态) │ +│ ③ 出图方案(默认/AI规划) │ ──提交──▶ │ → 方案展开 → 逐张生图 │ +│ ④ 轮询进度 → 导出 ZIP │ ◀─轮询── │ GET /api/suites/{id}[/zip] │ +└────────────────────────────┘ │ GET /api/proxy-image │ └──────────────────────────────┘ ``` @@ -77,11 +77,11 @@ pnpm build # 产物在 .output/chrome-mv3 | 方法 | 路径 | 说明 | |---|---|---| -| POST | `/api/materials` | 采集上传(文本 + 图片 URL),异步转存 | -| GET | `/api/products` / `/api/products/{id}` | 商品列表/详情 | -| POST | `/api/products/{id}/suites` | 创建套图任务 `{style_set, types, lang, provider?}` | +| POST | `/api/plan` | DeepSeek 出图方案规划(`{texts, sku_variants, image_stats, platform}`) | +| POST | `/api/generate` | 无状态一键生成(`{texts, images, plan, style_set, platform}`,不落商品库) | | GET | `/api/suites/{id}` | 任务状态 + 已生成图 URL | -| GET | `/api/suites/{id}/zip` | 导出 ZIP | +| GET | `/api/suites/{id}/zip` | 导出 ZIP(按方案标题命名) | +| GET | `/api/proxy-image?url=` | 图片代理(绕源站防盗链) | | GET | `/api/health` | 健康检查 + provider 配置状态 | ## 说明与限制 diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index 1dbbf32..5fcfea2 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -29,6 +29,22 @@ export default defineBackground(() => { return true; } + // 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API) + if (msg?.action === 'fetchText') { + const headers: Record = {}; + if (msg.referer) headers['Referer'] = msg.referer; + fetch(msg.url, { + headers, + credentials: msg.credentials ? 'include' : 'omit', + }) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + sendResponse({ ok: true, text: await res.text() }); + }) + .catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) })); + return true; + } + return false; }); }); diff --git a/extension/entrypoints/bridge.content.ts b/extension/entrypoints/bridge.content.ts new file mode 100644 index 0000000..4be3522 --- /dev/null +++ b/extension/entrypoints/bridge.content.ts @@ -0,0 +1,36 @@ +// MAIN world 桥 —— 跑在页面主世界,读取页面 JS 变量(isolated world 读不到)。 +// 协议(对齐竞品 inject.js 模式): +// isolated → MAIN: {type:'sc-bridge-req', requestId, keys: [...]} keys 含 '*' 时返回诊断键列表 +// MAIN → isolated: {type:'sc-bridge-res', requestId, payload: {key: value}} +// MAIN 侧零业务逻辑:只读白名单键、JSON 序列化过滤后回传,不注入任何页面行为。 +export default defineContentScript({ + matches: [ + 'https://*.ozon.ru/*', 'https://*.ozon.kz/*', 'https://*.ozon.by/*', + 'https://detail.1688.com/*', + 'https://item.taobao.com/*', 'https://detail.tmall.com/*', + ], + world: 'MAIN', + main() { + window.addEventListener('message', (ev: MessageEvent) => { + if (ev.source !== window) return; + const d = ev.data as { type?: string; requestId?: string; keys?: string[] } | null; + if (!d || d.type !== 'sc-bridge-req' || !d.requestId || !Array.isArray(d.keys)) return; + + const payload: Record = {}; + if (d.keys.includes('*')) { + // 诊断模式:列出页面上可能有数据的全局键 + payload['__sc_window_keys__'] = Object.keys(window).filter(k => + /^(__|_)?[A-Za-z]/.test(k) && /(context|rawData|ICE|sku|item|g_config|DATA|state)/i.test(k) + ); + } + for (const k of d.keys) { + if (k === '*') continue; + try { + const v = (window as unknown as Record)[k]; + if (v !== undefined) payload[k] = JSON.parse(JSON.stringify(v)); // 过滤函数/循环引用 + } catch { /* 不可序列化的跳过 */ } + } + window.postMessage({ type: 'sc-bridge-res', requestId: d.requestId, payload }, '*'); + }); + }, +}); diff --git a/extension/entrypoints/sidepanel/App.tsx b/extension/entrypoints/sidepanel/App.tsx index d8b953d..0d8b555 100644 --- a/extension/entrypoints/sidepanel/App.tsx +++ b/extension/entrypoints/sidepanel/App.tsx @@ -10,12 +10,12 @@ */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { App as AntApp, ConfigProvider, Popover, Progress } from 'antd'; +import { App as AntApp, ConfigProvider, Popover, Progress, Select } 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, + 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'; @@ -37,17 +37,31 @@ const PLATFORM_LABELS: Record = { 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 SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i; + +/** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */ +async function tryScan(tabId: number): Promise { const [res] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, + target: { tabId }, func: () => (window as any).__SuiteCollector?.scan?.() ?? null, }); - const r = (res?.result ?? null) as ScanResult | null; - if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试'); - return r; + 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 { @@ -62,9 +76,11 @@ function send(action: string, payload: Record): Promise { // ── 小组件 ───────────────────────────────────────────────────────────────── -const Section: React.FC<{ no: string; title: string; extra?: React.ReactNode; children: React.ReactNode }> = - ({ no, title, extra, children }) => ( -
+const Section: React.FC<{ + no: string; title: string; extra?: React.ReactNode; children: React.ReactNode; className?: string; +}> = + ({ no, title, extra, children, className }) => ( +
{no} {title} @@ -95,9 +111,13 @@ const App: React.FC = () => { // 采集 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); @@ -105,34 +125,107 @@ const App: React.FC = () => { const [settings, setSettings] = useState({ baseUrl: 'http://127.0.0.1:3300', token: '' }); // 出图方案 - const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('cn'); + const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon'); const [styleSet, setStyleSet] = useState(1); + /** 生图模型(通义 DashScope,默认 wan2.7-image-pro,与后端 .env 默认一致) */ + const [model, setModel] = useState('wan2.7-image-pro'); + /** 用户改写的风格提示词(按风格 id 存,切风格不丢) */ + const [stylePrompts, setStylePrompts] = 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 pollRef = useRef | null>(null); + /** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */ + const planSeqRef = useRef(0); - // 图片放大预览 - const [previewUrl, setPreviewUrl] = useState(null); + // 图片放大预览(画廊:可左右切换) + 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); }; }, []); - const price = result?.texts.find(t => t.kind === 'price')?.content ?? ''; + // 预览态的键盘导航:← → 切换,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]); + 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 = async () => { + 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 r = await scanActiveTab(); + 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); @@ -141,10 +234,19 @@ const App: React.FC = () => { 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); } }; @@ -169,25 +271,66 @@ const App: React.FC = () => { }, 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 (orig('price')?.content) texts.push({ kind: 'price', content: orig('price')!.content }); + 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('params')?.pairs ?? []).length) texts.push({ kind: 'params', content: '', pairs: orig('params')!.pairs }); + 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; }; - /** AI 智能规划出图方案 */ + /** 提交生成任务(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( + result, selectedKeys, editedTexts(), + { style_set: styleSet, style_prompt: currentStylePrompt, plan: activePlan, platform, model }, + ); + 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( @@ -202,25 +345,34 @@ const App: React.FC = () => { platform, }, }); + if (seq !== planSeqRef.current) return; // 规划已被重新采集重置,丢弃过期响应 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: '好的', - }); + 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 { - setPlanning(false); + 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; const suiteTotal = suite?.images.length ?? totalPlanned; + /** 当前风格生效的提示词:用户改写值 > 该风格默认值 */ + const currentStylePrompt = stylePrompts[styleSet] + ?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? ''; const handleGenerate = () => { if (!result) return; @@ -229,26 +381,9 @@ const App: React.FC = () => { 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} 张。生成需要几分钟,可在下方查看进度。`, + content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${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: '知道了' }); - } - }, + onOk: () => startGenerate(), }); }; @@ -266,6 +401,13 @@ const App: React.FC = () => { const groupImages = (groupKey: string): ImageMaterial[] => result?.images.filter(i => i.groupKey === groupKey) ?? []; + /** 预览用的全量图序列(主图→SKU→详情,与展示顺序一致) */ + const collectedPreviewList: string[] = result + ? (['main', 'sku', 'detail'] 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)); @@ -319,21 +461,21 @@ const App: React.FC = () => { -
{/* ── 目标平台切换(决定文案语言 + 图片比例)── */}
目标平台 -
+
{PLATFORM_OPTIONS.map(p => ( - setPlatform(p.value)} - >{p.label} + >{p.label} ))}
@@ -360,7 +502,7 @@ const App: React.FC = () => {
- + setPriceEdit(e.target.value)} />
@@ -369,6 +511,32 @@ const App: React.FC = () => {
+ +
+ 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 && (
)} -