import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { ChangeEvent } from "react"; import { ArrowLeft, ArrowRight, ArrowUpRight, Check, ChevronDown, Download, Grid2X2, Image, ImageOff, ImagePlus, LayoutGrid, PackageSearch, List, MoreHorizontal, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, Users, WandSparkles, X } from "lucide-react"; import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types"; import { api } from "../api"; import { useFileDrop } from "../components/use-file-drop"; import { imageModelPickerOptions } from "../model-display"; import { ModelLibrary } from "../components/model-library"; import { SkeletonRows, SystemLoading } from "../components/loading"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { Pager } from "../components/pager"; import { useViewMode } from "../components/use-view-mode"; import type { Page } from "./route-config"; import { statusPill } from "./stage-config"; import { productMockCoverUrl } from "./products"; import { isLocalLife } from "../product-business"; import "../ai-tools-page.css"; // 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作, // 而 task_type 区分不了——cover 与 image 都是 product_image) const MODE_LABEL: Record = { model: "模特上身图", cover: "平台套图", image: "图片创作" }; const MODE_TAG: Record = { model: "模特上身", cover: "平台套图", image: "自由创作", }; function formatAfTime(iso: string) { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso.slice(0, 10); const pad = (n: number) => String(n).padStart(2, "0"); const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; const now = new Date(); if (d.toDateString() === now.toDateString()) return `今天 ${hm}`; const yest = new Date(now); yest.setDate(now.getDate() - 1); if (d.toDateString() === yest.toDateString()) return `昨天 ${hm}`; return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${hm}`; } 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); }); // 任务中心 = 生成历史:要看全部生成图(含未加入资产库的),故 in_library: "all" 旁路过滤 api.assetsPage({ asset_type: "image", source: "ai_generated", in_library: "all", 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] = { url: file.preview_url, name: asset.name || "" }; } return map; }, [assets]); const cards = [ { page: "modelPhoto" as Page, title: "模特上身", desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。", image: "/assets/yz/tool-model.jpg", }, { page: "platformCover" as Page, title: "平台套图", desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。", image: "/assets/yz/tool-cover.jpg", }, { page: "imageOptimize" as Page, title: "图片创作", desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。", image: "/assets/yz/tool-studio.jpg", } ]; // 任务中心只看「工作台图片生成」(mode∈model/cover/image —— 能分模特上身图/平台套图/图片创作; // 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。 // 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。 type TaskBatch = { key: string; batchId: string | null; firstTaskId: string; label: string; title: string; mode: string; count: number; doneCount: number; status: "info" | "ok" | "err"; created_at: string; cover: string; }; const taskBatches = useMemo(() => { const groups = new Map(); for (const t of aiTasks) { if (!t.mode || !MODE_LABEL[t.mode]) continue; // 只保留图片生成模式(model/cover/image) const key = t.batch_id || t.id; const arr = groups.get(key); if (arr) arr.push(t); else groups.set(key, [t]); } const out: TaskBatch[] = []; for (const [key, tasks] of groups) { const first = tasks[0] as AITask & { product_title?: string; prompt?: string }; const pills = tasks.map((t) => statusPill(t.status)); const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err"; const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), ""); const coverHit = tasks.map((t) => taskImage[t.id]).find(Boolean); const doneCount = pills.filter((p) => p === "ok").length; const title = first.product_title || first.prompt?.slice(0, 24) || coverHit?.name || MODE_LABEL[first.mode!] || "图片任务"; out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!] || "图片创作", title, mode: first.mode!, count: tasks.length, doneCount, status, created_at: created, cover: coverHit?.url || "", }); } out.sort((a, b) => b.created_at.localeCompare(a.created_at)); return out; }, [aiTasks, taskImage]); // 任务中心筛选:状态 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] = useViewMode<"grid" | "list">("viewmode:tasks", "grid"); const [openChip, setOpenChip] = useState<"" | "time" | "type">(""); // 任务中心分页:每页 8 条(4 列网格两行),筛选/搜索变化时回第 1 页 const TASKS_PER_PAGE = 8; const [taskPage, setTaskPage] = useState(1); // 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大 const [openBatch, setOpenBatch] = useState(null); const [batchImgs, setBatchImgs] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); useEffect(() => { if (!openBatch) { setBatchImgs([]); return; } let alive = true; setBatchLoading(true); // 有 batch_id 按批取整批;旧图无 batch_id 则按 origin_task 取该任务那张 const params = openBatch.batchId ? { meta: { batch_id: openBatch.batchId }, in_library: "all", asset_type: "image", pageSize: 50 } : { origin_task: openBatch.firstTaskId, in_library: "all", asset_type: "image", pageSize: 50 }; api.assetsPage(params) .then((res) => { if (alive) setBatchImgs(res.results); }) .catch(() => { if (alive) setBatchImgs([]); }) .finally(() => { if (alive) setBatchLoading(false); }); return () => { alive = false; }; }, [openBatch]); useEffect(() => { if (!openChip) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".af-select")) setOpenChip(""); }; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [openChip]); // R109:生成图已「自动入资产库」,任务中心批次弹窗不再提供加入/取消入库操作(纯查看 + 放大)。 const typeOptions = Array.from(new Set(taskBatches.map((b) => b.label).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 = taskBatches.filter((batch) => { if (filter === "gen" && batch.status !== "info") return false; if (filter === "ok" && batch.status !== "ok") return false; if (filter === "err" && batch.status !== "err") return false; if (typeFilter && batch.label !== typeFilter) return false; if (timeFilter !== "all" && batch.created_at) { const days = (Date.now() - new Date(batch.created_at).getTime()) / 86400000; if (days > Number(timeFilter)) return false; } if (query) { const hay = `${batch.label} ${batch.title} ${batch.key}`.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); const batchMeta = (batch: TaskBatch) => { if (batch.status === "info") return `生成中 · ${batch.doneCount} / ${batch.count} 张`; if (batch.status === "err") return `${formatAfTime(batch.created_at)} · 生成失败`; return `${formatAfTime(batch.created_at)} · ${batch.count} 张`; }; return (

