import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { ChangeEvent } from "react"; import { ArrowLeft, ArrowRight, Bookmark, Check, ChevronDown, Download, Grid2X2, ImagePlus, LayoutGrid, List, MoreHorizontal, Plus, Quote, RefreshCw, Search, Sparkles, Trash2, Users, WandSparkles, X } from "lucide-react"; import type { AITask, Asset, ModelConfig, ModelEntity, Product } from "../types"; import { api } from "../api"; import { ActorLibrary } from "../components/actor-library"; import { SkeletonRows } from "../components/loading"; import { MediaLightbox } from "../components/overlays"; import { Pager } from "../components/pager"; import type { Page } from "./route-config"; import { statusPill } from "./stage-config"; import "../ai-tools-page.css"; const TASK_TYPE_LABEL: Record = { model: "模特上身图", platform: "平台套图", image: "图片创作", model_photo: "模特上身图", platform_cover: "平台套图", image_optimize: "图片创作" }; const STATUS_LABEL: Record = { succeeded: "已完成", completed: "已完成", done: "已完成", ok: "已完成", skipped: "已跳过", failed: "失败", error: "失败", running: "生成中", queued: "排队中", polling: "生成中", needs_review: "待确认", // 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文) created: "待处理", reserved: "排队中", submitted: "已提交", postprocessing: "处理中", compensating: "回滚中", cancelled: "已取消" }; function statusText(status: string) { return STATUS_LABEL[status] || status; } // 下载图片:优先 fetch→blob 触发真实下载;跨域失败则回退到新标签打开(用户仍拿到图) async function downloadImage(url: string, filename: string) { try { const res = await fetch(url, { mode: "cors" }); const blob = await res.blob(); const href = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = href; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(href); } catch { window.open(url, "_blank", "noopener"); } } export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void }) { // 任务历史 + 任务→结果图:都在本页懒加载(不再吃全局 bootstrap 的 aiTasks/assets 全量)。 const [aiTasks, setAiTasks] = useState([]); const [tasksLoading, setTasksLoading] = useState(true); const [assets, setAssets] = useState([]); useEffect(() => { let alive = true; setTasksLoading(true); api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); }); api.assetsPage({ asset_type: "image", source: "ai_generated", pageSize: 200 }) .then((res) => { if (alive) setAssets(res.results); }) .catch(() => { if (alive) setAssets([]); }); return () => { alive = false; }; }, []); const taskImage = useMemo(() => { const map: Record = {}; for (const asset of assets) { const taskId = asset.origin_task; if (!taskId || map[taskId]) continue; const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true)); if (file?.preview_url) map[taskId] = file.preview_url; } return map; }, [assets]); const cards = [ { page: "modelPhoto" as Page, tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", cost: "≈ ¥0.30 / 张" }, { page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", cost: "≈ ¥0.50 / 张" }, { page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", cost: "≈ ¥0.40 / 组" } ]; const counts = useMemo(() => { const acc = { gen: 0, ok: 0, err: 0 }; for (const task of aiTasks) { const pill = statusPill(task.status); if (pill === "ok") acc.ok += 1; else if (pill === "err") acc.err += 1; else if (pill === "info") acc.gen += 1; } return acc; }, [aiTasks]); // 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效 const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all"); const [query, setQuery] = useState(""); const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all"); const [typeFilter, setTypeFilter] = useState(""); const [view, setView] = useState<"grid" | "list">("grid"); const [openChip, setOpenChip] = useState<"" | "time" | "type">(""); // 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页 const TASKS_PER_PAGE = 10; const [taskPage, setTaskPage] = useState(1); useEffect(() => { if (!openChip) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); }; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [openChip]); const typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean))); const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [ { value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" } ]; const visible = aiTasks.filter((task) => { const pill = statusPill(task.status); if (filter === "gen" && pill !== "info") return false; if (filter === "ok" && pill !== "ok") return false; if (filter === "err" && pill !== "err") return false; if (typeFilter && task.task_type !== typeFilter) return false; if (timeFilter !== "all" && task.created_at) { const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000; if (days > Number(timeFilter)) return false; } if (query) { const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase(); if (!hay.includes(query.toLowerCase())) return false; } return true; }); // 筛选条件变化时回到第 1 页 useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]); const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE)); const taskCurPage = Math.min(taskPage, taskTotalPages); const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE); return (

图片生成

// 一键生成 · 电商视觉素材,提升内容制作效率
{cards.map((card) => (
{card.tag}
{card.title}
{card.desc}
{/* CTA 放在 factory-text 内(对齐 asset-factory.html):按钮紧跟描述, 而非被 factory-body 的 height:100% + margin-top:auto 顶到卡片最底部 */}
[ {card.cost} ]
))}

任务中心

// {aiTasks.length} 个 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
setFilter("all")}>全部 {aiTasks.length}
setFilter("gen")}>生成中 {counts.gen}
setFilter("ok")}>已完成 {counts.ok}
setFilter("err")}>失败 {counts.err}
setQuery(event.target.value)} />
{TIME_OPTS.map((opt) => (
{ setTimeFilter(opt.value); setOpenChip(""); }}> {opt.label}
))}
{ setTypeFilter(""); setOpenChip(""); }}> 全部类型
{typeOptions.length > 0 &&
} {typeOptions.map((t) => (
{ setTypeFilter(t); setOpenChip(""); }}> {TASK_TYPE_LABEL[t] || t}
))}
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && ( )}
// 显示 {paged.length} / {visible.length} 个任务
{tasksLoading && aiTasks.length === 0 ? ( ) : aiTasks.length === 0 ? (
// NO TASKS YET
还没有任务,去上方选一个工序开始生成吧
) : visible.length === 0 ? (
// NO MATCH
没有符合筛选条件的任务
) : view === "grid" ? (
{paged.map((task) => { const pill = statusPill(task.status); const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type; const img = taskImage[task.id]; return (
{img ? {typeLabel} : {task.id.slice(0, 4)}}
{typeLabel}
// {task.task_type}
{(task.created_at || "").slice(0, 10)} {statusText(task.status)}
); })}
) : (
{paged.map((task) => { const pill = statusPill(task.status); const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type; const img = taskImage[task.id]; return ( ); })}
任务 进度 状态 创建于
{img ? {typeLabel} : {task.id.slice(0, 4)}}
{typeLabel}
// {task.task_type}
{pill === "info" ? (
60%
) : ( {pill === "ok" ? "已完成" : "—"} )}
{statusText(task.status)} {(task.created_at || "").slice(0, 10)}
)}
); } type WorkMode = "image" | "model" | "cover"; const MODE_META: Record< WorkMode, { title: string; tag: string; desc: string; ratio: string; promptTemplate: (productTitle: string) => string; } > = { image: { title: "图片创作", tag: "[ IMAGE · STUDIO ]", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。", ratio: "1:1", promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰` }, model: { title: "模特上身图", tag: "[ MODEL · TRY-ON ]", desc: "选择模特和商品,生成电商模特上身图。", ratio: "1:1", promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图` }, cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", ratio: "4:5", promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版` } }; const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"]; const COUNT_OPTIONS = ["1", "2", "4"]; const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"]; const MODEL_COUNT_OPTIONS = ["4", "8", "12"]; const COVER_COUNT_OPTIONS = ["4", "8", "12"]; /* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */ const IMAGE_SUGGESTIONS = [ "一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风", "极简北欧风格的茶杯,白底,自然柔光,产品摄影", "国风水墨海报,主体一只白鹤立于水边,留白构图", "电影感都市夜景,街道湿漉漉反射霓虹,4K 高清" ]; /* 图片创作 · 风格胶囊(基线 image-optimize STYLES) */ const STYLE_OPTIONS = [ { id: "auto", label: "默认" }, { id: "realistic", label: "写实" }, { id: "cinematic", label: "电影感" }, { id: "anime", label: "动漫" }, { id: "oil", label: "油画" }, { id: "cn-ink", label: "国风水墨" } ]; /* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */ const FALLBACK_MODELS = [ { id: "m1", name: "Ava", tag: "亚洲·25岁·清新" }, { id: "m2", name: "Luna", tag: "亚洲·22岁·学生" }, { id: "m3", name: "Mia", tag: "混血·28岁·OL" }, { id: "m4", name: "Zoe", tag: "亚洲·30岁·健身" } ]; /* 平台套图 · 平台卡(基线 platform-cover · logo 配色用 token 化 className) */ const PLATFORM_OPTIONS = [ { id: "dy", name: "抖音电商", logo: "抖" }, { id: "tb", name: "淘宝", logo: "淘" }, { id: "tm", name: "天猫", logo: "猫" }, { id: "jd", name: "京东", logo: "京" }, { id: "pdd", name: "拼多多", logo: "拼" }, { id: "xhs", name: "小红书", logo: "红" }, { id: "ks", name: "快手", logo: "快" }, { id: "sph", name: "视频号", logo: "视" }, { id: "amz", name: "亚马逊", logo: "a" }, { id: "al", name: "1688", logo: "阿" } ]; /* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态、加入资产库切换;支持多批并行跑。 行31(并发提交) / 行32(重跑追加新批次 + 气泡菜单 + 加入/取消切换) / 行33(多批次持久化恢复)。 */ type GenBatch = { id: string; prompt: string; ratio: string; count: number; status: "generating" | "done" | "failed"; results: Asset[]; /** 批次级:是否已"加入资产库"(可来回切) */ adopted: boolean; ts: number; /** 该批次归属的商品(用于按商品分组,各商品一个导航头) */ productId?: string; productTitle?: string; /** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */ modelId?: string; /** 该批次选中的模特展示名(导航头显示) */ modelName?: string; /** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */ platformIds?: string[]; }; export function ImageWorkbenchPage({ mode, products, modelConfigs, onBack, navigate, onGenerate, onResume, initialProductId, onProductChange }: { mode: WorkMode; products: Product[]; modelConfigs: ModelConfig[]; onBack: () => void; navigate?: (page: Page) => void; onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string }) => Promise<{ assets: Asset[] } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; /** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */ onProductChange?: (productId: string) => void; }) { const meta = MODE_META[mode]; const [productId, setProductId] = useState(initialProductId || products[0]?.id || ""); // 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个 useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]); const product = products.find((item) => item.id === productId) || products[0]; const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品")); const [ratio, setRatio] = useState(meta.ratio); const [style, setStyle] = useState("auto"); const [count, setCount] = useState(mode === "image" ? "4" : "4"); // 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果) const [pickedIds, setPickedIds] = useState([]); // 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填) const [actorLibOpen, setActorLibOpen] = useState(false); // 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用) const [pickedModelName, setPickedModelName] = useState(""); // 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批) const [plOpen, setPlOpen] = useState(false); const [plDraft, setPlDraft] = useState([]); const [plQuery, setPlQuery] = useState(""); const [plCat, setPlCat] = useState(""); // gen-card 就地反馈(§4.18:采用反馈不走全局弹窗,改卡内 show-feedback 1.5s) const [feedbackKey, setFeedbackKey] = useState(""); // 单图「更多」气泡当前展开的 key(点击切换,点空收起) const [openMore, setOpenMore] = useState(""); // 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating const [batches, setBatches] = useState([]); const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null); const refInputRef = useRef(null); // 生成结果图片放大预览 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); // 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效) const [gridQuery, setGridQuery] = useState(""); const [gridSort, setGridSort] = useState<"recent" | "name">("recent"); const [tbOpen, setTbOpen] = useState<"" | "time" | "model">(""); const [searchOpen, setSearchOpen] = useState(false); // 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到) const [showAllModels, setShowAllModels] = useState(false); // 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤 const [prodQuery, setProdQuery] = useState(""); const visibleProducts = products.filter((item) => { const q = prodQuery.trim().toLowerCase(); if (!q) return true; return `${item.title} ${item.category || ""}`.toLowerCase().includes(q); }); // 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets const productCoverUrl = (p: Product): string => { const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order); const primary = p.images?.find((img) => img.is_primary) || sorted[0]; return p.cover_preview_url || primary?.preview_url || ""; }; function pickReference(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; setRefImage({ name: file.name, url: URL.createObjectURL(file) }); event.target.value = ""; } const imageModels = modelConfigs.filter((model) => model.capability.includes("image")); /* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。 映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。 无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ActorLibrary 弹窗同源,逻辑不动。 */ const [personAssets, setPersonAssets] = useState([]); const loadModels = useCallback(() => { api.listModels({ pageSize: 200 }) .then((res) => { const mapped: Asset[] = res.results .filter((m: ModelEntity) => m.portrait_asset) .map((m: ModelEntity) => ({ id: m.portrait_asset as string, name: m.name, asset_type: "image", source: m.source === "upload" ? "upload" : "ai_generated", category: "model_portrait", description: m.description || "", metadata: { model_entity_id: m.id }, files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [], created_at: m.created_at, updated_at: m.updated_at, })); setPersonAssets(mapped); }) .catch(() => setPersonAssets([])); }, []); useEffect(() => { loadModels(); }, [loadModels]); useEffect(() => { if (product) setPrompt(meta.promptTemplate(product.title)); // mode 或商品切换都重置 prompt 与默认比例 setRatio(meta.ratio); // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId, mode]); // 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组) function togglePick(id: string, name?: string) { if (mode === "cover") { setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); return; } setPickedIds((prev) => (prev[0] === id ? [] : [id])); setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || "")); } // 单图就地反馈(§4.18):显示 1.5s 后自动撤掉 function flashFeedback(key: string) { setFeedbackKey(key); window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500); } // 点击空白收起单图「更多」气泡 useEffect(() => { if (!openMore) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".gen-img-more")) setOpenMore(""); }; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [openMore]); const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); // 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用 // 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补 const canGenerate = prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0)) && // 平台套图:必须选商品 + 至少一个平台(P0③ 多选) (mode !== "cover" || (!!product?.id && pickedIds.length > 0)); const anyGenerating = batches.some((b) => b.status === "generating"); /* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。 已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */ const batchKey = `airshelf:imgwb:batches:${mode}`; function persistBatches(list: GenBatch[]) { try { // 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死) const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status })); localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() })); } catch { /* localStorage 不可用时静默降级 */ } } /* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */ async function startBatch(opts: { prompt: string; ratio: string; count: number; productId?: string; productTitle?: string; modelId?: string; modelName?: string; platformIds?: string[]; }) { const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const newBatch: GenBatch = { id: batchId, prompt: opts.prompt, ratio: opts.ratio, count: opts.count, status: "generating", results: [], adopted: false, ts: Date.now(), productId: opts.productId, productTitle: opts.productTitle, modelId: opts.modelId, modelName: opts.modelName, platformIds: opts.platformIds }; setBatches((prev) => [...prev, newBatch]); try { const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio }); setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b)); persistBatches(next); return next; }); } catch { setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b)); persistBatches(next); return next; }); } } async function runGenerate() { if (!canGenerate) return; const base = { prompt: prompt.trim(), ratio, count: candidateCount, productId: product?.id, productTitle: product?.title }; if (mode === "cover") { // P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section; // 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达) for (const pid of pickedIds) { const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name; void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台套图版式` : base.prompt, platformIds: [pid] }); } return; } void startBatch({ ...base, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined }); } /* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */ async function rerunBatch(src: GenBatch) { await startBatch({ prompt: src.prompt, ratio: src.ratio, count: src.count, // 重跑归属原批次的商品,而非当前选中商品 productId: src.productId ?? product?.id, productTitle: src.productTitle ?? product?.title, modelId: src.modelId, modelName: src.modelName, platformIds: src.platformIds }); } /* 删除整个批次(行32③:批次气泡"删除当前批次") */ function removeBatch(id: string) { setBatches((prev) => { const next = prev.filter((b) => b.id !== id); persistBatches(next); return next; }); } /* 删除批次内单张图(行32③:单图气泡"删除") */ function removeImageFromBatch(batchId: string, assetId: string) { setBatches((prev) => { const next = prev.map((b) => b.id === batchId ? { ...b, results: b.results.filter((a) => a.id !== assetId) } : b ); persistBatches(next); return next; }); } /* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */ function toggleAdoptBatch(batchId: string) { setBatches((prev) => { const target = prev.find((b) => b.id === batchId); const willAdopt = !target?.adopted; const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b)); persistBatches(next); const firstId = target?.results[0]?.id; if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`); return next; }); } /* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */ function toggleAdoptImage(batchId: string, assetId: string) { // 单图采用以批次级 adopted 表达(后端无单图采用接口),反馈落在被点的那张图上 setBatches((prev) => { const target = prev.find((b) => b.id === batchId); const willAdopt = !target?.adopted; const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b)); persistBatches(next); if (willAdopt) flashFeedback(`${batchId}:${assetId}`); return next; }); } /* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */ async function regenSingleImage(src: GenBatch) { await startBatch({ prompt: src.prompt, ratio: src.ratio, count: 1, productId: src.productId ?? product?.id, productTitle: src.productTitle ?? product?.title, modelId: src.modelId, modelName: src.modelName, platformIds: src.platformIds }); } /* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次; 再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */ useEffect(() => { let cancelled = false; // 1) 多批次结果回显 try { const raw = localStorage.getItem(batchKey); if (raw) { const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number }; if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) { const restored = (saved.batches || []).filter((b) => b.results && b.results.length); if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const }))); } } } catch { /* 残留解析失败则忽略 */ } // 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报) try { const raw = localStorage.getItem(`airshelf:imgwb:${mode}`); if (!raw) return; const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string }; if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; const pending = saved.pending || []; if (pending.length && onResume) { const batchId = `b-resume-${Date.now()}`; setBatches((prev) => { // 已由多批次残留恢复过结果就不重复加占位 if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev; return [ ...prev, // 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变, // 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。 { id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle } ]; }); onResume(mode, pending) .then((res) => { if (cancelled) return; setBatches((prev) => { const next = prev.map((b) => b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b ); persistBatches(next); return next; }); }); } } catch { /* 残留解析失败则忽略 */ } return () => { cancelled = true; }; // 仅在挂载/切换 mode 时恢复一次 // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); const hasResults = batches.length > 0; /* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ── 每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除) ② 中央 .gen-image-feedback 就地反馈(采用后亮 1.5s)③ 左上「已采用」绿角标。 */ function renderBatchGrid(batch: GenBatch) { const generating = batch.status === "generating"; const n = batch.results.length || batch.count; const cols = n >= 4 ? 4 : 2; const ratioBatchVar = batch.ratio.replace(":", " / "); return (
{(batch.results.length ? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id })) : Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" })) ).map(({ key, index, url, assetId }) => { const cellKey = `${batch.id}:${assetId || key}`; const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId; return (
{url ? ( {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : (
{generating ? ( ) : ( {batch.ratio} · #{index + 1} )}
)} {/* ③ 左上「已采用」绿角标(批次 adopted 时常驻) */} {url && batch.adopted && ( 已采用 )} {/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */} {url && (
已加入资产库
)} {/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */} {url && (
)}
); })}
); } /* 批次所属商品名:优先生成时记下的 productTitle;旧批次缺失则用 productId 或成图的 asset.product (后端已解析归属商品)反查 products——绝不回退到「当前选中商品」(那正是会显示错名的根因)。 */ function batchProductTitle(batch: GenBatch): string { if (batch.productTitle) return batch.productTitle; const pid = batch.productId || batch.results.find((a) => a.product)?.product || ""; return products.find((p) => p.id === pid)?.title || "未选择"; } /* ── 批次列表(行31/32/33):每批各一个商品导航头 + 一张卡片 + 底部操作/气泡 ── */ function renderBatchCards(list: GenBatch[]) { return ( <> {list.map((batch) => { const generating = batch.status === "generating"; const failed = batch.status === "failed"; return ( {/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
1 批 {mode === "model" ? ` · ${batch.ratio}` : ""}
商品 {batchProductTitle(batch)}
{mode === "model" && batch.modelName && (
模特 {batch.modelName}
)} {mode === "cover" && (
平台 {batch.platformIds && batch.platformIds.length ? PLATFORM_OPTIONS.filter((p) => batch.platformIds!.includes(p.id)) .map((p) => p.name) .join("、") : "未选择"}
)}
{batch.count}×
{batch.count} 张 · {batch.ratio}
{generating ? "生成中" : failed ? "失败" : "已完成"} {batch.adopted && !generating && !failed ? " · 已加入资产库" : ""}
{renderBatchGrid(batch)} {/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
{/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */}
); })} ); } /* ── 平台套图 · 多平台分组结果(P0③)── 每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */ function renderCoverGrouped(list: GenBatch[]) { // 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现) const groups: Array<{ pid: string; batches: GenBatch[] }> = []; for (const batch of list) { const pid = batch.platformIds?.[0] || "_"; let g = groups.find((x) => x.pid === pid); if (!g) { g = { pid, batches: [] }; groups.push(g); } g.batches.push(batch); } return ( <> {groups.map((g) => { const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid); return (
{plat ? ( <> {plat.logo} {plat.name} ) : ( 未分类平台 )} // {g.batches.length} 批
{renderBatchCards(g.batches)}
); })} ); } /* ════════════════════════════════════════════════ mode === "image" → 对话流形态(基线 image-optimize · design.md §4.13) 左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏 ════════════════════════════════════════════════ */ if (mode === "image") { return (
{/* 左 · 会话列表 */} {/* 右 · 对话流 + 底部输入栏 */}
{hasResults ? (
{/* 行31/32/33:每个批次独立一条对话,各自展示自己的图与操作 */} {batches.map((batch) => (
{batch.prompt}
{batch.ratio} · {batch.count} 张 · {batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}
{renderBatchGrid(batch)}
))} setPreview(null)} />
) : (
// IMAGE · STUDIO

开始你的创作

输入想法、剧本或上传参考,和 Agent 一起把灵感变成电商视觉素材。

{IMAGE_SUGGESTIONS.map((text) => ( ))}
)}
{/* 底部 · chat 输入栏(textarea + 比例/风格/张数 + 发送) */}
{refImage && ( 参考图 {refImage.name.slice(0, 16)} )}