Files
yingqing/core/frontend/src/routes/ai-tools.tsx
T
zycandClaude Opus 4.8 217d24f316 fix: 平台套图工作台 YYX 测试清单一轮修复(13 条)
- 平台改单选;结果分组/标签名对应所选平台
- 工作台结果区按当前商品隔离(productBatches)
- 平台套图去掉「详情排版」,只出平台头图
- 部分失败给失败占位 +「重跑这张」单张重跑;部分成功批次恢复加入资产库
- 提示词框加大;生图模型选择移入框内作胶囊靠左置底
- 去掉生成按钮下方说明文字
- 工作台「新建商品」改弹抽屉(复用 ProductCreateDrawer),不再跳商品库
- 右上搜索恒显全部平台、改为筛选结果图(按提示词)
- 结果卡参考即梦:显示提示词/平台/张数

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:18:21 +08:00

2675 lines
127 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, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
Bookmark,
Check,
ChevronDown,
Download,
Grid2X2,
ImagePlus,
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Plus,
Quote,
RefreshCw,
Search,
Sparkles,
Trash2,
Users,
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product } from "../types";
import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
// 而 task_type 区分不了——cover 与 image 都是 product_image)
const MODE_LABEL: Record<string, string> = {
model: "模特上身图",
cover: "平台套图",
image: "图片创作"
};
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, 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] = file.preview_url;
}
return map;
}, [assets]);
const cards = [
{
page: "modelPhoto" as Page,
tag: "[ MODEL · TRY-ON ]",
title: "模特上身图",
desc: "选择模特,AI 生成商品模特上身效果图",
cost: "≈ ¥0.30 / 张"
},
{
page: "platformCover" as Page,
tag: "[ PLATFORM · KIT ]",
title: "平台套图",
desc: "选择平台模板,AI 生成电商平台套图",
cost: "≈ ¥0.50 / 张"
},
{
page: "imageOptimize" as Page,
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
cost: "≈ ¥0.40 / 组"
}
];
// 任务中心只看「工作台图片生成」(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; mode: string;
count: 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];
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 cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
}
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
return out;
}, [aiTasks, taskImage]);
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const b of taskBatches) {
if (b.status === "ok") acc.ok += 1;
else if (b.status === "err") acc.err += 1;
else acc.gen += 1;
}
return acc;
}, [taskBatches]);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
const [taskPage, setTaskPage] = useState(1);
// 批次详情弹窗:点批次卡 → 按 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(".chip-wrap")) setOpenChip(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
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.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);
return (
<div className="asset-factory">
<div className="page-head">
<div>
<h1>图片生成</h1>
<div className="sub">
<span className="mono">// 一键生成</span>
<span>·</span>
<span>电商视觉素材,提升内容制作效率</span>
</div>
</div>
</div>
<div className="factory-hero">
{cards.map((card) => (
<article className="factory-card with-corners" key={card.title}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="factory-body">
<div className="factory-text">
<span className="factory-tag">{card.tag}</span>
<div className="factory-title">{card.title}</div>
<div className="factory-desc">{card.desc}</div>
{/* CTA 放在 factory-text 内(对齐 asset-factory.html):按钮紧跟描述,
而非被 factory-body 的 height:100% + margin-top:auto 顶到卡片最底部 */}
<div className="factory-cta">
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
开始生成
<ArrowRight size={14} />
</button>
<span className="cost">[ {card.cost} ]</span>
</div>
</div>
</div>
</article>
))}
</div>
<div className="section-h">
<h2>任务中心</h2>
<span className="sub-mono">
// {taskBatches.length} 批 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
</span>
</div>
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
<div className="tabs" id="tc-tabs">
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}>全部 <span className="count">{taskBatches.length}</span></div>
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}>生成中 <span className="count">{counts.gen}</span></div>
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}>已完成 <span className="count">{counts.ok}</span></div>
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}>失败 <span className="count">{counts.err}</span></div>
</div>
<div className="toolbar">
<div className="search-inline">
<Search size={14} />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{TIME_OPTS.find((o) => o.value === timeFilter)?.label !== "全部时间" ? TIME_OPTS.find((o) => o.value === timeFilter)?.label : "时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter || "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
</div>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{t}
</div>
))}
</div>
</div>
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && (
<button className="clear-filters" type="button" onClick={() => { setFilter("all"); setQuery(""); setTimeFilter("all"); setTypeFilter(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
清空筛选
</button>
)}
<span className="spacer" />
<div className="view-toggle">
<button type="button" className={view === "grid" ? "active" : ""} data-view="grid" onClick={() => setView("grid")}>
<Grid2X2 size={13} />
网格
</button>
<button type="button" className={view === "list" ? "active" : ""} data-view="list" onClick={() => setView("list")}>
<List size={13} />
列表
</button>
</div>
</div>
<div className="result-meta">
// 显示 {paged.length} / {visible.length} 批
</div>
{tasksLoading && aiTasks.length === 0 ? (
<SkeletonRows count={5} />
) : aiTasks.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO TASKS YET</div>
<div>还没有任务,去上方选一个工序开始生成吧</div>
</div>
) : visible.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO MATCH</div>
<div>没有符合筛选条件的任务</div>
</div>
) : view === "grid" ? (
<div className="history-grid">
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<article className="task-card history-card" 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={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
<div className="history-body">
<div className="history-name">{batch.label}</div>
<div className="history-type">// {batch.count} 张</div>
<div className="history-foot">
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="task-list-view">
<table className="t">
<thead>
<tr>
<th style={{ width: "42%" }}>任务</th>
<th style={{ width: 160 }}>进度</th>
<th>状态</th>
<th style={{ width: 120 }}>创建于</th>
<th style={{ width: 48 }} />
</tr>
</thead>
<tbody>
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<tr key={batch.key} role="button" tabIndex={0} title="查看本批图片" style={{ cursor: "pointer" }}
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<td>
<div className="task-name-cell">
<div className={`placeholder task-thumb${batch.cover ? " has-img" : ""}`}>
{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label.slice(0, 2)}</span>}
</div>
<div>
<div className="task-name">{batch.label}</div>
<div className="task-sub">// {batch.count} 张</div>
</div>
</div>
</td>
<td>
{batch.status === "info" ? (
<div className="task-list-prog">
<div className="bar">
<span style={{ width: "60%" }} />
</div>
<span className="pct">生成中</span>
</div>
) : (
<span className="muted-2 mono" style={{ fontSize: 12 }}>
{batch.status === "ok" ? "已完成" : "—"}
</span>
)}
</td>
<td>
<span className={`pill ${batch.status}`}>
<span className="dot" />
{statusLabel}
</span>
</td>
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(batch.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{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 }} />
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
</div>
<div className="modal-b">
{batchLoading ? (
<div className="task-empty"><div className="mono">// LOADING…</div></div>
) : batchImgs.length === 0 ? (
<div className="task-empty"><div className="mono">// NO IMAGE</div><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;
tag: string;
desc: string;
ratio: string;
promptTemplate: (productTitle: string) => string;
}
> = {
image: {
title: "图片创作",
tag: "[ IMAGE · STUDIO ]",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。",
ratio: "1:1",
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
},
model: {
title: "模特上身图",
tag: "[ MODEL · TRY-ON ]",
desc: "选择模特和商品,生成电商模特上身图。",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "选择平台模板,一键生成电商平台头图 / 主图。",
ratio: "4:5",
// 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 GEN_MODEL_OPTIONS: { value: string; label: string }[] = [
{ value: "volcano", label: "火山 Seedream" },
{ value: "gpt-image", label: "gpt-image-2" },
];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
const IMAGE_SUGGESTIONS = [
"一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风",
"极简北欧风格的茶杯,白底,自然柔光,产品摄影",
"国风水墨海报,主体一只白鹤立于水边,留白构图",
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
];
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
const FALLBACK_MODELS = [
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
{ id: "m2", name: "Luna", tag: "亚洲·22岁·学生" },
{ id: "m3", name: "Mia", tag: "混血·28岁·OL" },
{ id: "m4", name: "Zoe", tag: "亚洲·30岁·健身" }
];
/* 平台套图 · 平台卡(基线 platform-cover · logo 配色用 token 化 className) */
const PLATFORM_OPTIONS = [
{ id: "dy", name: "抖音电商", logo: "抖" },
{ id: "tb", name: "淘宝", logo: "淘" },
{ id: "tm", name: "天猫", logo: "猫" },
{ id: "jd", name: "京东", logo: "京" },
{ id: "pdd", name: "拼多多", logo: "拼" },
{ id: "xhs", name: "小红书", logo: "红" },
{ id: "ks", name: "快手", logo: "快" },
{ id: "sph", name: "视频号", logo: "视" },
{ id: "amz", name: "亚马逊", logo: "a" },
{ id: "al", name: "1688", logo: "阿" }
];
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态、加入资产库切换;支持多批并行跑。
行31(并发提交) / 行32(重跑追加新批次 + 气泡菜单 + 加入/取消切换) / 行33(多批次持久化恢复)。 */
type GenBatch = {
id: string;
prompt: string;
ratio: string;
count: number;
status: "generating" | "done" | "failed";
results: Asset[];
/** 批次级:是否已"加入资产库"(可来回切) */
adopted: boolean;
ts: number;
/** 该批次归属的商品(用于按商品分组,各商品一个导航头) */
productId?: string;
productTitle?: string;
/** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */
modelId?: string;
/** 该批次选中的模特展示名(导航头显示) */
modelName?: string;
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
platformIds?: string[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
refs?: { name: string; url: string }[];
/** 该批次已提交的生图任务 id:落盘后切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
pendingIds?: string[];
};
export function ImageWorkbenchPage({
mode,
products,
modelConfigs,
onBack,
navigate,
onGenerate,
onResume,
initialProductId,
onProductChange
}: {
mode: WorkMode;
products: Product[];
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
onProductChange?: (productId: string) => void;
}) {
const meta = MODE_META[mode];
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
const product = products.find((item) => item.id === productId) || products[0];
// 图片创作(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("");
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
const [genModel, setGenModel] = useState<string>(() => {
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
});
const [genModelOpen, setGenModelOpen] = useState(false);
useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]);
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
const [pickedIds, setPickedIds] = useState<string[]>([]);
// 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填)
const [actorLibOpen, setActorLibOpen] = useState(false);
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
const [pickedModelName, setPickedModelName] = useState("");
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
const [plOpen, setPlOpen] = useState(false);
const [plDraft, setPlDraft] = useState<string[]>([]);
const [plQuery, setPlQuery] = useState("");
const [plCat, setPlCat] = useState("");
// gen-card 就地反馈(§4.18:采用反馈不走全局弹窗,改卡内 show-feedback 1.5s)
const [feedbackKey, setFeedbackKey] = useState("");
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
const [openMore, setOpenMore] = useState("");
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
const [batches, setBatches] = useState<GenBatch[]>([]);
/* ── 图片创作「对话」(真实体,后端 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 [convLoading, setConvLoading] = useState(false);
// 对话操作失败的可见提示(替代原来的静默吞错)
const [convError, setConvError] = useState("");
// 重命名:正在改名的对话 id + 草稿
const [renamingId, setRenamingId] = useState("");
const [renameDraft, setRenameDraft] = useState("");
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 生成结果图片放大预览
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
const [gridQuery, setGridQuery] = useState("");
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
const [tbOpen, setTbOpen] = useState<"" | "time" | "model">("");
const [searchOpen, setSearchOpen] = useState(false);
// 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到)
const [showAllModels, setShowAllModels] = useState(false);
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
const [prodQuery, setProdQuery] = useState("");
const visibleProducts = products.filter((item) => {
const q = prodQuery.trim().toLowerCase();
if (!q) return true;
return `${item.title} ${item.category || ""}`.toLowerCase().includes(q);
});
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
return p.cover_preview_url || primary?.preview_url || "";
};
function pickReference(event: ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files || []);
if (!files.length) return;
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
event.target.value = "";
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ActorLibrary 弹窗同源,逻辑不动。 */
const [personAssets, setPersonAssets] = useState<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 || ""));
}
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
function flashFeedback(key: string) {
setFeedbackKey(key);
window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500);
}
// 点击空白收起单图「更多」气泡
useEffect(() => {
if (!openMore) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".gen-img-more")) setOpenMore(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openMore]);
const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4);
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
const canGenerate =
prompt.trim().length > 0 &&
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */
const batchKey = `airshelf:imgwb:batches:${mode}`;
function persistBatches(list: GenBatch[]) {
try {
// 已出图/失败的批次照常落盘;仍在跑(generating)但已拿到 pendingIds 的批次也落盘(保留 generating + pendingIds),
// 这样切走再回来每一批都能按各自的 pendingIds 续轮询,不再「数量少 / 只恢复一批」(PMC#5/#10)。
// 还没拿到 pendingIds 的占位批次(刚点、尚未提交成功)不落盘,避免恢复出一个永远空转的壳。
const slim = list
.filter((b) => b.status !== "generating" || (b.pendingIds && b.pendingIds.length))
.map((b) => ({ ...b, refs: undefined })); // refs 含本地 blob url,不落盘
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级 */
}
}
/* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */
// 后端对话任务流 → 前端批次:按 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", "compensating"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
const assets = list.flatMap((t) => t.assets || []);
const allTerminal = list.every((t) => TERMINAL.has(t.status));
const status: GenBatch["status"] = assets.length > 0 ? "done" : allTerminal ? "failed" : "generating";
const withId = assets.filter((a) => a.id);
result.push({
id: key,
prompt: list[0]?.prompt || "",
ratio: list[0]?.ratio || meta.ratio,
count: list.length,
status,
results: assets,
adopted: withId.length > 0 && withId.every((a) => a.in_library),
ts: new Date(list[0]?.created_at || Date.now()).getTime(),
// 该批用过的参考图(后端按 reference_image_ids 解析回 {name,url}),切换/刷新后仍可见
refs: (list[0]?.reference_images || []).length ? list[0].reference_images : undefined,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
return result.sort((a, b) => a.ts - b.ts);
}
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
const loadConvBatches = useCallback(async (convId: string) => {
try {
const res = await api.conversationTasks(convId);
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 (!r?.assets) return;
setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x)));
});
}
}
} catch {
setBatches([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, onResume]);
// 切换对话:置为 active 并回填它的批次
const selectConversation = useCallback((convId: string) => {
if (convId === activeConvRef.current) return;
setActiveConvId(convId);
setRenamingId("");
void loadConvBatches(convId);
}, [loadConvBatches]);
// 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流
async function handleNewConversation() {
try {
const conv = await api.createConversation({ mode, product: product?.id || null });
setConvError("");
setConversations((prev) => [conv, ...prev]);
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) {
const remaining = conversations.filter((c) => c.id !== convId);
setConversations(remaining);
if (convId === activeConvId) {
const nextActive = remaining[0]?.id || "";
setActiveConvId(nextActive);
if (nextActive) void loadConvBatches(nextActive);
else setBatches([]);
}
try { await api.deleteConversation(convId); } catch { void loadConversations(); }
}
// 列表加载:image 模式才用对话(model/cover 走商品空间布局)。
// autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。
const loadConversations = useCallback(async (autoSelect = true) => {
if (mode !== "image") return;
setConvLoading(true);
try {
const res = await api.listConversations(mode);
setConversations(res.results);
if (autoSelect && res.results.length && !activeConvRef.current) {
setActiveConvId(res.results[0].id);
void loadConvBatches(res.results[0].id);
}
} catch {
setConversations([]);
} finally {
setConvLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, loadConvBatches]);
// 只在挂载 / 切 mode 时加载一次对话列表(不依赖 callback 身份,否则 onResume 每次渲染变更会触发反复重拉)
useEffect(() => { void loadConversations(); }, [mode]); // 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[];
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
refs?: { name: string; url: string; file?: File; assetId?: string }[];
}) {
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newBatch: GenBatch = {
id: batchId,
prompt: opts.prompt,
ratio: opts.ratio,
count: opts.count,
status: "generating",
results: [],
adopted: false,
ts: Date.now(),
productId: opts.productId,
productTitle: opts.productTitle,
modelId: opts.modelId,
modelName: opts.modelName,
platformIds: opts.platformIds,
// 批次头回显「参考了哪些图」(只存名+预览,不存 file)
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
};
setBatches((prev) => [...prev, newBatch]);
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);
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
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, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
setActiveConvId(convId);
void loadConversations(false);
}
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
persistBatches(next);
return next;
});
} catch {
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
persistBatches(next);
return next;
});
}
}
async function runGenerate() {
if (!canGenerate) return;
// 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回——
// 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。
if (mode === "image" && !activeConvRef.current) {
try {
const conv = await api.createConversation({ mode, title: prompt.trim().slice(0, 24) || undefined });
activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到
setActiveConvId(conv.id);
setConversations((prev) => [conv, ...prev]);
} catch {
/* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */
}
}
const base = {
prompt: prompt.trim(),
ratio,
count: candidateCount,
productId: product?.id,
productTitle: product?.title
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
for (const pid of pickedIds) {
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
void startBatch({ ...base, prompt: platName ? `${base.prompt}${platName}平台头图版式` : base.prompt, platformIds: [pid] });
}
return;
}
void startBatch({
...base,
modelId: mode === "model" ? pickedIds[0] : undefined,
modelName: mode === "model" ? pickedModelName : undefined,
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
refs: mode === "image" && refImages.length ? refImages : undefined
});
// 参考图已交给本批,清空输入栏待下次(批次头会保留这次用过的参考图)
if (mode === "image") setRefImages([]);
}
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
async function rerunBatch(src: GenBatch) {
await startBatch({
prompt: src.prompt,
ratio: src.ratio,
count: src.count,
// 重跑归属原批次的商品,而非当前选中商品
productId: src.productId ?? product?.id,
productTitle: src.productTitle ?? product?.title,
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds
});
}
/* 删除整个批次(行32③:批次气泡"删除当前批次") */
function removeBatch(id: string) {
setBatches((prev) => {
const next = prev.filter((b) => b.id !== id);
persistBatches(next);
return next;
});
}
/* 删除批次内单张图(行32③:单图气泡"删除") */
function removeImageFromBatch(batchId: string, assetId: string) {
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId ? { ...b, results: b.results.filter((a) => a.id !== assetId) } : b
);
persistBatches(next);
return next;
});
}
/* 单图 in_library 写库 + 本地乐观更新 + 失败回滚的公共逻辑。
真源是后端 Asset.in_library:加入 → 出现在资产库列表;取消 → 从列表移出。batch.adopted 同步成「整批是否全已入库」。 */
function applyLibraryState(batchId: string, assetIds: string[], next: boolean) {
const ids = new Set(assetIds.filter(Boolean));
if (!ids.size) return;
const sync = (b: GenBatch, want: (a: Asset) => boolean): GenBatch => {
const results = b.results.map((a) => (ids.has(a.id) ? { ...a, in_library: want(a) } : a));
const withId = results.filter((a) => a.id);
return { ...b, results, adopted: withId.length > 0 && withId.every((a) => a.in_library) };
};
setBatches((prev) => {
const updated = prev.map((b) => (b.id === batchId ? sync(b, () => next) : b));
persistBatches(updated);
return updated;
});
void Promise.all([...ids].map((id) => api.setAssetLibrary(id, next))).catch(() => {
// 写库失败:回滚到改前状态(按 id 翻回 !next)
setBatches((prev) => {
const reverted = prev.map((b) => (b.id === batchId ? sync(b, () => !next) : b));
persistBatches(reverted);
return reverted;
});
});
}
/* 加入资产库 / 取消加入(批次级:全部已入库 → 整批移出,否则整批加入) */
function toggleAdoptBatch(batchId: string) {
const batch = batches.find((b) => b.id === batchId);
if (!batch) return;
const withId = batch.results.filter((a) => a.id);
if (!withId.length) return;
const next = !withId.every((a) => a.in_library);
if (next) flashFeedback(`${batchId}:${withId[0].id}`);
applyLibraryState(batchId, withId.map((a) => a.id), next);
}
/* 加入资产库 / 取消(单图级:只改被点的那一张,§4.18 就地反馈不弹全局窗) */
function toggleAdoptImage(batchId: string, assetId: string) {
if (!assetId) return;
const asset = batches.find((b) => b.id === batchId)?.results.find((a) => a.id === assetId);
if (!asset) return;
const next = !asset.in_library;
if (next) flashFeedback(`${batchId}:${assetId}`);
applyLibraryState(batchId, [assetId], next);
}
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
async function regenSingleImage(src: GenBatch) {
await startBatch({
prompt: src.prompt,
ratio: src.ratio,
count: 1,
productId: src.productId ?? product?.id,
productTitle: src.productTitle ?? product?.title,
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds
});
}
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
useEffect(() => {
let cancelled = false;
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架
if (mode === "image") return;
// 模特/平台套图:从多批次残留恢复「全部」批次 —— 已出图/失败的直接回显;仍在跑(带 pendingIds)的
// 恢复成 generating 并各自按本批 pendingIds 续轮询。这样切走再回来,多批/多平台都在,不再「数量少 / 分类丢」(PMC#5/#10)。
try {
const raw = localStorage.getItem(batchKey);
if (raw) {
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
const all = saved.batches || [];
if (all.length) {
setBatches(all);
if (onResume) {
for (const b of all) {
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
onResume(mode, b.pendingIds).then((res) => {
if (cancelled) return;
setBatches((prev) => {
const next = prev.map((x) =>
x.id === b.id ? { ...x, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || x.results } : x
);
persistBatches(next);
return next;
});
});
}
}
}
}
}
} catch {
/* 残留解析失败则忽略 */
}
return () => {
cancelled = true;
};
// 仅在挂载/切换 mode 时恢复一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// 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));
}
return list;
}, [batches, productId, mode, gridQuery]);
const hasResults = productBatches.length > 0;
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
② 中央 .gen-image-feedback 就地反馈(采用后亮 1.5s)③ 左上「已采用」绿角标。 */
function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating";
const n = batch.results.length || batch.count;
const cols = n >= 4 ? 4 : 2;
const ratioBatchVar = batch.ratio.replace(":", " / ");
return (
<div
className="gen-images"
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
>
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败,
给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */}
{Array.from({ length: Math.max(batch.count, batch.results.length) }).map((_, index) => {
const asset = batch.results[index];
const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || "";
const inLib = !!asset?.in_library;
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`;
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} 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)}>
<RefreshCw size={12} />
重跑这张
</button>
</span>
) : (
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
)}
</div>
)}
{/* ③ 左上「已入库」绿角标(该图已加入资产库时常驻) */}
{url && inLib && (
<span className="gen-adopt-badge">
<Check size={11} />
已入库
</span>
)}
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
{url && (
<div className="gen-image-feedback" aria-hidden={!showFeedback}>
<Check size={20} />
<span>已加入资产库</span>
</div>
)}
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
{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>
<div className="gen-img-more">
<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" : ""}`}>
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
<Bookmark size={13} />
{inLib ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(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}>
{/* 行34:每批一个合并导航头——商品/模特(平台)+ 结果(数量·比例·状态)左对齐一块;
原右上「1 批·比例」与「N×」徽标系重复信息,已剔除 */}
<div className="iw-pv-h">
<Quote className="quote-icon" />
{/* YYX#13:参考即梦 —— 显示用户输入的提示词 / 选择的平台 / 生成的张数 */}
<div className="pv-line">
<span className="k">提示词</span>
<span className="v pv-prompt">{batch.prompt || "—"}</span>
</div>
{mode === "cover" && (
<div className="pv-line">
<span className="k">平台</span>
<span className="v">
{batch.platformIds && batch.platformIds.length
? PLATFORM_OPTIONS.filter((p) => batch.platformIds!.includes(p.id))
.map((p) => p.name)
.join("、")
: "未选择"}
</span>
</div>
)}
{mode === "model" && batch.modelName && (
<div className="pv-line">
<span className="k">模特</span>
<span className="v">{batch.modelName}</span>
</div>
)}
<div className="pv-line">
<span className="k">张数</span>
<span className="v">
{batch.count} · {batch.ratio} · {generating ? "生成中" : failed ? "失败" : "已完成"}
{!generating && !failed && batch.results.some((a) => a.in_library)
? ` · ${batch.results.filter((a) => a.in_library).length} 张已入库`
: ""}
</span>
</div>
</div>
<div className="gen-card gen-batch-card">
{renderBatchGrid(batch)}
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
<div className="gen-batch-actions">
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => rerunBatch(batch)}>
<RefreshCw size={13} />
重跑
</button>
<button className="btn btn-sm" type="button" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
{batch.adopted ? <X size={13} /> : <Check size={13} />}
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
{/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */}
<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" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
<Bookmark size={13} />
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(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 ? (
<>
<span className={`cg-logo p-logo-${plat.id}`}>{plat.logo}</span>
<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>
);
})}
</>
);
}
/* ════════════════════════════════════════════════
mode === "image" → 对话流形态(基线 image-optimize · design.md §4.13)
左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏
════════════════════════════════════════════════ */
if (mode === "image") {
return (
<div className="image-workbench iw-chat">
{/* 左 · 会话列表 */}
<aside className="ic-side">
<div className="ic-side-h">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回
</button>
</div>
<button className="ic-new-conv" type="button" onClick={handleNewConversation}>
<Plus size={13} />
新对话
</button>
{convError && <div className="ic-conv-error">{convError}</div>}
<div className="ic-side-sec">最近</div>
<div className="ic-conv-list">
{conversations.length === 0 ? (
<div className="ic-conv-empty">
{convLoading ? "加载中…" : "还没有最近会话"}
<br />
<span className="mono">// NO HISTORY</span>
</div>
) : (
conversations.map((conv) => (
<div
className={`ic-conv-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); }}
>
<div className={`thumb ${conv.id === activeConvId ? "default" : ""}`}>
<ImagePlus size={13} />
</div>
{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(); handleDeleteConversation(conv.id); }}
>
<Trash2 size={12} />
</button>
</span>
</div>
))
)}
</div>
</aside>
{/* 右 · 对话流 + 底部输入栏 */}
<section className="ic-main">
<div className="ic-stream">
{hasResults ? (
<div className="ic-stream-inner">
{/* 行31/32/33:每个批次独立一条对话,各自展示自己的图与操作 */}
{batches.map((batch) => (
<div className="ic-msg" key={batch.id}>
<div className="ic-msg-prompt">
<span className="quote">
<Quote size={13} />
</span>
<div className="pt">
<div className="pt-text">{batch.prompt}</div>
{batch.refs && batch.refs.length > 0 && (
<div className="pt-refs">
{batch.refs.map((r, i) => (
<span className="pt-ref" key={`${r.name}-${i}`} title={r.name}>
<img src={r.url} alt={r.name} />
</span>
))}
<span className="pt-ref-label">参考图 ×{batch.refs.length}</span>
</div>
)}
<div className="pt-tags">
<span className="meta-chip">{batch.ratio}</span>
<span className="sep">·</span>
<span className="meta-chip">{batch.count} </span>
<span className="sep">·</span>
<span className="meta-chip">{batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}</span>
</div>
</div>
</div>
<div className="gen-card">{renderBatchGrid(batch)}</div>
<div className="gen-card-actions">
<button className="btn btn-sm" type="button" disabled={batch.status === "generating"} onClick={() => rerunBatch(batch)}>
<RefreshCw size={13} />
重跑
</button>
<button className="btn btn-sm" type="button" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
{batch.adopted ? <X size={13} /> : <Check size={13} />}
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
<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" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
<Bookmark size={13} />
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(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>
) : (
<div className="ic-empty">
<div className="ic">
<Sparkles size={28} />
</div>
<div className="badge">// IMAGE · STUDIO</div>
<h2>开始你的创作</h2>
<p>输入想法、剧本或上传参考,和 Agent 一起把灵感变成电商视觉素材。</p>
<div className="examples">
{IMAGE_SUGGESTIONS.map((text) => (
<button className="ex" type="button" key={text} onClick={() => setPrompt(text)}>
{text}
</button>
))}
</div>
</div>
)}
</div>
{/* 底部 · chat 输入栏(textarea + 比例/风格/张数 + 发送) */}
<div className="ic-input-wrap">
<div className="ic-input">
<div className="ic-input-top">
<button className="add-btn" type="button" title="上传参考图(可多张)" onClick={() => refInputRef.current?.click()}>
<Plus size={22} />
</button>
<input ref={refInputRef} type="file" accept="image/*" multiple hidden onChange={pickReference} />
{refImages.map((img, index) => (
<span className="meta-chip" key={`${img.name}-${index}`} style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={img.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{img.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImages((prev) => prev.filter((_, i) => i !== index))}>×</button>
</span>
))}
</div>
<textarea
className="ic-input-text"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="输入想法、剧本或上传参考,和 Agent 一起创作"
/>
<div className="ic-input-bottom">
<Pill
label="模型"
value={GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}
options={GEN_MODEL_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
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}
/>
<span className="right-meta">
预估 <span className="val">¥{(candidateCount * 0.1).toFixed(2)}</span>
</span>
<button className="send-btn" type="button" onClick={runGenerate} disabled={!canGenerate} title="生成">
<ArrowRight size={15} />
</button>
</div>
</div>
</div>
</section>
</div>
);
}
/* ════════════════════════════════════════════════
mode === "model" / "cover" → 商品列表栏 + 表单 + 预览
(基线 model-photo / platform-cover)
════════════════════════════════════════════════ */
const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
return (
<div className="image-workbench iw-prod">
<div className="iw-layout">
{/* 最左 · 商品空间 */}
<aside className="iw-prod-space">
<div className="iw-side-top">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回
</button>
</div>
<div className="iw-ps-search">
<Search size={13} />
<input placeholder="搜索商品 / 分类" value={prodQuery} onChange={(event) => setProdQuery(event.target.value)} />
</div>
<div className="iw-list-h">
<span className="mono">// 商品空间</span>
{navigate && (
<button className="new-prod" type="button" title="新建商品" onClick={() => navigate("productCreateUpload")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<span>新建商品</span>
</button>
)}
</div>
<div className="iw-ps-list">
{products.length === 0 ? (
<div className="iw-ps-empty">
还没有商品
<br />
// NO PRODUCTS
</div>
) : visibleProducts.length === 0 ? (
<div className="iw-ps-empty">
没有匹配的商品
<br />
// NO MATCH
</div>
) : (
visibleProducts.slice(0, 12).map((item) => {
const cover = productCoverUrl(item);
return (
<button
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
type="button"
key={item.id}
onClick={() => setProductId(item.id)}
>
{cover ? (
<div className="thumb has-real-media"><img src={cover} alt={item.title} loading="lazy" /></div>
) : (
<div className="placeholder thumb">
<span className="ph-frame">{item.title.slice(0, 2)}</span>
</div>
)}
<div className="body">
<div className="nm">{item.title}</div>
<div className="sub">// {item.category || "未分类"}</div>
</div>
</button>
);
})
)}
</div>
</aside>
{/* 主区 · 头部 + 参数/结果双栏 */}
<section className="iw-main">
<div className="iw-main-h">
<div className="cur-title">
<span className="crumb">// 商品空间</span>
<span className={`nm ${product ? "" : "placeholder"}`}>
{product?.title || "未选择 · 请在左侧商品空间选一个"}
</span>
</div>
{/* YYX#10:生图模型选择已移到下方提示词输入框内(胶囊靠左置底),此处不再放 */}
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
<LayoutGrid size={13} />
商品库
</button> */}
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索图片提示词"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
<button className={`tb-chip${gridSort === "name" ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "time" ? "" : "time"))}><span className="lbl">{gridSort === "name" ? "按名称" : "时间"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${gridSort === "recent" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("recent"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最近添加</div>
<div className={`mi${gridSort === "name" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("name"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>按名称</div>
</div>
</div>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "model" ? " open" : ""}`} data-filter="model">
<button className={`tb-chip${gridQuery ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "model" ? "" : "model"))}><span className="lbl">{gridQuery || "模特"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${!gridQuery ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridQuery(""); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部模特</div>
{(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => (
<div className={`mi${gridQuery === nm ? " selected" : ""}`} key={nm} role="button" tabIndex={0} onClick={() => { setGridQuery(nm); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{nm}</div>
))}
</div>
</div>
</>
)}
</div>
<div className="iw-main-body">
{/* 左 · 参数表单 */}
<div className="iw-form">
{mode === "model" ? (
<div className="iw-step">
<div className="iw-step-h">
<span className="num">1</span>
<span className="title">选择模特</span>
{/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */}
<button className="iw-actor-lib-btn" type="button" onClick={() => setActorLibOpen(true)}>
<Users size={12} />
模特库
</button>
{personAssets.length > 6 && (
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部 (${personAssets.length})`}</span>
)}
</div>
<div className="model-grid">
{personAssets.length > 0
? personAssets
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || "")))
.slice(0, showAllModels ? undefined : 6)
.map((item) => {
const url = item.files?.[0]?.preview_url;
return (
<button
type="button"
key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id, item.name)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="m-thumb">
{url ? (
<img className="m-thumb-img" src={url} alt={item.name} loading="lazy" />
) : (
<div className="placeholder">
<span className="ph-frame">{item.name.slice(0, 4)}</span>
</div>
)}
<div className="m-name">{item.name}</div>
</div>
</button>
);
})
: FALLBACK_MODELS
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0))
.map((item) => (
<button
type="button"
key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id, item.name)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="placeholder m-thumb">
<span className="ph-frame">{item.name}</span>
<div className="m-name">{item.name}</div>
</div>
</button>
))}
</div>
</div>
) : (
<div className="iw-step">
<div className="iw-step-h">
<span className="num">1</span>
<span className="title">选择平台</span>
</div>
<div className="platform-grid">
{/* YYX#12:平台网格恒显全部,右上搜索只筛右侧结果图,不再把平台过滤没 */}
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`platform-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id)}
>
<span className="p-check" />
<span className={`p-logo p-logo-${item.id}`}>{item.logo}</span>
<span className="p-name">{item.name}</span>
</button>
))}
</div>
</div>
)}
<div className="iw-step">
<div className="iw-step-h">
<span className="num">2</span>
<span className="title">生成设置</span>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 生成数量{mode === "model" ? " (每模特)" : ""}</div>
<div className="pill-row">
{countOptions.map((value) => (
<button
type="button"
key={value}
className={`opt ${count === value ? "active" : ""}`}
onClick={() => setCount(value)}
>
{value}
</button>
))}
</div>
</div>
{mode === "model" && (
<div className="iw-sub">
<div className="iw-sub-h">// 图片比例</div>
<div className="pill-row">
{ratioOptions.map((value) => (
<button
type="button"
key={value}
className={`opt ${!ratioManual && ratio === value ? "active" : ""}`}
onClick={() => { setRatioManual(false); setRatio(value); }}
>
{value}
</button>
))}
<button
type="button"
className={`opt ${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}
placeholder="宽"
value={ratioW}
onChange={(event) => {
const w = event.target.value;
setRatioW(w);
if (w && ratioH) setRatio(`${w}:${ratioH}`);
}}
/>
<span className="sep">:</span>
<input
className="input"
type="number"
min={1}
placeholder="高"
value={ratioH}
onChange={(event) => {
const h = event.target.value;
setRatioH(h);
if (ratioW && h) setRatio(`${ratioW}:${h}`);
}}
/>
</div>
)}
</div>
)}
<div className="iw-sub">
<div className="iw-sub-h">// 提示词 · 描述你想要的画面</div>
{/* YYX#7:提示词框加大;YYX#10:生图模型选择作胶囊嵌在框内靠左置底 */}
<div className="iw-prompt-box">
<textarea
className="textarea iw-prompt-text"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="iw-prompt-bar">
<div className={`chip-wrap iw-model-pill${genModelOpen ? " open" : ""}`} data-filter="genmodel">
<button className="chip" type="button" title="生图模型" onClick={() => setGenModelOpen((v) => !v)}>
<Sparkles size={12} />
<span className="chip-label">{GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}</span>
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{GEN_MODEL_OPTIONS.map((o) => (
<div className={`mi${genModel === o.value ? " selected" : ""}`} key={o.value} role="button" tabIndex={0} onClick={() => { setGenModel(o.value); setGenModelOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{o.label}
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="iw-cta">
{/* 行31:并发提交——有批次在跑时仍可再次提交,只在提交瞬间不重复触发 */}
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles size={13} />
立即生成 (预估 ¥{(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})
</button>
{/* YYX#9:去掉生成按钮下方那行说明文字 */}
</div>
</div>
{/* 右 · 结果预览(行31/32/33:多批次列表) */}
<div className="iw-preview">
{!hasResults ? (
<div className="iw-pv-empty">
<div className="mono">// EMPTY STATE</div>
<div className="title">还没有生成结果</div>
<div className="hint">
先选商品、选{mode === "model" ? "模特" : "平台"},点击 <b>立即生成</b> 后,效果图会出现在这里
</div>
</div>
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头。按商品隔离 → productBatches */}
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
</>
)}
</div>
</div>
</section>
</div>
{/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
<ActorLibrary
open={actorLibOpen}
mode="replace"
assets={personAssets}
onClose={() => setActorLibOpen(false)}
onPick={(assetId, assetName) => {
setPickedIds([assetId]);
setPickedModelName(assetName || "");
loadModels();
setActorLibOpen(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", "person");
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, 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}
/>
)}
</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 滚动锁(与 ActorLibrary 一致体感)
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 className="mono">// NO MATCH</div>
<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"> ¥1.20</span></span>
<span>余额 ¥327.40</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> ¥1.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>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark 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> ¥1.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>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark 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>¥1.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>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark 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>¥1.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>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark 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>¥0.60</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>¥1.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>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark 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">¥1.20</span> · 余额 <span className="v">¥327.40</span></span>
<button className="gen-btn" type="button" onClick={toRealTool}>
<Sparkles size={14} />
生成 · {active.title} × Ava
</button>
</div>
</div>
</section>
</div>
</div>
);
}