/** * 04 图片与 AI 生图(docs/v2.1/image-suite.md): * 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。 * 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。 */ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress, Row, Segmented, Select, Space, Tag, Typography, Upload, } from 'antd'; import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons'; import { ProductAsset, ProductDetail } from '@/services/product'; import { apiErrorMessage } from '@/services/api'; import { DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS, STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload, applyWatermarkToAssets, cancelSuite, deleteAsset, exportImage, exportImages, generateSuite, getSuite, listProductSuites, listProductWatermarkJobs, planSuite, removeWatermarkFromAssets, retrySuite, retrySuiteItem, uploadProductAsset, type PlanItem, } from '@/services/suite'; import { cleanFilename, downloadBlob } from '@/utils/file'; import AiImageGenModal from './AiImageGenModal'; import { CollectionImageGroups, FailedSuiteImages, GeneratedImageGrid } from './TrialImageGrids'; import TrialWatermarkSettings from './TrialWatermarkSettings'; import TrialVideoMaker from './TrialVideoMaker'; import { DISPLAY_GROUPS, GROUP_LABELS, type ImagePreviewConfig, type ResultImage } from './trialImageTypes'; const { Text } = Typography; const WATERMARK_STORAGE_KEY = 'trialWatermark'; /** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */ function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) { const [text, setText] = useState(String(value)); useEffect(() => setText(String(value)), [value]); const clamp = (v: number) => Math.max(0, Math.min(5, Number.isNaN(v) ? 0 : v)); const commit = () => onChange(clamp(parseInt(text, 10))); const btnBase: CSSProperties = { width: 26, height: 26, padding: 0, border: 'none', background: '#f5f5f5', cursor: 'pointer', fontSize: 15, lineHeight: '26px', color: '#555', }; return ( setText(e.target.value.replace(/[^\d]/g, ''))} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} title="可直接输入数量(0-5)" style={{ width: 34, height: 26, textAlign: 'center', border: 'none', outline: 'none', fontSize: 13, fontWeight: 600, color: value === 0 ? '#bbb' : '#333', background: '#fff', }} /> ); } interface Props { product: ProductDetail; assets: ProductAsset[]; onRefreshAssets: () => void; } /** 商品信息 → 规划/生成的文本素材(包装字段覆盖回参数表,对齐 image-suite-studio editedTexts) */ function buildSuiteTexts(product: ProductDetail): SuiteTextPayload[] { const raw = (product.raw ?? {}) as Record; const texts: SuiteTextPayload[] = []; const title = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim(); // 已回填的俄文商品名优先作为生图文字上下文;中文采集名仅保留为商品事实参考。 if (/[\u0400-\u04FF]/.test(product.name || '')) texts.push({ kind: 'title_ru', content: product.name.trim() }); if (title) texts.push({ kind: 'title', content: title }); if (raw.price) texts.push({ kind: 'price', content: String(raw.price) }); (['brand', 'sales', 'shop'] as const).forEach((k) => { const v = raw[k]; if (v) texts.push({ kind: k, content: String(v) }); }); const sellingPoint = (raw.selling_point as string) ?? (raw.sellingPoints as string); if (sellingPoint) texts.push({ kind: 'selling_point', content: sellingPoint }); let pairs = Array.isArray(raw.params) ? [...(raw.params as Array<{ key: string; value: string }>)] : []; pairs = pairs.filter((p) => !/尺寸|长宽高|重量/i.test(p.key)); if (product.weight != null && product.weight > 0) pairs.push({ key: '重量', value: `${product.weight}g` }); const scale = product.dimension_unit === 'cm' ? 1 : 0.1; if (product.depth != null && product.width != null && product.height != null) { const d = { l: product.depth * scale, w: product.width * scale, h: product.height * scale }; pairs.push({ key: '产品尺寸', value: `${d.l}×${d.w}×${d.h}` }); } if (pairs.length) texts.push({ kind: 'params', content: '', pairs }); if (raw.desc) texts.push({ kind: 'desc', content: String(raw.desc) }); return texts; } function loadWatermark(): WatermarkPayload { try { const s = localStorage.getItem(WATERMARK_STORAGE_KEY); if (s) return { ...DEFAULT_WATERMARK, ...(JSON.parse(s) as WatermarkPayload) }; } catch { /* 忽略坏数据 */ } return DEFAULT_WATERMARK; } export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Props) { // message / modal 用 App 上下文版本:静态方法吃不到 ConfigProvider 动态主题(控制台告警) const { modal, message } = AntdApp.useApp(); // 素材选择(默认全部不勾选,用户按需勾选参考底图;SKU 规格名供 AI 规划 variant 绑定,与勾选无关) const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); /** 上传成功待自动勾选的 asset id */ const pendingSelectRef = useRef>(new Set()); const [uploadingCount, setUploadingCount] = useState(0); // 出图方案 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 [styleSet, setStyleSet] = useState(1); const [stylePrompts, setStylePrompts] = useState>({}); const [requirements, setRequirements] = useState(''); const [model, setModel] = useState(DEFAULT_IMAGE_MODEL); const [watermark, setWatermark] = useState(loadWatermark); // 生成 const [suite, setSuite] = useState(null); const [generating, setGenerating] = useState(false); const [exportingResult, setExportingResult] = useState(false); const [exportingImages, setExportingImages] = useState(false); /** 生图结果选择:套图当次结果与已落库的单张/套图历史共用一套选择状态。 */ const [selectedResultKeys, setSelectedResultKeys] = useState>(() => new Set()); /** 删除后在本次任务的内存结果中同步隐藏,避免刷新前仍可导出已删图片。 */ const [hiddenResultKeys, setHiddenResultKeys] = useState>(() => new Set()); const [downloadingResultKeys, setDownloadingResultKeys] = useState>(() => new Set()); const [applyingWatermark, setApplyingWatermark] = useState(false); const [watermarkJob, setWatermarkJob] = useState(null); const pollTimerRef = useRef | null>(null); const pollAbortRef = useRef(null); const pollRunRef = useRef(0); const watermarkPollTimerRef = useRef | null>(null); const watermarkPollAbortRef = useRef(null); const watermarkPollRunRef = useRef(0); /** 规划请求序号:重置后丢弃迟到的过期响应 */ const planSeqRef = useRef(0); // 单张 AI 生图弹窗(afterAssetId:结果插到该素材之后,方便对比) const [genModal, setGenModal] = useState<{ url: string; name: string; afterAssetId?: string } | null>(null); // 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示 const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]); const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]); const groupImages = (g: string) => imgs.filter((a) => a.group_key === g); const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url; /** 生成时始终以无水印原图为参考;展示/下载仍使用 current(stored_url)。 */ const assetOriginalUrl = (a: ProductAsset) => a.original_url || assetUrl(a); // 图名:取 URL 路径末段(采集图为源站文件名;上传图为落盘文件名),无路径时回退规格名 const assetName = (a: ProductAsset): string => { const raw = a.source_url || a.stored_url || ''; try { const seg = decodeURIComponent(new URL(raw, window.location.origin).pathname.split('/').pop() || ''); return seg || a.variant_name || '未命名'; } catch { return a.variant_name || '未命名'; } }; const rawObj = (product.raw ?? {}) as Record; const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim(); const resultImages = useMemo(() => { const suiteImages = (suite?.images ?? []) .filter((img) => img.status === 'done') .map((img, index) => { const asset = generatedAssets.find((a) => assetUrl(a) === img.url); return { key: `suite-${suite?.id ?? 'current'}-${index}`, url: img.url, name: img.name, groupName: '套图结果', variantName: null as string | null, assetId: asset?.id, source: 'suite' as const, }; }); const historyImages = generatedAssets.map((asset) => ({ key: `asset-${asset.id}`, url: assetUrl(asset), name: assetName(asset), groupName: '单张生图', variantName: asset.variant_name, assetId: asset.id, source: 'history' as const, createdAt: asset.created_at, })); return [...suiteImages, ...historyImages].filter((img) => !hiddenResultKeys.has(img.key)); }, [generatedAssets, hiddenResultKeys, suite]); const suiteResultImages = resultImages.filter((img) => img.source === 'suite'); const historyResultImages = resultImages.filter((img) => img.source === 'history'); const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length; const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId); const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => img.assetId!))); const selectedCollectionWatermarkAssetIds = imgs .filter((asset) => selectedKeys.has(asset.id) && asset.type === 'img' && !!assetUrl(asset)) .map((asset) => asset.id); const assetById = new Map(assets.map((asset) => [asset.id, asset])); const selectedWatermarkedResultIds = selectedWatermarkAssetIds.filter((id) => assetById.get(id)?.watermark_enabled); const selectedWatermarkedCollectionIds = selectedCollectionWatermarkAssetIds.filter((id) => assetById.get(id)?.watermark_enabled); // 上传完成的素材自动勾选(素材列表刷新后合并) useEffect(() => { if (pendingSelectRef.current.size === 0) return; const next = new Set(selectedKeys); let touched = false; pendingSelectRef.current.forEach((id) => { if (assets.some((a) => a.id === id) && !next.has(id)) { next.add(id); touched = true; } }); pendingSelectRef.current.clear(); if (touched) setSelectedKeys(next); // eslint-disable-next-line react-hooks/exhaustive-deps }, [assets]); const stopPolling = () => { pollRunRef.current += 1; if (pollTimerRef.current) clearTimeout(pollTimerRef.current); pollTimerRef.current = null; pollAbortRef.current?.abort(); pollAbortRef.current = null; }; const toggleKey = (key: string) => { setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const toggleGroup = (g: string, on: boolean) => { setSelectedKeys((prev) => { const next = new Set(prev); groupImages(g).forEach((a) => (on ? next.add(a.id) : next.delete(a.id))); return next; }); }; const uploadButton = ( { setUploadingCount((c) => c + 1); try { const r = await uploadProductAsset(product.id, file as File); pendingSelectRef.current.add(r.asset_id); onSuccess?.(r); } catch (e) { message.error(`${(file as File).name}:${apiErrorMessage(e)}`); onError?.(e as Error); } finally { setUploadingCount((c) => c - 1); onRefreshAssets(); } }} > ); // ── 下载采集图片(勾选图打包 ZIP) ───────────────────────────── const handleExportImages = async () => { const selected = imgs.filter((a) => selectedKeys.has(a.id)); if (selected.length === 0) { message.warning('请先勾选要下载的图片'); return; } setExportingImages(true); try { const blob = await exportImages({ title: productTitle, images: selected.map((a) => ({ url: assetUrl(a), groupName: GROUP_LABELS[a.group_key] ?? a.group_key, variantName: a.variant_name, key: a.id, })), }); downloadBlob(blob, `${cleanFilename(productTitle) || '采集图片'}.zip`); } catch (e) { message.error(apiErrorMessage(e)); } finally { setExportingImages(false); } }; // ── AI 智能规划 ───────────────────────────────────────────── const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? ''; const totalPlanned = plan.reduce((s, i) => s + i.count, 0); const pollSuite = (suiteId: string) => { stopPolling(); const runId = ++pollRunRef.current; const controller = new AbortController(); pollAbortRef.current = controller; const startedAt = Date.now(); let failCount = 0; let timeoutWarned = false; try { sessionStorage.setItem(`trialSuiteTask:${product.id}`, suiteId); } catch { /* 私密模式等不可写时不影响当前轮询 */ } const clearSavedTask = () => { try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* 忽略 sessionStorage 不可用 */ } }; const finish = () => { if (runId !== pollRunRef.current) return; if (pollTimerRef.current) clearTimeout(pollTimerRef.current); pollTimerRef.current = null; pollAbortRef.current = null; setGenerating(false); }; const schedule = (delayMs: number) => { pollTimerRef.current = setTimeout(() => void poll(), delayMs); }; const poll = async () => { if (runId !== pollRunRef.current || controller.signal.aborted) return; try { const s = await getSuite(suiteId, { signal: controller.signal }); if (runId !== pollRunRef.current) return; setSuite(s); failCount = 0; // 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断 const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000; if (!timeoutWarned && ['running', 'queued'].includes(s.status) && Date.now() - startedAt > budgetMs) { timeoutWarned = true; message.warning('任务耗时异常,可能已中断(如服务端重启),可稍后观察或重新生成'); } if (['done', 'partial', 'failed', 'cancelled'].includes(s.status)) { finish(); clearSavedTask(); if (s.status === 'partial') message.warning(s.error || '部分生成失败,可重试或更换风格重新生成'); if (s.status === 'failed') message.error(s.error || '生成失败'); if (s.status === 'cancelled') message.info('生成任务已取消'); // Phase B 起服务端会把生成图回写素材(generated 组) if (s.status !== 'failed') onRefreshAssets(); return; } // 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。 schedule(3000); } catch { if (controller.signal.aborted || runId !== pollRunRef.current) return; failCount += 1; if (failCount >= 3) { finish(); // 保留 sessionStorage 任务 ID;刷新页面后可再尝试恢复跟踪。 message.error('连续查询生成进度失败,已停止自动跟踪;刷新页面可尝试恢复。若服务端刚重启,请重新生成'); return; } // 指数退避:6 秒、12 秒;成功一次后恢复为 3 秒。 schedule(Math.min(3000 * (2 ** failCount), 12_000)); } }; schedule(0); }; const stopWatermarkPolling = () => { watermarkPollRunRef.current += 1; if (watermarkPollTimerRef.current) clearTimeout(watermarkPollTimerRef.current); watermarkPollTimerRef.current = null; watermarkPollAbortRef.current?.abort(); watermarkPollAbortRef.current = null; }; /** 批量水印与 AI 图片任务共用同一查询接口,但用独立轮询避免相互中断。 */ const pollWatermarkJob = (jobId: string) => { stopWatermarkPolling(); const runId = ++watermarkPollRunRef.current; const controller = new AbortController(); watermarkPollAbortRef.current = controller; let failCount = 0; try { sessionStorage.setItem(`trialWatermarkTask:${product.id}`, jobId); } catch { /* 私密模式等不可写时不影响当前轮询 */ } const clearSavedTask = () => { try { sessionStorage.removeItem(`trialWatermarkTask:${product.id}`); } catch { /* 忽略 sessionStorage 不可用 */ } }; const finish = () => { if (runId !== watermarkPollRunRef.current) return; if (watermarkPollTimerRef.current) clearTimeout(watermarkPollTimerRef.current); watermarkPollTimerRef.current = null; watermarkPollAbortRef.current = null; setApplyingWatermark(false); }; const schedule = (delayMs: number) => { watermarkPollTimerRef.current = setTimeout(() => void poll(), delayMs); }; const poll = async () => { if (runId !== watermarkPollRunRef.current || controller.signal.aborted) return; try { const task = await getSuite(jobId, { signal: controller.signal }); if (runId !== watermarkPollRunRef.current) return; setWatermarkJob(task); failCount = 0; if (['done', 'partial', 'failed', 'cancelled'].includes(task.status)) { finish(); clearSavedTask(); if (task.status === 'done') message.success(`水印任务已完成:${task.completed ?? task.total ?? 0} 张`); if (task.status === 'partial') message.warning(`水印任务部分完成:${task.completed ?? 0} 张成功、${task.failed ?? 0} 张失败`); if (task.status === 'failed') message.error(task.error || '水印任务失败'); if (task.status === 'cancelled') message.info('水印任务已取消'); if (task.status !== 'cancelled') onRefreshAssets(); return; } // 上一轮完成后再安排下一轮,避免网络变慢时重叠请求。 schedule(2000); } catch { if (controller.signal.aborted || runId !== watermarkPollRunRef.current) return; failCount += 1; if (failCount >= 3) { finish(); message.error('连续查询水印进度失败,刷新页面可继续恢复跟踪'); return; } schedule(Math.min(2000 * (2 ** failCount), 12_000)); } }; schedule(0); }; // 刷新试算页后自动恢复当前商品仍在运行的套图任务;服务重启导致的任务丢失会按常规错误提示处理。 useEffect(() => { let savedTaskId = ''; try { savedTaskId = sessionStorage.getItem(`trialSuiteTask:${product.id}`) || ''; } catch { /* sessionStorage 不可用时跳过恢复 */ } if (savedTaskId) { setGenerating(true); pollSuite(savedTaskId); } else { void listProductSuites(product.id, true).then((active) => { const task = active[0]; if (!task) return; setSuite(task); setGenerating(true); pollSuite(task.id); }).catch(() => { /* 网络失败时不打断试算页使用 */ }); } return () => stopPolling(); // 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [product.id]); // 水印任务单独恢复:套图列表刻意排除了 watermark_batch,防止两个区域互相抢进度状态。 useEffect(() => { setWatermarkJob(null); let savedTaskId = ''; try { savedTaskId = sessionStorage.getItem(`trialWatermarkTask:${product.id}`) || ''; } catch { /* sessionStorage 不可用时跳过恢复 */ } if (savedTaskId) { setApplyingWatermark(true); pollWatermarkJob(savedTaskId); } else { void listProductWatermarkJobs(product.id, true).then((active) => { const task = active[0]; if (!task) return; setWatermarkJob(task); setApplyingWatermark(true); pollWatermarkJob(task.id); }).catch(() => { /* 网络失败时不打断试算页使用 */ }); } return () => stopWatermarkPolling(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [product.id]); const startGenerate = async (planOverride?: PlanItem[]): Promise => { if (selectedKeys.size === 0) { message.warning('请先在采集图片区勾选参考底图'); return; } const activePlan = (planOverride ?? plan).filter((p) => p.count > 0); if (activePlan.length === 0) { message.warning('出图方案的张数都是 0'); return; } setGenerating(true); setSuite(null); try { const { suite_id } = await generateSuite({ product_id: product.id, texts: buildSuiteTexts(product), images: imgs .filter((a) => selectedKeys.has(a.id)) .map((a) => ({ url: assetOriginalUrl(a), group_key: a.group_key, variant_name: a.variant_name })), style_set: styleSet, style_prompt: currentStylePrompt || null, requirements: requirements.trim() || null, plan: activePlan, platform: 'ozon', model, // 水印开启才下发,关闭时不带(服务端按无水印处理) watermark: watermark.enabled ? watermark : undefined, }); pollSuite(suite_id); } catch (e) { setGenerating(false); message.error(apiErrorMessage(e)); } }; const handleCancelSuite = async () => { if (!suite || ['done', 'partial', 'failed', 'cancelled'].includes(suite.status)) return; try { const next = await cancelSuite(suite.id); setSuite(next); stopPolling(); setGenerating(false); try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* ignore */ } message.info('已取消生成任务;已完成的图片会保留在生成记录中'); } catch (e) { message.error(apiErrorMessage(e)); } }; const handleRetrySuite = async () => { if (!suite) return; try { const next = await retrySuite(suite.id); setSuite(next); setGenerating(true); pollSuite(next.id); message.info('失败图片已重新加入队列'); } catch (e) { message.error(apiErrorMessage(e)); } }; const handleCancelWatermarkJob = async () => { if (!watermarkJob || ['done', 'partial', 'failed', 'cancelled'].includes(watermarkJob.status)) return; try { const next = await cancelSuite(watermarkJob.id); setWatermarkJob(next); stopWatermarkPolling(); setApplyingWatermark(false); try { sessionStorage.removeItem(`trialWatermarkTask:${product.id}`); } catch { /* ignore */ } message.info('已取消水印任务;已经完成的图片会保留水印版'); } catch (e) { message.error(apiErrorMessage(e)); } }; const handleRetryWatermarkJob = async () => { if (!watermarkJob) return; try { const next = await retrySuite(watermarkJob.id); setWatermarkJob(next); setApplyingWatermark(true); pollWatermarkJob(next.id); message.info('失败图片已重新加入水印队列'); } catch (e) { message.error(apiErrorMessage(e)); } }; const handleRetryWatermarkItem = async (itemId: string) => { if (!watermarkJob) return; try { const next = await retrySuiteItem(watermarkJob.id, itemId); setWatermarkJob(next); setApplyingWatermark(true); pollWatermarkJob(next.id); message.info('该图片已重新加入水印队列'); } catch (e) { message.error(apiErrorMessage(e)); } }; const handlePlan = async () => { const seq = ++planSeqRef.current; setPlanning(true); try { const skuVariants = Array.from( new Set(assets.filter((a) => a.group_key === 'sku' && a.variant_name).map((a) => a.variant_name!)), ); const stats: Record = {}; imgs.forEach((a) => { stats[a.group_key] = (stats[a.group_key] ?? 0) + 1; }); const data = await planSuite({ product_id: product.id, texts: buildSuiteTexts(product), sku_variants: skuVariants, image_stats: stats, platform: 'ozon', 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); message.info(`AI 方案已生成:${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`); } } catch (e) { if (seq !== planSeqRef.current) return; message.error(apiErrorMessage(e)); } finally { if (seq === planSeqRef.current) setPlanning(false); } }; const handleGenerate = () => { if (selectedKeys.size === 0) { message.warning('请先在采集图片区勾选参考底图'); return; } if (totalPlanned === 0) { message.warning('出图方案的张数都是 0'); return; } modal.confirm({ title: '生成电商套图', content: (
目标平台「Ozon」(俄文文案 · 3:4 图片),模型「{model}」,风格「 {STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考底图{' '} {selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。
), okText: '开始生成', cancelText: '取消', onOk: () => startGenerate(), }); }; // ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ── const handleExportResult = async () => { if (resultImages.length === 0) { message.warning('暂无生图结果可导出'); return; } const selected = resultImages.filter((img) => selectedResultKeys.has(img.key)); if (selected.length === 0) { message.warning('请先勾选要导出的生图结果'); return; } setExportingResult(true); try { const images = selected.map((img) => ({ url: img.url, groupName: img.groupName, variantName: img.variantName, key: img.key, })); const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images }); downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`); } catch (e) { message.error(apiErrorMessage(e)); } finally { setExportingResult(false); } }; const handleDownloadResultImage = async (img: { url: string; name: string; key: string; variantName?: string | null }) => { setDownloadingResultKeys((prev) => new Set(prev).add(img.key)); try { const blob = await exportImage({ url: img.url, groupName: '生图结果', variantName: img.variantName, key: img.name || img.key, }); const name = cleanFilename(img.name || img.key) || '生图结果'; const suffix = (() => { try { return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg'; } catch { return '.jpg'; } })(); downloadBlob(blob, /\.(jpe?g|png|webp|gif|bmp)$/i.test(name) ? name : `${name}${suffix}`); } catch (e) { message.error(apiErrorMessage(e)); } finally { setDownloadingResultKeys((prev) => { const next = new Set(prev); next.delete(img.key); return next; }); } }; const handlePreviewDownload = (url: string) => { // 预览中的地址就是当前展示版本:开启水印后,这里自然下载水印版。 void handleDownloadResultImage({ url, name: '图片预览', key: `preview-${url}`, variantName: null, }); }; const imagePreviewConfig: ImagePreviewConfig = { actionsRender: (originalNode, info) => { const actions = originalNode.props as { children?: ReactNode; className?: string; style?: CSSProperties; }; return (
{actions.children}
); }, }; const handleApplyWatermark = ( assetIds: string[], targetLabel: string, resultItems: typeof selectedWatermarkItems = [], ) => { if (!watermark.enabled) { message.warning('请先在水印设置中开启水印'); return; } if (assetIds.length === 0) { message.warning(`请先勾选已落库的${targetLabel}`); return; } modal.confirm({ title: '批量添加水印', content: `将按当前设置为 ${assetIds.length} 张${targetLabel}生成水印版。原图会保留,用于以后更改水印设置。`, okText: `添加水印(${assetIds.length})`, cancelText: '取消', onOk: async () => { setApplyingWatermark(true); try { const result = await applyWatermarkToAssets({ asset_ids: assetIds, watermark, }); const suiteKeysToHide = resultItems .filter((img) => img.source === 'suite' && img.assetId) .map((img) => img.key); if (suiteKeysToHide.length > 0) { setHiddenResultKeys((prev) => new Set([...prev, ...suiteKeysToHide])); } if (resultItems.length > 0) { setSelectedResultKeys((prev) => { const next = new Set(prev); resultItems.forEach((img) => next.delete(img.key)); return next; }); } message.info(`水印任务已提交:${result.total} 张图片将在后台处理`); pollWatermarkJob(result.job_id); } catch (e) { setApplyingWatermark(false); message.error(apiErrorMessage(e)); } }, }); }; const handleRemoveWatermark = ( assetIds: string[], targetLabel: string, resultItems: typeof selectedWatermarkItems = [], ) => { if (assetIds.length === 0) { message.warning(`请先勾选已加水印的${targetLabel}`); return; } modal.confirm({ title: '移除水印', content: `将为 ${assetIds.length} 张${targetLabel}恢复无水印原图,并删除当前水印版。`, okText: `移除水印(${assetIds.length})`, cancelText: '取消', onOk: async () => { setApplyingWatermark(true); try { const result = await removeWatermarkFromAssets(assetIds); const updated = new Set(result.updated_ids); const suiteKeysToHide = resultItems .filter((img) => img.source === 'suite' && img.assetId && updated.has(img.assetId)) .map((img) => img.key); if (suiteKeysToHide.length > 0) { setHiddenResultKeys((prev) => new Set([...prev, ...suiteKeysToHide])); } onRefreshAssets(); result.failed.length > 0 ? message.warning(`已移除 ${result.updated_ids.length} 张水印,${result.failed.length} 张失败`) : message.success(`已移除 ${result.updated_ids.length} 张图片水印`); } catch (e) { message.error(apiErrorMessage(e)); } finally { setApplyingWatermark(false); } }, }); }; const setPlanCount = (idx: number, count: number) => { setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p))); }; const togglePlanRow = (idx: number) => { setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p))); }; const planAllEnabled = plan.every((p) => p.count >= 1); const togglePlanAll = (on: boolean) => { setPlan((prev) => prev.map((p) => (on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 }))); }; const doneCount = suite?.images.filter((i) => i.status === 'done').length ?? 0; const suiteTotal = suite?.total ?? totalPlanned; const patchWatermark = (patch: Partial) => { setWatermark((prev) => { const next = { ...prev, ...patch }; try { localStorage.setItem(WATERMARK_STORAGE_KEY, JSON.stringify(next)); } catch { /* 忽略存储失败 */ } return next; }); }; const watermarkPopup = ( handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')} onApplyResult={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)} onRemoveCollection={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')} onRemoveResult={() => handleRemoveWatermark( selectedWatermarkedResultIds, '生成结果图片', selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled), )} onCancelJob={() => void handleCancelWatermarkJob()} onRetryJob={() => void handleRetryWatermarkJob()} onRetryItem={(itemId) => void handleRetryWatermarkItem(itemId)} /> ); const selectableCount = imgs.length; const allResultSelected = resultImages.length > 0 && resultImages.every((img) => selectedResultKeys.has(img.key)); const toggleResultKey = (key: string) => { setSelectedResultKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const toggleAllResults = (on: boolean) => { setSelectedResultKeys((prev) => { const next = new Set(prev); resultImages.forEach((img) => (on ? next.add(img.key) : next.delete(img.key))); return next; }); }; /** 删除一张素材(二次确认后调用),成功后刷新素材列表并清掉相应选择。 */ const handleDeleteAsset = async (assetId: string, hiddenKeys: string[] = []) => { try { await deleteAsset(assetId); setSelectedKeys((prev) => { const next = new Set(prev); next.delete(assetId); return next; }); setSelectedResultKeys((prev) => { const next = new Set(prev); next.delete(`asset-${assetId}`); hiddenKeys.forEach((key) => next.delete(key)); return next; }); if (hiddenKeys.length > 0) { setHiddenResultKeys((prev) => new Set([...prev, ...hiddenKeys])); } message.success('已删除'); onRefreshAssets(); } catch (e) { message.error(apiErrorMessage(e)); } }; const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0); return (
{/* ── 4 采集图片 ── */}
{uploadButton} } > {displayGroups.length === 0 ? ( ) : ( ({ key: group, assets: groupImages(group) }))} selectedKeys={selectedKeys} preview={imagePreviewConfig} imageUrl={assetUrl} imageName={assetName} onToggle={toggleKey} onToggleGroup={toggleGroup} onDelete={(assetId) => void handleDeleteAsset(assetId)} onEdit={(asset) => setGenModal({ url: assetOriginalUrl(asset), name: asset.variant_name || asset.id })} /> )}
{/* ── 5 出图方案(左:规划操作+方案列表;右:风格/要求/模型/生成) ── */}
AI 方案} > {/* 左列整块:与价格试算右列相同的浅灰卡片 */}
setPlanThenGenerate(e.target.checked)} title="勾选后,AI 规划完成将自动开始生成" > 规划并生成
{plan.map((p, idx) => (
togglePlanRow(idx)} title="点击勾选/取消该方案(数量用右侧加减调整)" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 8px', borderRadius: 6, cursor: 'pointer', opacity: p.count === 0 ? 0.45 : 1, background: p.count === 0 ? 'transparent' : 'rgba(22,119,255,0.03)', }} >
0}>{p.title} {p.variant_name && {p.variant_name}} {p.detail && ( {p.detail} )} {p.prompt_hint && ( 🎯 {p.prompt_hint} )}
e.stopPropagation()}> setPlanCount(idx, v)} />
))}
togglePlanAll(e.target.checked)} title="勾选:全部方案至少 1 张(>1 张保留原数量);取消:全部改为 0,方便单独勾选一种方案" > 全部方案 {planSource === 'ai' && ( { setPlan(DEFAULT_PLAN.map((p) => ({ ...p }))); setPlanSource('default'); setPlanSummary(''); }} > 恢复默认方案 )} {planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
视觉风格
setStyleSet(v as number)} options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} />
风格提示词(可编辑,直接决定生成画面风格) {stylePrompts[styleSet] !== undefined && ( setStylePrompts((p) => { const n = { ...p }; delete n[styleSet]; return n; }) } > 恢复默认 )} setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))} />
生图要求(优先级最高,强制约束,会覆盖其他设定) setRequirements(e.target.value)} placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印" />