图片创作

从商品与模特资产出发,快速完成电商图片生产

{cards.map((card) => ( ))}

最近创作

查看图片任务状态与生成结果

{TIME_OPTS.map((opt) => ( ))}
{typeOptions.map((t) => ( ))}
{tasksLoading && aiTasks.length === 0 ? ( ) : aiTasks.length === 0 ? (
还没有任务 去上方选一个入口开始生成
) : visible.length === 0 ? (
没有符合筛选条件的作品 请调整状态、时间、任务类型或搜索关键词后重试
) : (
{paged.map((batch) => { const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成"; return (
setOpenBatch(batch)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
{batch.cover ? {batch.title} : null}

{batch.title}

{batchMeta(batch)}

{statusLabel} {MODE_TAG[batch.mode] || batch.label}
); })}
)}
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */} {openBatch && createPortal(
setOpenBatch(null)}>
e.stopPropagation()} style={{ maxWidth: 760, width: "92%" }}>
{openBatch.label}{openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}
{/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
{batchLoading ? ( ) : batchImgs.length === 0 ? (
这批没有可显示的图片
) : (
{batchImgs.map((a, i) => { const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || ""; return (
{u ? ( {a.name} setPreview({ src: u, name: a.name })} /> ) : (
#{i + 1}
)}
); })}
)}
, document.body )} setPreview(null)} />
); } 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: "使用提示词与参考图,自由生成或修改电商视觉素材", 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: "根据平台规范生成主图、卖点图、细节图与场景图", // 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选 ratio: "1:1", // YYX#4:只出平台头图,去掉「详情排版」 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 = ["1", "2", "4"]; // 生图模型选择(写入 localStorage,下次进页面读回)。默认火山。 const GEN_MODEL_KEY = "airshelf:imgwb:gen_model"; const COVER_COUNT_OPTIONS = ["4", "8", "12"]; /* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */ const IMAGE_SUGGESTIONS = [ { label: "晨光护肤品广告", prompt: "净颜精华悬浮在晨光中,蓝白色调,干净高级的护肤品广告摄影" }, { label: "极简白底产品图", prompt: "极简北欧风格的白底产品摄影,自然柔光,突出包装质感" }, { label: "国风水墨海报", prompt: "围绕商品制作国风水墨海报,主体清晰,留白构图" }, { label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" } ]; /* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg; logo 留作图加载失败时的兜底字符,img 为真 logo) */ const PLATFORM_OPTIONS = [ { id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" }, { id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" }, { id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" }, { id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" }, { id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" }, { id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" }, { id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" }, { id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" }, { id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" }, { id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" } ]; /* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */ const PLATFORM_ID_MAP: Record = { dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd", xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688" }; /* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。 className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */ function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) { const [err, setErr] = useState(false); return ( {p.img && !err ? {p.name} setErr(true)} /> : p.logo} ); } /* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。 行31(并发提交) / 行32(重跑追加 + 气泡菜单) / R100(批次从后端持久任务恢复) / R109(成图自动入库)。 */ type GenBatch = { id: string; prompt: string; ratio: string; count: number; status: "generating" | "done" | "failed"; results: Asset[]; ts: number; /** 该批次归属的商品(用于按商品分组,各商品一个导航头) */ productId?: string; productTitle?: string; /** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */ modelId?: string; /** 该批次选中的模特展示名(导航头显示) */ modelName?: string; /** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */ platformIds?: string[]; /** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */ refs?: { name: string; url: string; assetId?: string }[]; /** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */ backendBatchId?: string; /** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */ pendingIds?: string[]; /** 该批全部后端任务 id:删除/恢复批次时使用稳定锚点,终态批次也必须保留。 */ taskIds?: string[]; /** 补图/单图重跑任务:删掉其成图时不应减少原始批次的失败格数量。 */ rerunTaskIds?: string[]; failedTaskIds?: string[]; }; /** * 判断本地临时卡与后端恢复卡是否代表同一真实任务组。 * 新提交后先有前端 b- 临时 id,随后才回填后端 batch_id;两者在商品切换时会短暂共存, * 必须按真实 batch_id(或任务 id 重叠的提交/恢复交错兜底)合并,不能按前端卡片 id 比较。 */ function isSameWorkbenchBatch(left: GenBatch, right: GenBatch): boolean { if (left.backendBatchId && right.backendBatchId && left.backendBatchId === right.backendBatchId) return true; const leftIds = left.pendingIds || []; const rightIds = new Set(right.pendingIds || []); return leftIds.some((id) => rightIds.has(id)); } /* R100:后端 platform_id(douyin/taobao…) → 前端平台 key(dy/tb…),恢复批次时还原平台分组 */ const PLATFORM_KEY_MAP: Record = Object.fromEntries( Object.entries(PLATFORM_ID_MAP).map(([key, canonical]) => [canonical, key]) ); export function ImageWorkbenchPage({ mode, products, modelConfigs, onBack, navigate, onGenerate, onResume, imageProductId, initialProductId, onProductChange, unreadByProduct, onProductViewed, onNotify }: { 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; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */ imageProductId?: string; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; /** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */ onProductChange?: (productId: string) => void; /** YYX#row22:每个商品的未读生成任务数(商品角标) */ unreadByProduct?: Record; /** YYX#row22:查看某商品(选中)即标记该商品的生成任务已读 → 清零角标 */ onProductViewed?: (productId: string) => void; /** 批次删除失败时复用应用现有 Toast,避免静默移除卡片。 */ onNotify?: (type: "success" | "error" | "info", text: string) => void; }) { const meta = MODE_META[mode]; const [productId, setProductId] = useState(initialProductId || products[0]?.id || ""); // 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个 useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]); // 本地生活没有模特上身图:进模特工作台时如果带入的是本地生活商品,改选第一个电商商品。 useEffect(() => { if (mode !== "model") return; const current = products.find((item) => item.id === productId); if (!current || !isLocalLife(current)) return; const next = products.find((item) => !isLocalLife(item)); setProductId(next?.id || ""); }, [mode, productId, products]); // YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零 useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]); const product = products.find((item) => item.id === productId) || products[0]; const imageProduct = products.find((item) => item.id === imageProductId); const conversationScopeKey = imageProductId ? `product:${imageProductId}` : "unbound"; const conversationScopeLabel = imageProductId ? `当前商品 · ${imageProduct?.title || "加载中"}` : "通用创作"; const conversationScopeKeyRef = useRef(conversationScopeKey); conversationScopeKeyRef.current = conversationScopeKey; // 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步 const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品")); const [ratio, setRatio] = useState(meta.ratio); // 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill const [ratioManual, setRatioManual] = useState(false); const [ratioW, setRatioW] = useState(""); const [ratioH, setRatioH] = useState(""); const updateRatioDimension = (event: ChangeEvent, dimension: "width" | "height") => { const value = event.target.value; const next = value === "" || /^[1-9]\d*$/.test(value) ? value : ""; event.target.value = next; if (dimension === "width") { setRatioW(next); if (next && ratioH) setRatio(`${next}:${ratioH}`); } else { setRatioH(next); if (ratioW && next) setRatio(`${ratioW}:${next}`); } }; const blockNonIntegerKey = (event: React.KeyboardEvent) => { if ([".", "+", "-", "e", "E"].includes(event.key)) event.preventDefault(); }; // 生图模型选择:保存后端路由键;展示名由共享映射统一提供。 const [genModel, setGenModel] = useState(() => { try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; } }); useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]); const selectedImageModelLabel = imageModelPickerOptions.find((option) => option.id === genModel)?.label || imageModelPickerOptions[0].label; const [count, setCount] = useState(mode === "image" ? "4" : "4"); // 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果) const [pickedIds, setPickedIds] = useState([]); // 模特库全屏弹窗:选一个模特回填。 const [modelLibOpen, setModelLibOpen] = 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(""); // 单图「更多」气泡当前展开的 key(点击切换,点空收起) const [openMore, setOpenMore] = useState(""); // 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating const [batches, setBatches] = useState([]); // 商品切换后的后端恢复回调不依赖 batches,避免恢复 effect 因批次状态变化反复触发; // 用 ref 读取最新内存卡,识别仍由首次提交轮询的临时批次。 const batchesRef = useRef(batches); batchesRef.current = batches; /* ── 图片创作「对话」(真实体,后端 ImageConversation)── conversations = 左栏会话列表;activeConvId = 当前选中对话(空 = 还没建,首次发送时后端自动开)。 activeConvRef 让 startBatch 在不进 deps 的情况下读到最新 active id。 */ const [conversations, setConversations] = useState([]); const [activeConvId, setActiveConvId] = useState(""); const activeConvRef = useRef(""); useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]); const conversationLoadSeqRef = useRef(0); const conversationBatchLoadSeqRef = useRef(0); const [convLoading, setConvLoading] = useState(false); // 对话操作失败的可见提示(替代原来的静默吞错) const [convError, setConvError] = useState(""); // 重命名:正在改名的对话 id + 草稿 const [renamingId, setRenamingId] = useState(""); const [renameDraft, setRenameDraft] = useState(""); // 整条对话删除独立确认,不与单图/批次删除状态耦合。 const [pendingDeleteConversation, setPendingDeleteConversation] = useState(null); // 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。 const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]); const refInputRef = useRef(null); // 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22) const resultsEndRef = useRef(null); // 生成结果图片放大预览 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); // 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用; // row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除) const [gridQuery, setGridQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); // YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛) const [filterOpen, setFilterOpen] = useState(false); const [platformFilter, setPlatformFilter] = useState([]); const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item))); // 商品主图:用后端内嵌的 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]; // YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位 return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title); }; function acceptReferences(files: File[]) { if (!files.length) return; // 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传 setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]); } function pickReference(event: ChangeEvent) { acceptReferences(Array.from(event.target.files || [])); event.target.value = ""; } const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") }); const imageModels = modelConfigs.filter((model) => model.capability.includes("image")); // 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1 const [priceMultiplier, setPriceMultiplier] = useState(1); useEffect(() => { void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined); }, []); // 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型, // 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。 const perImagePrice = (() => { const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel; const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0]; const p = Number(m?.unit_price); return Number.isFinite(p) && p > 0 ? p : 20; })(); // 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张 // (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认) const perImageFinal = Math.max(1, Math.round(Number((Math.round(perImagePrice) * priceMultiplier).toFixed(6)))); const estimateFee = (n: number) => `${n * perImageFinal} 积分`; /* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。 映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。 无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ModelLibrary 弹窗同源。 */ 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(() => { // image 模式默认留空(不预填模板);模特/平台仍随商品预填模板 if (mode === "image") setPrompt(""); else if (product) setPrompt(meta.promptTemplate(product.title)); // mode 或商品切换都重置默认比例 setRatio(meta.ratio); setRatioManual(false); setRatioW(""); setRatioH(""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId, mode]); // 模特 / 平台都单选(YYX#1:平台只能单选):再点同一张取消,点别张替换 function togglePick(id: string, name?: string) { setPickedIds((prev) => (prev[0] === id ? [] : [id])); if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || "")); } // 点击空白收起单图「更多」气泡 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]); // YYX#12:点空白收起平台筛选弹窗 useEffect(() => { if (!filterOpen) return; const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); }; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [filterOpen]); const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); // PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。 // 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。 const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount; // 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用 // 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补 const canGenerate = prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0)) && // 平台套图:必须选商品 + 至少一个平台(P0③ 多选) (mode !== "cover" || (!!product?.id && pickedIds.length > 0)); /* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、 工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect), image 模式仍由后端「对话」驱动 —— 两边与任务中心同源(同一批 AITask)。 */ /* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */ // 后端对话任务流 → 前端批次:按 batch_id 把同一次提交的多张图归到一个 GenBatch function batchesFromConvTasks(tasks: ImageConversationTask[]): GenBatch[] { const groups = new Map(); for (const t of tasks) { const key = t.batch_id || t.id; // 老任务可能无 batch_id,各自成批 const list = groups.get(key) || []; list.push(t); groups.set(key, list); } // 回滚中仍可能继续变更任务与资产,和其它在途状态一致,不允许删除。 const TERMINAL = new Set(["succeeded", "failed", "cancelled"]); const result: GenBatch[] = []; for (const [key, list] of groups) { // R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定 const coveredFailureIds = new Set( list.filter((t) => t.rerun && t.status === "succeeded" && t.retry_of_task_id).map((t) => t.retry_of_task_id!) ); // 旧补图任务没有记录它替代的失败格;按一张补图抵一张失败格兼容,避免历史批次显示超过原始张数。 const legacyReplacementCount = list.filter((t) => t.rerun && t.status === "succeeded" && !t.retry_of_task_id).length; const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0); if (!live.length) continue; const assets = live.flatMap((t) => t.assets || []); // 有任务还在跑(含重跑补图)就是 generating,不能只看「有没有图」—— // 否则「已有部分好图 + 重跑在途」被判 done,轮询不接续,补的图要手动刷新才出现 const hasRunning = live.some((t) => !TERMINAL.has(t.status)); const status: GenBatch["status"] = hasRunning ? "generating" : assets.length > 0 ? "done" : "failed"; // 应出张数只数原始任务:重跑/补图任务(rerun)是替补,计入会把失败格越滚越多 // (原失败任务仍在批里,重跑一次多一个格)。成功补图后 results 增长,失败格自然收掉。 const failedTaskIds = list .filter((t) => !t.rerun && ["failed", "cancelled"].includes(t.status) && !(t.assets || []).length && !coveredFailureIds.has(t.id)) .map((t) => t.id) .slice(legacyReplacementCount); const intended = live.filter((t) => !t.rerun).length; const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.length); // 该批用过的参考图(后端解析回 {id,name,url}):批次头回显 + 重跑凭 assetId 原样复用 const refSrc = live.find((t) => (t.reference_images || []).length)?.reference_images || []; result.push({ id: key, prompt: live[0]?.prompt || "", ratio: live[0]?.ratio || meta.ratio, count, status, results: assets, ts: new Date(live[0]?.created_at || Date.now()).getTime(), productId: live[0]?.product_id || undefined, productTitle: products.find((item) => item.id === live[0]?.product_id)?.title, refs: refSrc.length ? refSrc.map((r) => ({ name: r.name, url: r.url, assetId: r.id })) : undefined, // 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端 backendBatchId: live[0]?.batch_id || undefined, taskIds: live.map((t) => t.id), rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id), failedTaskIds, }); } // 旧批次在上、新批次在下(对话流自上而下时间序) return result.sort((a, b) => a.ts - b.ts); } /* R100:后端工作台任务 → 前端批次(与 batchesFromConvTasks 同思路,多带商品/模特/平台归属)。 仍在跑的任务留在 pendingIds,挂载后按批续轮询;成功但成图已全删的任务不回显(R109 绑定)。 */ function batchesFromWorkbenchTasks(tasks: WorkbenchTask[]): GenBatch[] { const groups = new Map(); for (const t of tasks) { const key = t.batch_id || t.id; const list = groups.get(key) || []; list.push(t); groups.set(key, list); } // 回滚中仍可能继续变更任务与资产,和其它在途状态一致,不允许删除。 const TERMINAL = new Set(["succeeded", "failed", "cancelled"]); const result: GenBatch[] = []; for (const [key, list] of groups) { const coveredFailureIds = new Set( list.filter((t) => t.rerun && t.status === "succeeded" && t.retry_of_task_id).map((t) => t.retry_of_task_id!) ); // 同图片创作:历史补图没有目标格关联时,仍按一张补图替代一张失败格回放。 const legacyReplacementCount = list.filter((t) => t.rerun && t.status === "succeeded" && !t.retry_of_task_id).length; const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0); if (!live.length) continue; const assets = live.flatMap((t) => t.assets || []); const hasRunning = live.some((t) => !TERMINAL.has(t.status)); const status: GenBatch["status"] = hasRunning ? "generating" : assets.length ? "done" : "failed"; const first = live[0]; const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : ""; // 应出张数只数原始任务(重跑/补图任务是替补,不计入,与 batchesFromConvTasks 同理) const failedTaskIds = list .filter((t) => !t.rerun && ["failed", "cancelled"].includes(t.status) && !(t.assets || []).length && !coveredFailureIds.has(t.id)) .map((t) => t.id) .slice(legacyReplacementCount); const intended = live.filter((t) => !t.rerun).length; const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.length); result.push({ id: key, prompt: first.prompt || "", ratio: first.ratio || meta.ratio, count, status, results: assets, ts: new Date(first.created_at || Date.now()).getTime(), productId: first.product_id || undefined, modelId: first.model_id || undefined, platformIds: platformKey ? [platformKey] : undefined, backendBatchId: first.batch_id || undefined, taskIds: live.map((t) => t.id), rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id), failedTaskIds, // 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results, // 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。 pendingIds: hasRunning ? live.map((t) => t.id) : undefined, }); } return result.sort((a, b) => a.ts - b.ts); } // 拉某对话的历史批次并回显;非终态批次继续轮询补齐 const loadConvBatches = useCallback(async (convId: string) => { const batchLoadSeq = ++conversationBatchLoadSeqRef.current; try { const res = await api.conversationTasks(convId); if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return; const next = batchesFromConvTasks(res.tasks); setBatches(next); // 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐 if (onResume) { for (const b of next.filter((x) => x.status === "generating")) { const ids = res.tasks.filter((t) => (t.batch_id || t.id) === b.id).map((t) => t.id); if (!ids.length) continue; onResume(mode, ids).then((r) => { if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return; if (!r?.assets) return; setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x))); }); } } } catch { if (batchLoadSeq === conversationBatchLoadSeqRef.current) setBatches([]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, onResume]); // 切换对话:置为 active 并回填它的批次 const selectConversation = useCallback((convId: string) => { if (convId === activeConvRef.current) return; activeConvRef.current = convId; setActiveConvId(convId); setRenamingId(""); void loadConvBatches(convId); }, [loadConvBatches]); // 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流 async function handleNewConversation() { try { const conv = await api.createConversation({ mode, product: imageProductId || null }); setConvError(""); setConversations((prev) => [conv, ...prev]); activeConvRef.current = conv.id; setActiveConvId(conv.id); setBatches([]); setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品")); setPickedIds([]); } catch (err) { // 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404) setConvError(err instanceof Error ? err.message : "新建对话失败,请稍后重试"); } } // 提交重命名 async function commitRename(convId: string) { const title = renameDraft.trim(); setRenamingId(""); if (!title) return; setConversations((prev) => prev.map((c) => (c.id === convId ? { ...c, title } : c))); try { await api.renameConversation(convId, title); } catch { void loadConversations(); } } // 删除对话(软删):接口成功后再从列表移除;删的是当前对话则切到剩下第一条或清空。 async function handleDeleteConversation(convId: string) { try { await api.deleteConversation(convId); setConvError(""); } catch (error) { setConvError(error instanceof Error ? error.message : "删除对话失败,请稍后重试"); void loadConversations(); return; } const remaining = conversations.filter((c) => c.id !== convId); setConversations(remaining); if (convId === activeConvId) { const nextActive = remaining[0]?.id || ""; activeConvRef.current = nextActive; setActiveConvId(nextActive); if (nextActive) void loadConvBatches(nextActive); else setBatches([]); } } async function confirmDeleteConversation() { const target = pendingDeleteConversation; setPendingDeleteConversation(null); if (!target) return; await handleDeleteConversation(target.id); } // 列表加载:image 模式才用对话(model/cover 走商品空间布局)。 // autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。 const loadConversations = useCallback(async (autoSelect = true) => { if (mode !== "image") return; const loadSeq = ++conversationLoadSeqRef.current; setConvLoading(true); try { const res = await api.listConversations(mode, imageProductId); if (loadSeq !== conversationLoadSeqRef.current) return; setConversations(res.results); if (autoSelect && res.results.length && !activeConvRef.current) { activeConvRef.current = res.results[0].id; setActiveConvId(res.results[0].id); void loadConvBatches(res.results[0].id); } } catch { if (loadSeq !== conversationLoadSeqRef.current) return; setConversations([]); } finally { if (loadSeq === conversationLoadSeqRef.current) setConvLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, imageProductId, loadConvBatches]); // 图片创作范围变化时先同步清掉旧活动会话,再加载当前商品/通用范围;序号防止旧请求晚到覆盖新列表。 useEffect(() => { if (mode !== "image") return; conversationLoadSeqRef.current += 1; conversationBatchLoadSeqRef.current += 1; activeConvRef.current = ""; setActiveConvId(""); setConversations([]); setBatches([]); setRenamingId(""); setConvError(""); void loadConversations(); return () => { conversationLoadSeqRef.current += 1; }; }, [mode, conversationScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps /* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */ async function startBatch(opts: { prompt: string; ratio: string; count: number; productId?: string; productTitle?: string; modelId?: string; modelName?: string; platformIds?: string[]; /** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */ platformId?: string; /** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */ refs?: { name: string; url: string; file?: File; assetId?: string }[]; /** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */ reuseBatchId?: string; /** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */ appendToBatchId?: string; /** 重跑/补图:原批次的后端 batch_id,带给后端沿用 → 新任务归回原批次(刷新后不裂新记录) */ batchId?: string; /** 单格重跑要替代的原失败任务。 */ retryOfTaskId?: string; }) { const submittedScopeKey = conversationScopeKey; // 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片 const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (opts.reuseBatchId) { // 全批重跑:把原卡复位为「生成中」,清掉旧(失败/旧)结果,保留其归属与参数 setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b))); } else if (opts.appendToBatchId) { // 单图重跑:原卡转「生成中」补图,保留已有好图 setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b))); } else { const newBatch: GenBatch = { id: batchId, prompt: opts.prompt, ratio: opts.ratio, count: opts.count, status: "generating", results: [], ts: Date.now(), productId: opts.productId, productTitle: opts.productTitle, modelId: opts.modelId, modelName: opts.modelName, platformIds: opts.platformIds, // 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId) refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId })) }; setBatches((prev) => [...prev, newBatch]); } // YYX#16:新批次提交即把面板滚到底部新任务处;原地重跑滚到该卡也无妨 window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); try { // 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。 // 上传任一失败不阻断:跳过该张,有几张算几张。 let referenceImageIds: string[] | undefined; if (opts.refs?.length) { const ids = await Promise.all( opts.refs.map(async (r) => { if (r.assetId) return r.assetId; if (!r.file) return null; const form = new FormData(); form.append("file", r.file); form.append("asset_type", "image"); return api.uploadAsset(form).then((a) => a.id).catch(() => null); }) ); referenceImageIds = ids.filter((x): x is string => !!x); // 上传得到的 assetId 回写进批次 refs:重跑时凭它原样复用参考图(不再重新上传、不丢参考) const refsWithIds = opts.refs.map((r, i) => ({ name: r.name, url: r.url, assetId: r.assetId || ids[i] || undefined })); setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, refs: refsWithIds } : b))); } // 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值 // batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录) // onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100) const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId, onSubmitted: (taskIds, submittedBatchId) => { if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; setBatches((prev) => prev.map((b) => { if (b.id !== batchId) return b; const allTaskIds = opts.appendToBatchId ? [...new Set([...(b.taskIds || []), ...taskIds])] : taskIds; const rerunTaskIds = opts.appendToBatchId ? [...new Set([...(b.rerunTaskIds || []), ...taskIds])] : b.rerunTaskIds; return { ...b, pendingIds: taskIds, taskIds: allTaskIds, rerunTaskIds, backendBatchId: b.backendBatchId || submittedBatchId }; })); } }); if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; // 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数 const convId = result?.conversation_id; if (convId && convId !== activeConvRef.current) { activeConvRef.current = convId; setActiveConvId(convId); void loadConversations(false); } setBatches((prev) => prev.map((b) => { if (b.id !== batchId) return b; const newAssets = result?.assets || []; // 后端批次 id 落进批次卡:之后重跑这张/重跑整批都带它回原批次 const backendBatchId = b.backendBatchId || result?.batch_id; // 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果 if (opts.appendToBatchId) { const seen = new Set(b.results.map((a) => a.id)); const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))]; const failedTaskIds = opts.retryOfTaskId && newAssets.length ? b.failedTaskIds?.filter((id) => id !== opts.retryOfTaskId) : b.failedTaskIds; return { ...b, backendBatchId, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, failedTaskIds, count: failedTaskIds ? Math.max(1, merged.length + failedTaskIds.length) : b.count, pendingIds: undefined }; } return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined }; })); } catch { if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; // 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed) setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b))); } } async function runGenerate() { if (!canGenerate) return; const requestedScopeKey = conversationScopeKey; // 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回—— // 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。 if (mode === "image" && !activeConvRef.current) { try { const conv = await api.createConversation({ mode, product: imageProductId || null, title: prompt.trim().slice(0, 24) || undefined }); if (requestedScopeKey === conversationScopeKeyRef.current) { activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到 setActiveConvId(conv.id); setConversations((prev) => [conv, ...prev]); } } catch { /* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */ } } const base = { prompt: prompt.trim(), ratio, count: candidateCount, productId: mode === "image" ? imageProductId : product?.id, productTitle: mode === "image" ? imageProduct?.title : product?.title }; if (mode === "cover") { // P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section; // 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束) for (const pid of pickedIds) { void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] }); } return; } void startBatch({ ...base, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined, // 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端) refs: mode === "image" && refImages.length ? refImages : undefined }); // 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。 // 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。 if (mode === "image") { setRefImages([]); setPrompt(""); } // 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。 window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); } /* 重跑指定批次(PMC#25:在原任务卡上原地重跑,不新开任务卡)。 */ async function rerunBatch(src: GenBatch) { await startBatch({ prompt: src.prompt, ratio: src.ratio, count: src.count, // 重跑归属原批次的商品,而非当前选中商品 productId: mode === "image" ? src.productId : (src.productId ?? product?.id), productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title), modelId: src.modelId, modelName: src.modelName, platformIds: src.platformIds, platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined, // 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录 refs: src.refs, batchId: src.backendBatchId, reuseBatchId: src.id }); } /* 工作台删除:单张保持既有资产垃圾桶;整批交给后端判断。 全成功批次继续逐图进入资产垃圾桶;含失败/取消任务的批次进入“图片异常批次”, 任务与成图可整批恢复,避免失败卡刷新后复活。 */ const [confirmDel, setConfirmDel] = useState<{ batchId: string; assetId?: string } | null>(null); async function doConfirmedDelete() { const target = confirmDel; setConfirmDel(null); if (!target) return; const batch = batches.find((b) => b.id === target.batchId); if (!batch) return; try { if (target.assetId) { await api.deleteAsset(target.assetId); // 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除 setBatches((prev) => prev.flatMap((b) => { if (b.id !== target.batchId) return [b]; const deletedAsset = b.results.find((a) => a.id === target.assetId); const deletedTaskId = deletedAsset?.origin_task || undefined; const results = b.results.filter((a) => a.id !== target.assetId); const taskIds = deletedTaskId ? b.taskIds?.filter((id) => id !== deletedTaskId) : b.taskIds; const rerunTaskIds = deletedTaskId ? b.rerunTaskIds?.filter((id) => id !== deletedTaskId) : b.rerunTaskIds; // 单图删除只移除该图对应的任务;同批仍有失败、成功或补图任务时,批次卡必须保留。 if (taskIds && taskIds.length === 0) return []; return [{ ...b, results, taskIds, rerunTaskIds, count: b.failedTaskIds ? Math.max(1, results.length + b.failedTaskIds.length) : Math.max(1, b.count - 1), status: b.status === "generating" ? "generating" : results.length ? "done" : "failed" }]; })); return; } const anchorTaskId = batch.taskIds?.[0] || batch.pendingIds?.[0]; if (!anchorTaskId) throw new Error("当前批次暂无法删除,请刷新页面后重试"); await api.deleteWorkbenchImageBatch(anchorTaskId); setBatches((prev) => prev.filter((b) => b.id !== target.batchId)); } catch (error) { onNotify?.("error", error instanceof Error ? error.message : "删除失败,请稍后重试"); } } /* 图片创作「加入模特库」:把该图复用为模特形象图建 Model 条目(后端不二次扣费;幂等)。 成功后该图气泡项变「已加入模特库」禁用,与「加入资产库」同款单图粒度操作。 */ const [enrolledModelIds, setEnrolledModelIds] = useState>(new Set()); const [enrollingModelId, setEnrollingModelId] = useState(""); async function enrollModelFromImage(assetId: string) { if (!assetId || enrollingModelId) return; setEnrollingModelId(assetId); try { await api.enrollModelFromAsset(assetId); setEnrolledModelIds((prev) => new Set(prev).add(assetId)); } catch { /* 失败静默,可重试 */ } finally { setEnrollingModelId(""); } } /* 单图「再生成」/「重跑这张」(§4.18 悬浮① / YYX#5): PMC#25——补一张图进原批次卡(不新开任务、不覆盖已有好图)。 */ async function regenSingleImage(src: GenBatch, retryOfTaskId?: string) { await startBatch({ prompt: src.prompt, ratio: src.ratio, count: 1, productId: mode === "image" ? src.productId : (src.productId ?? product?.id), productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title), modelId: src.modelId, modelName: src.modelName, platformIds: src.platformIds, platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined, // 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录 refs: src.refs, batchId: src.backendBatchId, appendToBatchId: src.id, retryOfTaskId }); } /* R100:工作台记录从后端持久任务恢复(与任务中心同源)—— 原 localStorage 残留 1 小时即丢、 换浏览器/清缓存即空,造成「任务中心有记录、工作台丢」。挂载 / 切换商品时按 mode+product 拉取 该商品的全部生成记录;仍在跑的批次带回 pendingIds,各自续轮询补齐(刷新也不断)。 */ useEffect(() => { // image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,避免双源打架 if (mode === "image") return; if (!productId) { setBatches([]); return; } let cancelled = false; api.workbenchTasks(mode, productId) .then((res) => { if (cancelled) return; const next = batchesFromWorkbenchTasks(res.tasks || []); // 竞态保护:拉取在途时用户又提交了新批次(本地 id 以 b- 开头、还在生成中)→ 合并保留,别被整体替换冲掉。 // 同时,切走再回来时 next 会带回这张临时卡对应的真实 batch_id:若直接追加, // 页面会出现两张同一任务卡,并对同一 pendingIds 启动两次轮询。 const inMemoryGenerating = batchesRef.current.filter((b) => b.id.startsWith("b-") && b.status === "generating"); const recoveredToResume = next.filter((b) => !inMemoryGenerating.some((local) => isSameWorkbenchBatch(local, b))); setBatches((prev) => { const localGenerating = prev.filter((b) => b.id.startsWith("b-") && b.status === "generating"); const recovered = next.filter((b) => !localGenerating.some((local) => isSameWorkbenchBatch(local, b))); return [...recovered, ...localGenerating]; }); if (onResume) { // 仅恢复当前内存中不存在的批次;已匹配的临时卡仍由首次提交流程继续轮询。 for (const b of recoveredToResume) { if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue; onResume(mode, b.pendingIds).then((r) => { if (cancelled || !r?.assets) return; setBatches((prev) => prev.map((x) => x.id === b.id ? { ...x, status: "done" as const, results: r.assets, pendingIds: undefined } : x )); }); } } }) .catch(() => { /* 拉取失败(后端未更新/网络抖动):保留当前内存批次,不清空 */ }); return () => { cancelled = true; }; // 仅在挂载 / 切 mode / 切商品时恢复(不依赖 onResume 身份,避免反复重拉) // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, productId]); // YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次 // (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。 // YYX#12:右上搜索框(gridQuery)在平台套图下用来筛「工作台图片」(按提示词匹配),不再清空左侧平台网格。 const productBatches = useMemo(() => { let list = mode === "image" ? batches : batches.filter((b) => !b.productId || b.productId === productId); if (mode === "cover" && gridQuery.trim()) { const q = gridQuery.trim().toLowerCase(); list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q)); } // YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些) if (mode === "cover" && platformFilter.length) { list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p))); } return list; }, [batches, productId, mode, gridQuery, platformFilter]); const hasResults = productBatches.length > 0; /* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ── 每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→删除) ② 失败格占位 + 单张重跑。R109:成图自动入资产库,不再有「已入库」角标/入库切换。 */ function renderBatchGrid(batch: GenBatch) { const generating = batch.status === "generating"; const n = generating ? Math.max(batch.results.length, batch.count) : Math.max(1, batch.results.length + (batch.failedTaskIds?.length ?? Math.max(0, batch.count - batch.results.length))); const cols = n >= 4 ? 4 : 2; const ratioBatchVar = batch.ratio.replace(":", " / "); return (
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败, 给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */} {Array.from({ length: n }).map((_, index) => { const asset = batch.results[index]; const failedTaskId = batch.failedTaskIds?.[index - batch.results.length]; const url = asset?.files?.[0]?.preview_url; const assetId = asset?.id || ""; const failed = !generating && !asset && (Boolean(failedTaskId) || !batch.failedTaskIds); // 非生成中且该格无图 → 这张失败了 const key = assetId || `ph-${index}`; const cellKey = `${batch.id}:${assetId || key}`; return (
{url ? ( {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : (
{generating ? ( ) : failed ? ( /* YYX#5:这张失败了 —— 占位提示 + 单张重跑 */ 这张生成失败 ) : ( {batch.ratio} · #{index + 1} )}
)} {/* R109:成图自动入资产库 —— 去掉「已入库」角标与加入/取消入口 */} {/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */} {url && (
{/* row39:鼠标移出「更多按钮 + 气泡」整体区域自动收起(气泡是本容器后代,悬停气泡不算移出; 按钮与气泡间 6px 空隙由 .gen-bubble::before 透明桥接补上,移过去不会误收起)。 仅当收起的是本格气泡才清空,避免误关他格。保留点击切换 + 点空白收起。 */}
setOpenMore((k) => (k === cellKey ? "" : k))}>
{/* row41:仅图片创作(mode=image)显示「加入模特库」;模特上身图/平台套图不显示 */} {mode === "image" && ( )} {/* R108/R109:删除 = 真删资产(回收站软删),先二次确认 */}
)}
); })}
); } /* 批次所属商品名:优先生成时记下的 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 ( {/* YYX#18:结果卡头部精简 —— 去容器/引号/平台/小标题,只留可折叠提示词 + 灰色张数文案 */}
{renderBatchGrid(batch)} {/* 行30/32:批次底部操作行(R109:成图自动入资产库,去掉「加入资产库」按钮) */}
{!generating && (
)}
); })} ); } /* ── 平台套图 · 多平台分组结果(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.name} ) : ( 未分类平台 )} {g.batches.length} 批
{renderBatchCards(g.batches)}
); })} ); } const featuredProducts = (() => { const selected = visibleProducts.find((item) => item.id === productId); const rest = visibleProducts.filter((item) => item.id !== productId); return (selected ? [selected, ...rest] : rest).slice(0, 2); })(); const featuredModels = (() => { const selected = pickedIds[0] ? personAssets.find((item) => item.id === pickedIds[0]) : undefined; const rest = personAssets.filter((item) => item.id !== pickedIds[0]); return (selected ? [selected, ...rest] : rest).slice(0, 3); })(); const selectedPlatform = PLATFORM_OPTIONS.find((item) => item.id === pickedIds[0]); const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS; const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS; const openProductLibrary = () => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }; const productRefLabel = (item: Product) => { const n = item.images?.length || 0; return `${item.category || "未分类"} · ${n} 张参考图`; }; /* ════════════════════════════════════════════════ mode === "image" → 影擎图片创作工作台 ════════════════════════════════════════════════ */ if (mode === "image") { return (

图片创作

使用提示词与参考图,自由生成或修改电商视觉素材

{hasResults ? ( <> {batches.map((batch) => (
{batch.refs && batch.refs.length > 0 && (
{batch.refs.map((r, i) => ( {r.name} ))} 参考图 ×{batch.refs.length}
)} {renderBatchGrid(batch)}
{batch.status !== "generating" && (
)}
))} setPreview(null)} />
{refImages.length > 0 && (
{refImages.map((img, index) => ( {img.name} ))}
)}