Files
yingqing/core/frontend/src/routes/ai-tools.tsx
T
Azmat@qq.com 2889c92c34 同步图片/视频创作页卡片封面与设计稿
图片创作三张与视频创作五张入口封面切到 yz 设计稿资源;自由创作改名为图片创作,自由生成封面升至 film-v5。
2026-09-21 18:49:21 +08:00

2866 lines
140 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { pointsPerImageFromCatalog } from "../components/free-create/constants";
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<string, string> = {
model: "模特上身图",
cover: "平台套图",
image: "自由创作"
};
const MODE_TAG: Record<string, string> = {
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<string, string> = {
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<AITask[]>([]);
const [tasksLoading, setTasksLoading] = useState(true);
const [assets, setAssets] = useState<Asset[]>([]);
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<string, { url: string; name: string }> = {};
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/image-model-wear-film-v1.jpg",
},
{
page: "platformCover" as Page,
title: "平台套图",
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
image: "/assets/yz/image-platform-set-film-v1.jpg",
},
{
page: "imageOptimize" as Page,
title: "图片创作",
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
image: "/assets/yz/image-studio-film-v2.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<TaskBatch[]>(() => {
const groups = new Map<string, AITask[]>();
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<TaskBatch | null>(null);
const [batchImgs, setBatchImgs] = useState<Asset[]>([]);
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 (
<div className="asset-factory">
<div className="asset-factory-inner">
<header className="af-page-head">
<h1>图片创作</h1>
</header>
<div className="af-tools">
{cards.map((card) => (
<button className="af-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="af-tool-cover">
<img src={card.image} alt="" />
</div>
<div className="af-tool-body">
<div className="af-tool-title">
<h3>{card.title}</h3>
<ArrowUpRight size={21} />
</div>
<p>{card.desc}</p>
</div>
</button>
))}
</div>
<div className="af-section">
<div>
<h2>最近创作</h2>
</div>
</div>
<div className="af-toolbar">
<div className="af-seg" role="tablist" aria-label="任务状态">
<button className={`af-seg-btn${filter === "all" ? " active" : ""}`} type="button" onClick={() => setFilter("all")}>全部</button>
<button className={`af-seg-btn${filter === "gen" ? " active" : ""}`} type="button" onClick={() => setFilter("gen")}>生成中</button>
<button className={`af-seg-btn${filter === "ok" ? " active" : ""}`} type="button" onClick={() => setFilter("ok")}>已完成</button>
<button className={`af-seg-btn${filter === "err" ? " active" : ""}`} type="button" onClick={() => setFilter("err")}>失败</button>
</div>
<div className="af-toolbar-right">
<label className="af-search">
<Search size={16} />
<input placeholder="搜索任务名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<div className={`af-select${openChip === "time" ? " open" : ""}`}>
<button className="af-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "time"} onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span>{TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间"}</span>
<ChevronDown size={15} />
</button>
<div className="af-select-menu" role="listbox">
{TIME_OPTS.map((opt) => (
<button className={`af-select-option${timeFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
{opt.label}
</button>
))}
</div>
</div>
<div className={`af-select${openChip === "type" ? " open" : ""}`}>
<button className="af-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "type"} onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span>{typeFilter || "全部任务"}</span>
<ChevronDown size={15} />
</button>
<div className="af-select-menu" role="listbox">
<button className={`af-select-option${!typeFilter ? " selected" : ""}`} type="button" onClick={() => { setTypeFilter(""); setOpenChip(""); }}>全部任务</button>
{typeOptions.map((t) => (
<button className={`af-select-option${typeFilter === t ? " selected" : ""}`} type="button" key={t} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>{t}</button>
))}
</div>
</div>
<div className="af-view">
<button type="button" className={`af-view-btn${view === "grid" ? " active" : ""}`} aria-label="网格显示" onClick={() => setView("grid")}>
<Grid2X2 size={16} />
</button>
<button type="button" className={`af-view-btn${view === "list" ? " active" : ""}`} aria-label="列表显示" onClick={() => setView("list")}>
<List size={16} />
</button>
</div>
</div>
</div>
{tasksLoading && aiTasks.length === 0 ? (
<SkeletonRows count={5} />
) : aiTasks.length === 0 ? (
<div className="af-empty" role="status">
<ImageOff />
<strong>还没有任务</strong>
<span>去上方选一个入口开始生成</span>
</div>
) : visible.length === 0 ? (
<div className="af-empty" role="status">
<ImageOff />
<strong>没有符合筛选条件的作品</strong>
<span>请调整状态、时间、任务类型或搜索关键词后重试</span>
</div>
) : (
<div className={`af-media-grid${view === "list" ? " list" : ""}`}>
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<article className="af-media" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<div className="af-media-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.title} loading="lazy" decoding="async" /> : null}
</div>
<div className="af-media-meta">
<h3>{batch.title}</h3>
<p>{batchMeta(batch)}</p>
<div className="af-meta-line">
<span className={`af-tag ${batch.status}`}>{statusLabel}</span>
<span className={`af-tag type ${batch.mode}`}>{MODE_TAG[batch.mode] || batch.label}</span>
</div>
</div>
</article>
);
})}
</div>
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</div>
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{openBatch && createPortal(
<div className="modal-bg show" onClick={() => setOpenBatch(null)}>
<div className="modal with-corners" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 760, width: "92%" }}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="modal-h">
<div className="ic-m"><LayoutGrid size={17} /></div>
<div className="ti">{openBatch.label}<span>{openBatch.count} · {(openBatch.created_at || "").slice(0, 10)}</span></div>
<span style={{ flex: 1 }} />
{/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
</div>
<div className="modal-b">
{batchLoading ? (
<SystemLoading variant="inline" title="正在加载详情" icon="images" />
) : batchImgs.length === 0 ? (
<div className="task-empty"><div>这批没有可显示的图片</div></div>
) : (
<div className="gen-images" style={{ "--cols": Math.min(4, batchImgs.length), "--ratio": "1 / 1" } as React.CSSProperties}>
{batchImgs.map((a, i) => {
const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || "";
return (
<div className="gen-image" key={a.id}>
{u ? (
<img className="gen-image-img" src={u} alt={a.name} loading="lazy" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: u, name: a.name })} />
) : (
<div className="placeholder"><span className="ph-frame">#{i + 1}</span></div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</div>
);
}
type WorkMode = "image" | "model" | "cover";
const MODE_META: Record<
WorkMode,
{
title: string;
desc: string;
ratio: string;
promptTemplate: (productTitle: string) => string;
}
> = {
image: {
title: "图片创作",
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
ratio: "1:1",
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
},
model: {
title: "模特上身",
desc: "选择授权模特与生成规格,为商品创建自然真实的上身展示图",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
title: "平台套图",
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<string, string> = {
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 (
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
{p.img && !err
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
: p.logo}
</span>
);
}
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。
行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<string, string> = 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<string, number>;
/** 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<HTMLInputElement>, 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<HTMLInputElement>) => {
if ([".", "+", "-", "e", "E"].includes(event.key)) event.preventDefault();
};
// 生图模型选择:保存后端路由键;展示名由共享映射统一提供。
const [genModel, setGenModel] = useState<string>(() => {
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<string[]>([]);
// 模特库全屏弹窗:选一个模特回填。
const [modelLibOpen, setModelLibOpen] = useState(false);
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
const [pickedModelName, setPickedModelName] = useState("");
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
const [plOpen, setPlOpen] = useState(false);
const [plDraft, setPlDraft] = useState<string[]>([]);
const [plQuery, setPlQuery] = useState("");
const [plCat, setPlCat] = useState("");
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
const [openMore, setOpenMore] = useState("");
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
const [batches, setBatches] = useState<GenBatch[]>([]);
// 商品切换后的后端恢复回调不依赖 batches,避免恢复 effect 因批次状态变化反复触发;
// 用 ref 读取最新内存卡,识别仍由首次提交轮询的临时批次。
const batchesRef = useRef<GenBatch[]>(batches);
batchesRef.current = batches;
/* ── 图片创作「对话」(真实体,后端 ImageConversation)──
conversations = 左栏会话列表;activeConvId = 当前选中对话(空 = 还没建,首次发送时后端自动开)。
activeConvRef 让 startBatch 在不进 deps 的情况下读到最新 active id。 */
const [conversations, setConversations] = useState<ImageConversation[]>([]);
const [activeConvId, setActiveConvId] = useState<string>("");
const activeConvRef = useRef<string>("");
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<ImageConversation | null>(null);
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
const resultsEndRef = useRef<HTMLDivElement | null>(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<string[]>([]);
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<HTMLInputElement>) {
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);
}, []);
// 每张图实扣单价:优先后台挂牌 points_pricing.points_per_image,否则 unit_price,再兜底 20。
// 与后端 quote_flat / image_points_per_unit 同口径(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 listed = pointsPerImageFromCatalog(m);
return listed != null && listed > 0 ? listed : 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<Asset[]>([]);
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<string, ImageConversationTask[]>();
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<string, WorkbenchTask[]>();
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<Set<string>>(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 (
<div
className="gen-images"
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
>
{/* 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 (
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}`} key={key}>
{url ? (
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
) : (
<div className="placeholder">
{generating ? (
<span className="gen-loading" aria-label="生成中">
<span className="spinner" aria-hidden />
</span>
) : failed ? (
/* YYX#5:这张失败了 —— 占位提示 + 单张重跑 */
<span className="gen-failed">
<X size={18} />
<span className="gf-text">这张生成失败</span>
<button type="button" className="gf-retry" onClick={() => regenSingleImage(batch, failedTaskId)}>
<RefreshCw size={12} />
重跑这张
</button>
</span>
) : (
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
)}
</div>
)}
{/* R109:成图自动入资产库 —— 去掉「已入库」角标与加入/取消入口 */}
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
{url && (
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="再生成" disabled={generating} onClick={() => regenSingleImage(batch)}>
<RefreshCw size={14} />
</button>
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
{/* row39:鼠标移出「更多按钮 + 气泡」整体区域自动收起(气泡是本容器后代,悬停气泡不算移出;
按钮与气泡间 6px 空隙由 .gen-bubble::before 透明桥接补上,移过去不会误收起)。
仅当收起的是本格气泡才清空,避免误关他格。保留点击切换 + 点空白收起。 */}
<div className="gen-img-more" onMouseLeave={() => setOpenMore((k) => (k === cellKey ? "" : k))}>
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
<MoreHorizontal size={14} />
</button>
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
{/* row41:仅图片创作(mode=image)显示「加入模特库」;模特上身图/平台套图不显示 */}
{mode === "image" && (
<button type="button" className="gen-bubble-item" disabled={enrolledModelIds.has(assetId) || enrollingModelId === assetId} onClick={() => { setOpenMore(""); enrollModelFromImage(assetId); }}>
<Users size={13} />
{enrolledModelIds.has(assetId) ? "已加入模特库" : (enrollingModelId === assetId ? "加入中…" : "加入模特库")}
</button>
)}
{/* R108/R109:删除 = 真删资产(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); setConfirmDel({ batchId: batch.id, assetId }); }}>
<Trash2 size={13} />
删除
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
/* 批次所属商品名:优先生成时记下的 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 (
<Fragment key={batch.id}>
{/* YYX#18:结果卡头部精简 —— 去容器/引号/平台/小标题,只留可折叠提示词 + 灰色张数文案 */}
<BatchHeader
prompt={batch.prompt}
count={batch.count}
statusText={generating ? "生成中" : failed ? "失败" : "已完成"}
/>
<div className="gen-card gen-batch-card">
{renderBatchGrid(batch)}
{/* 行30/32:批次底部操作行(R109:成图自动入资产库,去掉「加入资产库」按钮) */}
<div className="gen-batch-actions">
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => rerunBatch(batch)}>
<RefreshCw size={13} />
重跑
</button>
{!generating && (
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
删除当前批次
</button>
</div>
</div>
)}
</div>
</div>
</Fragment>
);
})}
</>
);
}
/* ── 平台套图 · 多平台分组结果(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 (
<Fragment key={g.pid}>
<div className="iw-cover-group-h">
{plat ? (
<>
<PlatformLogo p={plat} className="cg-logo" />
<span className="cg-name">{plat.name}</span>
</>
) : (
<span className="cg-name">未分类平台</span>
)}
<span className="cg-ct">{g.batches.length} </span>
</div>
{renderBatchCards(g.batches)}
</Fragment>
);
})}
</>
);
}
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 (
<div className="yz-image image-workbench">
<header className="image-subpage-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回" onClick={onBack}>
<ArrowLeft />
</button>
<div>
<h1>图片创作</h1>
<p>使用提示词与参考图,自由生成或修改电商视觉素材</p>
</div>
</div>
<button type="button" className="secondary-action" onClick={handleNewConversation}>
<Plus />
<span>新建对话</span>
</button>
</header>
<div className="studio-layout">
<aside className="studio-history">
<button type="button" className="new-conversation" onClick={handleNewConversation}>
<Plus />
<span>新对话</span>
</button>
{convError && <div className="ic-conv-error">{convError}</div>}
<span className="history-label">最近创作 · {conversationScopeLabel}</span>
{conversations.length === 0 ? (
<p className="history-empty">
{convLoading ? "加载中…" : <>还没有历史对话<br />从一个灵感开始。</>}
</p>
) : (
<div className="history-list">
{conversations.map((conv) => (
<div
className={`history-item ${conv.id === activeConvId ? "active" : ""}`}
key={conv.id}
role="button"
tabIndex={0}
onClick={() => selectConversation(conv.id)}
onKeyDown={(e) => { if (e.key === "Enter") selectConversation(conv.id); }}
>
{renamingId === conv.id ? (
<input
className="ic-conv-rename"
autoFocus
value={renameDraft}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={() => commitRename(conv.id)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitRename(conv.id);
if (e.key === "Escape") setRenamingId("");
}}
/>
) : (
<span className="nm">{conv.title || "未命名创作"}</span>
)}
<span className="ic-conv-acts">
<button
type="button"
title="重命名"
onClick={(e) => { e.stopPropagation(); setRenamingId(conv.id); setRenameDraft(conv.title); }}
>
<Pencil size={12} />
</button>
<button
type="button"
title="删除对话"
onClick={(e) => { e.stopPropagation(); setPendingDeleteConversation(conv); }}
>
<Trash2 size={12} />
</button>
</span>
</div>
))}
</div>
)}
<div className="history-tip">支持上传参考图、引用商品与模特资产;生成结果会自动保存到成品库。</div>
</aside>
<section className="studio-stage">
<div className={`studio-canvas${hasResults ? " has-results" : ""}`}>
{hasResults ? (
<>
{batches.map((batch) => (
<div className="gen-card gen-batch-card" key={batch.id}>
<BatchHeader
prompt={batch.prompt}
count={batch.count}
statusText={batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}
/>
{batch.refs && batch.refs.length > 0 && (
<div className="pt-refs" style={{ display: "flex", gap: 6, marginBottom: 8, alignItems: "center" }}>
{batch.refs.map((r, i) => (
<span className="pt-ref" key={`${r.name}-${i}`} title={r.name}>
<img src={r.url} alt={r.name} style={{ width: 28, height: 28, objectFit: "cover", borderRadius: 6 }} />
</span>
))}
<span className="pt-ref-label">参考图 ×{batch.refs.length}</span>
</div>
)}
{renderBatchGrid(batch)}
<div className="gen-batch-actions">
<button className="btn btn-sm" type="button" disabled={batch.status === "generating"} onClick={() => rerunBatch(batch)}>
<RefreshCw size={13} />
重跑
</button>
{batch.status !== "generating" && (
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
删除当前批次
</button>
</div>
</div>
)}
</div>
</div>
))}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
<div ref={resultsEndRef} aria-hidden="true" />
</>
) : (
<div className="studio-empty">
<span className="result-empty-icon"><Sparkles /></span>
<p className="studio-kicker">IMAGE STUDIO</p>
<h2>开始你的创作</h2>
<p>输入想法或上传参考图,把灵感快速变成可用于电商的视觉素材。</p>
<div className="suggestion-list">
{IMAGE_SUGGESTIONS.map((item) => (
<button className="suggestion-chip" type="button" key={item.label} onClick={() => setPrompt(item.prompt)}>
{item.label}
</button>
))}
</div>
</div>
)}
</div>
<div className={`image-composer${refDrop.dragging ? " is-dragover" : ""}`} {...refDrop.dropProps}>
<div className="image-composer-main">
<div>
<button type="button" className="image-reference-button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
<ImagePlus />
</button>
<input ref={refInputRef} type="file" accept="image/*" multiple hidden onChange={pickReference} />
{refImages.length > 0 && (
<div className="image-reference-thumbs">
{refImages.map((img, index) => (
<span key={`${img.name}-${index}`}>
<img src={img.url} alt={img.name} />
<button type="button" aria-label="移除参考图" onClick={() => setRefImages((prev) => prev.filter((_, i) => i !== index))}>×</button>
</span>
))}
</div>
)}
</div>
<textarea
className="studio-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
/>
</div>
<div className="image-composer-footer">
<div className="image-composer-options">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<Pill label="比例" value={ratio} options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))} onSelect={setRatio} />
<Pill label="张数" value={count} options={COUNT_OPTIONS.map((value) => ({ id: value, label: value }))} onSelect={setCount} />
</div>
<button className="studio-submit" type="button" onClick={runGenerate} disabled={!canGenerate}>
<span>预计 {estimateFee(candidateCount)} · 生成</span>
<ArrowRight />
</button>
</div>
</div>
</section>
</div>
<ConfirmModal
open={!!pendingDeleteConversation}
title="删除当前对话"
icon={<Trash2 size={16} />}
detail="删除后可在垃圾桶恢复,是否需要删除?"
confirmText="删除"
onCancel={() => setPendingDeleteConversation(null)}
onConfirm={confirmDeleteConversation}
/>
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
icon={<Trash2 size={16} />}
detail="删除后可在垃圾桶恢复,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
/* ════════════════════════════════════════════════
mode === "model" / "cover" → 影擎生成台
════════════════════════════════════════════════ */
const productCover = product ? productCoverUrl(product) : "";
return (
<div className="yz-image image-workbench">
<header className="image-subpage-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回" onClick={onBack}>
<ArrowLeft />
</button>
<div>
<h1>{meta.title}</h1>
<p>{meta.desc}</p>
</div>
</div>
<button type="button" className="product-context" onClick={openProductLibrary}>
{productCover ? <img src={productCover} alt={product?.title || "商品"} /> : <span className="thumb-fallback">{(product?.title || "商").slice(0, 2)}</span>}
<div className="product-context-copy">
<span>当前商品</span>
<strong>{product ? `${product.title} · ${mode === "cover" ? `${product.images?.length || 0} 张参考图` : (product.category || "未分类")}` : "从商品库选择"}</strong>
</div>
</button>
</header>
<div className="generator-layout">
<aside className="generator-panel">
<div className="asset-pair-grid">
<section className="control-section">
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
<div className="product-choice-list">
{featuredProducts.map((item) => {
const cover = productCoverUrl(item);
const unread = unreadByProduct?.[item.id] || 0;
return (
<button
type="button"
className={`product-choice ${productId === item.id ? "active" : ""}`}
key={item.id}
onClick={() => setProductId(item.id)}
>
<span className="choice-check" />
{unread > 0 && <span className="product-choice-unread">{unread > 99 ? "99+" : unread}</span>}
{cover ? <img src={cover} alt={item.title} /> : <span className="thumb-fallback">{item.title.slice(0, 2)}</span>}
<span className="product-choice-copy">
<strong>{item.title}</strong>
<span>{productRefLabel(item)}</span>
</span>
</button>
);
})}
<button
type="button"
className="product-library-choice"
onClick={openProductLibrary}
>
<PackageSearch />
<span>从商品库选择</span>
</button>
</div>
</section>
{mode === "model" ? (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
<div className="model-choice-grid">
{featuredModels.map((item) => {
const url = item.files?.[0]?.preview_url;
const name = item.name || "模特";
return (
<button
type="button"
className={`model-choice ${pickedIds[0] === item.id ? "active" : ""}`}
key={item.id}
onClick={() => togglePick(item.id, name)}
>
<span className="choice-check" />
{url ? <img src={url} alt={name} /> : <span className="thumb-fallback">{name.slice(0, 2)}</span>}
<span className="model-choice-copy">
<strong>{name}</strong>
<span>{item.description || "授权模特"}</span>
</span>
</button>
);
})}
<button type="button" className="upload-choice model-upload" onClick={() => setModelLibOpen(true)}>
<Plus />
<span>从模特库选择</span>
</button>
</div>
</section>
) : (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择平台</strong></div>
<div className="yz-platform-grid">
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`platform-choice ${pickedIds.includes(item.id) ? "active" : ""}`}
onClick={() => togglePick(item.id)}
>
<span className="choice-check" />
<PlatformLogo p={item} />
<span>{item.name}</span>
</button>
))}
</div>
</section>
)}
</div>
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
<div className="field-group">
<div className="field-label">
<span>生成数量</span>
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
</div>
<div className="choice-row">
{countOptions.map((value) => (
<button type="button" key={value} className={count === value ? "active" : ""} onClick={() => setCount(value)}>
{value}
</button>
))}
</div>
</div>
{mode === "model" && (
<div className="field-group">
<div className="field-label"><span>图片比例</span><small>电商常用比例</small></div>
<div className="choice-row four">
{ratioOptions.map((value) => (
<button
type="button"
key={value}
className={!ratioManual && ratio === value ? "active" : ""}
onClick={() => { setRatioManual(false); setRatio(value); }}
>
{value}
</button>
))}
<button
type="button"
className={ratioManual ? "active" : ""}
onClick={() => {
setRatioManual(true);
if (ratioW && ratioH) setRatio(`${ratioW}:${ratioH}`);
}}
>
自定义
</button>
</div>
{ratioManual && (
<div className="iw-ratio-manual">
<input
className="input"
type="number"
min={1}
step={1}
placeholder="宽"
value={ratioW}
onKeyDown={blockNonIntegerKey}
onChange={(event) => updateRatioDimension(event, "width")}
/>
<span className="sep">:</span>
<input
className="input"
type="number"
min={1}
step={1}
placeholder="高"
value={ratioH}
onKeyDown={blockNonIntegerKey}
onChange={(event) => updateRatioDimension(event, "height")}
/>
</div>
)}
</div>
)}
<div className="field-group">
<label className="field-label">
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>
{mode === "cover"
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
: "自动匹配商品参考图"}
</span>
</div>
</div>
</section>
<div className="generator-footer">
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles />
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
</button>
<div className="cost-line">
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
<strong>预计 {estimateFee(estimateCount)}</strong>
</div>
</div>
</aside>
<section className="result-panel">
<div className="result-topbar">
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
<div className="result-topbar-tools">
<div className="result-tags">
{mode === "cover" ? (
<>
<span>{selectedPlatform?.name || "未选平台"}</span>
<span>{count} 个图位</span>
<span>统一视觉</span>
</>
) : (
<>
<span>{ratio}</span>
<span>{count} </span>
<span>自动保存至成品库</span>
</>
)}
</div>
{mode === "cover" && (
<>
<div className="tb-search-wrap">
{searchOpen && (
<div className="iw-search-box">
<input className="input" autoFocus placeholder="搜索图片提示词" value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} />
{gridQuery && (
<button className="iw-search-clear" type="button" aria-label="清空搜索" onClick={() => setGridQuery("")}>
<X size={13} />
</button>
)}
</div>
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title="搜索" onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
<button className={`search-btn${platformFilter.length ? " active" : ""}`} type="button" title="按平台筛选" onClick={() => setFilterOpen((v) => !v)}>
<SlidersHorizontal size={14} />
{platformFilter.length > 0 && <span className="iw-filter-count">{platformFilter.length}</span>}
</button>
<div className="chip-menu align-right iw-filter-menu">
<div className="iw-filter-h">
<span className="mono">按平台筛选</span>
<button type="button" className="iw-filter-clear" disabled={!platformFilter.length} onClick={() => setPlatformFilter([])}>清空</button>
</div>
<div className="iw-filter-grid">
{PLATFORM_OPTIONS.map((p) => (
<button
type="button"
key={p.id}
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
>
<PlatformLogo p={p} />
<span className="nm">{p.name}</span>
<Check className="ck" size={12} />
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
<div className="iw-filter-notice">
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
<X size={12} />
清空筛选
</button>
</div>
)}
{!hasResults ? (
<div className="result-empty">
<span className="result-empty-icon">{mode === "cover" ? <LayoutGrid /> : <Image />}</span>
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
<p>
{mode === "cover"
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
</p>
</div>
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
<div ref={resultsEndRef} aria-hidden="true" />
</>
)}
</div>
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 </strong></div>
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
</section>
</div>
{/* 模特库全屏弹窗:只选 Model,新增模特后刷新并回填。 */}
<ModelLibrary
open={modelLibOpen}
mode="replace"
onClose={() => setModelLibOpen(false)}
onPick={(assetId, assetName) => {
setPickedIds([assetId]);
setPickedModelName(assetName || "");
loadModels();
setModelLibOpen(false);
}}
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
onUpload={(file) => {
const fd = new FormData();
fd.append("file", file);
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "model_portrait");
return api.uploadAsset(fd).catch(() => null);
}}
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
onRefresh={() => { loadModels(); }}
/>
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
{plOpen && (
<ProductLibraryModal
products={products}
draft={plDraft}
setDraft={setPlDraft}
query={plQuery}
setQuery={setPlQuery}
cat={plCat}
setCat={setPlCat}
coverUrl={productCoverUrl}
onClose={() => setPlOpen(false)}
onConfirm={() => {
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
if (plDraft.length) {
setProductId(plDraft[0]);
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
}
}
}
setPlOpen(false);
}}
navigate={navigate}
/>
)}
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
icon={<Trash2 size={16} />}
detail="删除后可在垃圾桶恢复,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
/* YYX#18:结果卡精简头部 —— 提示词单行折叠(超出给「展开/收起」)+ 灰色张数文案 */
function BatchHeader({ prompt, count, statusText }: { prompt: string; count: number; statusText: string }) {
const [expanded, setExpanded] = useState(false);
const [overflow, setOverflow] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (el) setOverflow(el.scrollWidth > el.clientWidth + 1); // 折叠态(单行)是否被截断
}, [prompt]);
return (
<div className="iw-batch-head">
<div className={`ibh-prompt${expanded ? " expanded" : ""}`} ref={ref}>{prompt || "—"}</div>
{(overflow || expanded) && (
<button type="button" className="ibh-toggle" onClick={() => setExpanded((v) => !v)}>
{expanded ? "收起提示词" : "展开提示词"}
</button>
)}
<div className="ibh-count">
{count} · {statusText}
</div>
</div>
);
}
/* 底部 chat 输入栏 · 参数胶囊(基线 image-optimize .param + 下拉气泡) */
function Pill({
label,
value,
options,
onSelect
}: {
label: string;
value: string;
options: Array<{ id: string; label: string }>;
onSelect: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div
className={`ic-param ${open ? "open" : ""}`}
tabIndex={0}
onBlur={() => setOpen(false)}
>
<button className="ic-param-btn" type="button" onClick={() => setOpen((prev) => !prev)}>
<span className="lbl-mono">{label}</span>
<span>{value}</span>
<ChevronDown size={10} />
</button>
<div className="ic-param-menu">
{options.map((opt) => (
<button
type="button"
key={opt.id}
className={`mi ${opt.label === value || opt.id === value ? "selected" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
onSelect(opt.id);
setOpen(false);
}}
>
{opt.label}
<Check className="mi-check" size={12} />
</button>
))}
</div>
</div>
);
}
/* ════════════════════════════════════════════════
P0④ · 商品库全屏选择器(pl-modal · 基线 model-photo.html .pl-modal)
左 = 分类侧栏;右 = 搜索 toolbar + 商品多选网格(右上勾)+ 底部已选汇总 + 确定。
全 restraint token,不复用 styles.css 旧版 .pl-card(暖米)。
════════════════════════════════════════════════ */
function ProductLibraryModal({
products,
draft,
setDraft,
query,
setQuery,
cat,
setCat,
coverUrl,
onClose,
onConfirm,
navigate
}: {
products: Product[];
draft: string[];
setDraft: (next: string[]) => void;
query: string;
setQuery: (v: string) => void;
cat: string;
setCat: (v: string) => void;
coverUrl: (p: Product) => string;
onClose: () => void;
onConfirm: () => void;
navigate?: (page: Page) => void;
}) {
// Esc 关闭 + body 滚动锁(与 ModelLibrary 一致体感)
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
}, [onClose]);
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
const visible = products.filter((p) => {
if (cat && p.category !== cat) return false;
const q = query.trim().toLowerCase();
if (!q) return true;
return `${p.title} ${p.category || ""} ${p.brand || ""}`.toLowerCase().includes(q);
});
function toggle(id: string) {
setDraft(draft.includes(id) ? draft.filter((x) => x !== id) : [...draft, id]);
}
return createPortal(
<div className="pl-modal-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="pl-modal" role="dialog" aria-modal="true" aria-label="商品库">
<div className="pl-modal-h">
<h2>商品库</h2>
<span className="ct">选择商品 · 可多选</span>
<div className="actions">
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div className="pl-modal-body">
<aside className="pl-side">
<div className="pl-side-h">分类</div>
<div className={`pl-side-item${!cat ? " active" : ""}`} role="button" tabIndex={0} onClick={() => setCat("")}>
全部商品
<span className="ct">{products.length}</span>
</div>
{categories.map((c) => (
<div className={`pl-side-item${cat === c ? " active" : ""}`} key={c} role="button" tabIndex={0} onClick={() => setCat(c)}>
{c}
<span className="ct">{products.filter((p) => p.category === c).length}</span>
</div>
))}
</aside>
<div className="pl-main">
<div className="pl-toolbar">
<div className="search">
<Search size={14} />
<input placeholder="搜索商品 / 分类 / 品牌" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
{navigate && (
<button className="btn-new" type="button" onClick={() => navigate("productCreateUpload")}>
<Plus size={13} />
新建商品
</button>
)}
</div>
<div className="pl-scroll">
{visible.length === 0 ? (
<div className="pl-empty">
<div>没有符合条件的商品</div>
</div>
) : (
<div className="pl-grid">
{visible.map((p) => {
const url = coverUrl(p);
const on = draft.includes(p.id);
return (
<div className={`pl-card${on ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => toggle(p.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(p.id); } }}>
<span className="pl-check"><Check size={11} /></span>
{url ? (
<div className="pl-thumb has-real-media"><img src={url} alt={p.title} loading="lazy" /></div>
) : (
<div className="placeholder pl-thumb"><span className="ph-frame">{p.title.slice(0, 2)}</span></div>
)}
<div className="pl-name">{p.title}</div>
<div className="pl-meta">{p.category || "未分类"}</div>
</div>
);
})}
</div>
)}
</div>
<div className="pl-modal-f">
<span className="summary">已选 <b>{draft.length}</b> 个商品</span>
<button className="btn" type="button" onClick={onClose}>取消</button>
<button className="btn btn-primary" type="button" disabled={draft.length === 0} onClick={onConfirm}>确定 ({draft.length})</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
/* ════════════════════════════════════════════════
模特图方案对比 Demo(纯静态展示 · 像素还原)
variant="A" → public/exact/model-photo-demo-a.html
variant="B" → public/exact/model-photo-demo-b.html
左栏 = 商品空间(搜索 + 最近 6 条 + 全部入口),由真 products 填充,空则回退基线占位。
主区为静态方案展示(A:参数面板 + 结果双栏;B:任务流 + 底部 fixed 参数栏)。
════════════════════════════════════════════════ */
/* 基线左栏占位商品(无真 products 时兜底) */
const DEMO_SIDE_PRODUCTS = [
{ id: "d1", title: "透真补水面膜", category: "美妆个护", batches: 6 },
{ id: "d2", title: "透真清透防晒霜", category: "美妆个护", batches: 3 },
{ id: "d3", title: "南卡 Lite Pro 蓝牙耳机", category: "数码 3C", batches: 2 },
{ id: "d4", title: "滋啦速食牛肉面", category: "食品饮料", batches: 1 },
{ id: "d5", title: "三顿半同款冻干咖啡", category: "食品饮料", batches: 1 },
{ id: "d6", title: "小熊 4L 可视空气炸锅", category: "家居家电", batches: 0 }
];
export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { variant: "A" | "B"; products: Product[]; onBack: () => void; navigate?: (page: Page) => void }) {
// 方案 A/B 是设计展示页(mock 数据,无生成后端);所有动作按钮导向真实工具 / 改本地选中态,杜绝死按钮
const [demoCount, setDemoCount] = useState("4 张");
const [demoRatio, setDemoRatio] = useState("3:4");
const toRealTool = () => onBack();
// 左栏商品空间:优先真 products(最近 6 条),空则回退基线占位
const sideProducts =
products.length > 0
? products.slice(0, 6).map((item, index) => ({
id: item.id,
title: item.title,
category: item.category || "未分类",
batches: DEMO_SIDE_PRODUCTS[index % DEMO_SIDE_PRODUCTS.length].batches
}))
: DEMO_SIDE_PRODUCTS;
const [activeId, setActiveId] = useState(sideProducts[0].id);
const active = sideProducts.find((item) => item.id === activeId) || sideProducts[0];
const totalCount = products.length > 0 ? products.length : 24;
// 共享左栏(两版一致)
const side = (
<aside className="dm-side">
<div className="dm-side-h">
<div className="ti-row">
<span className="ti">商品空间</span>
<button className="add" type="button" title="新建商品" onClick={() => navigate?.("productCreateUpload")}>
<Plus size={11} />
</button>
</div>
<div className="dm-search">
<Search size={13} />
<input type="text" placeholder="搜索商品 / 分类" />
</div>
</div>
<div className="dm-prod-list">
{sideProducts.map((item) => (
<button
type="button"
key={item.id}
className={`dm-prod ${item.id === active.id ? "active" : ""}`}
onClick={() => setActiveId(item.id)}
>
<div className="thumb">主图</div>
<div className="body">
<div className="nm">{item.title}</div>
<div className="sub">{item.category} · {item.batches} </div>
</div>
</button>
))}
</div>
<button className="dm-all" type="button" onClick={() => navigate?.("products")}>
<LayoutGrid size={12} />
全部商品
<span className="ct">{totalCount} </span>
<ArrowRight className="arrow" size={12} />
</button>
</aside>
);
// ── 返回(基线无,挂在主区头部,保留 onBack)──
const backBtn = (
<button className="dm-back" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回模特图
</button>
);
if (variant === "A") {
return (
<div className="model-demo dm-a">
<div className="dm-banner">
// DEMO · 方案 A · <b>商品 = 项目空间</b>。左栏仅商品空间:搜索 + 最近 6 条 + <b>全部商品</b>兜底入口;
历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。
</div>
<div className="dm-grid">
{side}
<section className="dm-main">
{/* 顶部 · 商品名 + 统计 */}
<div className="dm-main-h">
<div className="cur">
<div className="crumb">商品空间</div>
<h2>{active.title}</h2>
</div>
<div className="stats">
<span>本商品 <b>6</b> </span>
<span className="sep">·</span>
<span>累计 <b>22</b> 张图</span>
<span className="sep">·</span>
<span>最近 <b>3 分钟前</b></span>
</div>
{backBtn}
</div>
{/* 参数面板 + 结果双栏 */}
<div className="dm-body">
{/* 左 · 参数面板 */}
<div className="dm-form">
<div className="dm-form-scroll">
<div className="dm-field">
<div className="dm-field-h">
选择模特<span className="opt">(已锁定商品 · {active.title})</span>
</div>
<div className="dm-models">
{["Ava", "Zoe", "Ben", "Lin", "Mia"].map((name, index) => (
<div className={`dm-model ${index === 0 ? "selected" : ""}`} key={name}>
<div className="ph">{name} · 3:4</div>
<div className="nm">{name}</div>
</div>
))}
<div className="dm-model add">
<Plus size={18} />
</div>
</div>
</div>
<div className="dm-field">
<div className="dm-field-h">生成张数</div>
<div className="dm-chip-row">
{["1 张", "2 张", "4 张", "8 张"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === demoCount ? "active" : ""}`} onClick={() => setDemoCount(label)}>
{label}
</button>
))}
</div>
</div>
<div className="dm-field">
<div className="dm-field-h">画面比例</div>
<div className="dm-chip-row">
{["1:1", "3:4", "9:16", "16:9"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === demoRatio ? "active" : ""}`} onClick={() => setDemoRatio(label)}>
{label}
</button>
))}
</div>
</div>
<div className="dm-field">
<div className="dm-field-h">补充提示词<span className="opt">(选填)</span></div>
<textarea className="dm-textarea" placeholder="例:户外阳光、敷面膜的特写、白底产品摄影" />
</div>
</div>
<div className="dm-form-cta">
<div className="dm-cost">
<span>预估扣费 <span className="v"> 20 积分/</span></span>
<span>余额以账户中心为准</span>
</div>
<button className="dm-gen" type="button" onClick={toRealTool}>
<Sparkles size={15} />
立即生成 · {active.title} × Ava
</button>
</div>
</div>
{/* 右 · 结果(当前商品全部批次) */}
<div className="dm-result">
<div className="dm-result-h">
<span className="ti">最近批次 · Ava × 4 </span>
<span className="sub">3 分钟前 · 已完成</span>
</div>
{/* 批次 1 */}
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">4×</div>
<div className="meta">
<div className="nm">Ava × 4 </div>
<div className="info">
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 3 分钟前{" "}
<span className="sep">·</span> 20 积分/
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
<div className="dm-cell" key={n}>
<div className="ph">Ava · #{n}</div>
<span className="tag">3:4</span>
</div>
))}
</div>
</div>
{/* 批次 2 */}
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">4×</div>
<div className="meta">
<div className="nm">Zoe × 4 </div>
<div className="info">
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 12 分钟前{" "}
<span className="sep">·</span> 20 积分/
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
<div className="dm-cell" key={n}>
<div className="ph">Zoe · #{n}</div>
<span className="tag">3:4</span>
</div>
))}
</div>
</div>
{/* 批次 3 · 生成中 */}
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">2×</div>
<div className="meta">
<div className="nm">Ben × 2 </div>
<div className="info">
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 刚刚{" "}
<span className="sep">·</span> 生成中
</div>
</div>
<div className="ops">
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
<div className="dm-cell"><div className="ph">生成中…</div></div>
<div className="dm-cell"><div className="ph">生成中…</div></div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
);
}
// ── variant === "B" · v2:任务流主区 + 底部 fixed 参数栏 ──
return (
<div className="model-demo dm-b">
<div className="dm-banner">
// DEMO v2 · 方案 A · <b>商品空间 + 任务流主区</b>。左栏只保留商品空间(搜索+最近6条+全部入口),
任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed ( image-optimize)
</div>
<div className="dm-grid">
{side}
<section className="dm-main">
{/* 顶部 标题 + stats + toolbar */}
<div className="dm-main-h">
<div className="crumb">商品空间 · 模特上身图</div>
<div className="title-row">
<h2>{active.title}</h2>
{backBtn}
</div>
<div className="row">
<div className="stats">
<span>{active.category}</span><span className="sep">·</span>
<span>本商品 <b>6</b> </span><span className="sep">·</span>
<span>累计 <b>22</b> 张图</span><span className="sep">·</span>
<span>最近 <b>3 分钟前</b></span>
</div>
<span className="spacer" />
<div className="dm-tb">
<button className="icbtn" type="button" title="搜索批次" onClick={toRealTool}><Search size={13} /></button>
<button className="chip" type="button" onClick={toRealTool}>时间 <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}>状态 <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}>模特 <ChevronDown size={10} /></button>
</div>
</div>
</div>
{/* 任务流 */}
<div className="dm-stream">
<div className="dm-day-h">
<span>今天</span>
<span className="ct">3 · 10 </span>
</div>
{/* 批次 1 */}
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">4×</div>
<div className="meta">
<div className="nm">Ava × 4 <span className="pill ok stat-pill"><span className="dot" />已完成</span></div>
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>3 分钟前</span><span className="sep">·</span>
<span>20 积分</span>
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
<div className="dm-cell" key={n}><div className="ph">Ava · #{n}</div><span className="tag">3:4</span></div>
))}
</div>
</div>
{/* 批次 2 */}
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">4×</div>
<div className="meta">
<div className="nm">Zoe × 4 <span className="pill ok stat-pill"><span className="dot" />已完成</span></div>
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>12 分钟前</span><span className="sep">·</span>
<span>20 积分</span>
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
<div className="dm-cell" key={n}><div className="ph">Zoe · #{n}</div><span className="tag">3:4</span></div>
))}
</div>
</div>
{/* 批次 3 · 生成中 */}
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">2×</div>
<div className="meta">
<div className="nm">Ben × 2 <span className="pill info stat-pill"><span className="dot" />生成中</span></div>
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>刚刚</span><span className="sep">·</span>
<span>20 积分</span>
</div>
</div>
<div className="ops">
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
<div className="dm-cell gen"><div className="ph">生成中…</div></div>
<div className="dm-cell gen"><div className="ph">生成中…</div></div>
</div>
</div>
{/* 昨天 */}
<div className="dm-day-h">
<span>昨天</span>
<span className="ct">2 · 8 </span>
</div>
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">4×</div>
<div className="meta">
<div className="nm">Lin × 4 <span className="pill ok stat-pill"><span className="dot" />已完成</span></div>
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>昨天 18:24</span><span className="sep">·</span>
<span>20 积分</span>
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
</div>
<div className="dm-batch-grid">
{[1, 2, 3, 4].map((n) => (
<div className="dm-cell" key={n}><div className="ph">Lin · #{n}</div><span className="tag">3:4</span></div>
))}
</div>
</div>
{/* 更早 */}
<div className="dm-day-h">
<span>更早</span>
<span className="ct">1 · 2 · 1 失败</span>
</div>
<div className="dm-batch">
<div className="dm-batch-h">
<div className="pic">2×</div>
<div className="meta">
<div className="nm">Ava × 2 <span className="pill err stat-pill"><span className="dot" />失败</span></div>
<div className="info">
<span>3:4</span><span className="sep">·</span>
<span>2 天前</span>
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="删除"><Trash2 size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
<div className="dm-cell err"><div className="ph">失败 · 点重跑</div></div>
<div className="dm-cell err"><div className="ph">失败 · 点重跑</div></div>
</div>
</div>
</div>
{/* 底部 fixed 参数面板 */}
<div className="dm-param-wrap">
<div className="dm-param">
<button className="pchip active" type="button" onClick={toRealTool}>
<span className="lbl-mono">模特</span>
<span>Ava</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono">张数</span>
<span>4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono">比例</span>
<span>3:4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono">补充提示词</span>
<span className="muted">+ 添加</span>
</button>
<span className="spacer" />
<span className="meta-right">预估 <span className="v">20 积分/</span></span>
<button className="gen-btn" type="button" onClick={toRealTool}>
<Sparkles size={14} />
生成 · {active.title} × Ava
</button>
</div>
</div>
</section>
</div>
</div>
);
}