Files
yingqing/core/frontend/src/routes/ai-tools.tsx
T
zycandClaude Opus 4.8 9fce7e8fff feat(core): 平台套图/模特上身图生成设置加可编辑提示词输入框
生成结合提示词出图,默认按商品名预填模板,可自由编辑

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

1919 lines
88 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, useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
Bookmark,
Check,
ChevronDown,
Download,
Grid2X2,
ImagePlus,
LayoutGrid,
List,
MoreHorizontal,
Plus,
Quote,
RefreshCw,
Search,
Sparkles,
Trash2,
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { MediaLightbox, SuccessModal } from "../components/overlays";
import { Pager } from "../components/pager";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
const TASK_TYPE_LABEL: Record<string, string> = {
model: "模特上身图",
platform: "平台套图",
image: "图片创作",
model_photo: "模特上身图",
platform_cover: "平台套图",
image_optimize: "图片创作"
};
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); });
api.assetsPage({ asset_type: "image", source: "ai_generated", pageSize: 200 })
.then((res) => { if (alive) setAssets(res.results); })
.catch(() => { if (alive) setAssets([]); });
return () => { alive = false; };
}, []);
const taskImage = useMemo(() => {
const map: Record<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 / 组"
}
];
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const task of aiTasks) {
const pill = statusPill(task.status);
if (pill === "ok") acc.ok += 1;
else if (pill === "err") acc.err += 1;
else if (pill === "info") acc.gen += 1;
}
return acc;
}, [aiTasks]);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
const [taskPage, setTaskPage] = useState(1);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
];
const visible = aiTasks.filter((task) => {
const pill = statusPill(task.status);
if (filter === "gen" && pill !== "info") return false;
if (filter === "ok" && pill !== "ok") return false;
if (filter === "err" && pill !== "err") return false;
if (typeFilter && task.task_type !== typeFilter) return false;
if (timeFilter !== "all" && task.created_at) {
const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
});
// 筛选条件变化时回到第 1 页
useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]);
const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE));
const taskCurPage = Math.min(taskPage, taskTotalPages);
const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE);
return (
<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>
</div>
<div className="factory-cta">
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
开始生成
<ArrowRight size={13} />
</button>
<span className="cost">[ {card.cost} ]</span>
</div>
</div>
</article>
))}
</div>
<div className="section-h">
<h2>任务中心</h2>
<span className="sub-mono">
// {aiTasks.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">{aiTasks.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 ? TASK_TYPE_LABEL[typeFilter] || 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>{TASK_TYPE_LABEL[t] || 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((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
const img = taskImage[task.id];
return (
<article className="task-card history-card" key={task.id}>
<div className={`placeholder${img ? " has-img" : ""}`}>{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}</div>
<div className="history-body">
<div className="history-name">{typeLabel}</div>
<div className="history-type">// {task.task_type}</div>
<div className="history-foot">
<span className="mono">{(task.created_at || "").slice(0, 10)}</span>
<span className={`pill ${pill}`}><span className="dot" />{statusText(task.status)}</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((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
const img = taskImage[task.id];
return (
<tr key={task.id}>
<td>
<div className="task-name-cell">
<div className={`placeholder task-thumb${img ? " has-img" : ""}`}>
{img ? <img src={img} alt={typeLabel} /> : <span className="ph-frame">{task.id.slice(0, 4)}</span>}
</div>
<div>
<div className="task-name">{typeLabel}</div>
<div className="task-sub">// {task.task_type}</div>
</div>
</div>
</td>
<td>
{pill === "info" ? (
<div className="task-list-prog">
<div className="bar">
<span style={{ width: "60%" }} />
</div>
<span className="pct">60%</span>
</div>
) : (
<span className="muted-2 mono" style={{ fontSize: 12 }}>
{pill === "ok" ? "已完成" : "—"}
</span>
)}
</td>
<td>
<span className={`pill ${pill}`}>
<span className="dot" />
{statusText(task.status)}
</span>
</td>
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(task.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</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",
promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版`
}
};
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
const COUNT_OPTIONS = ["1", "2", "4"];
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
const MODEL_COUNT_OPTIONS = ["4", "8", "12"];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
const IMAGE_SUGGESTIONS = [
"一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风",
"极简北欧风格的茶杯,白底,自然柔光,产品摄影",
"国风水墨海报,主体一只白鹤立于水边,留白构图",
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
];
/* 图片创作 · 风格胶囊(基线 image-optimize STYLES) */
const STYLE_OPTIONS = [
{ id: "auto", label: "默认" },
{ id: "realistic", label: "写实" },
{ id: "cinematic", label: "电影感" },
{ id: "anime", label: "动漫" },
{ id: "oil", label: "油画" },
{ id: "cn-ink", label: "国风水墨" }
];
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
const FALLBACK_MODELS = [
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
{ id: "m2", name: "Luna", tag: "亚洲·22岁·学生" },
{ id: "m3", name: "Mia", tag: "混血·28岁·OL" },
{ id: "m4", name: "Zoe", tag: "亚洲·30岁·健身" }
];
/* 平台套图 · 平台卡(基线 platform-cover · logo 配色用 token 化 className) */
const PLATFORM_OPTIONS = [
{ id: "dy", name: "抖音电商", logo: "抖" },
{ id: "tb", name: "淘宝", logo: "淘" },
{ id: "tm", name: "天猫", logo: "猫" },
{ id: "jd", name: "京东", logo: "京" },
{ id: "pdd", name: "拼多多", logo: "拼" },
{ id: "xhs", name: "小红书", logo: "红" },
{ id: "ks", name: "快手", logo: "快" },
{ id: "sph", name: "视频号", logo: "视" },
{ id: "amz", name: "亚马逊", logo: "a" },
{ id: "al", name: "1688", logo: "阿" }
];
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态、加入资产库切换;支持多批并行跑。
行31(并发提交) / 行32(重跑追加新批次 + 气泡菜单 + 加入/取消切换) / 行33(多批次持久化恢复)。 */
type GenBatch = {
id: string;
prompt: string;
ratio: string;
count: number;
status: "generating" | "done" | "failed";
results: Asset[];
/** 批次级:是否已"加入资产库"(可来回切) */
adopted: boolean;
ts: number;
/** 该批次归属的商品(用于按商品分组,各商品一个导航头) */
productId?: string;
productTitle?: string;
/** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */
modelId?: string;
};
export function ImageWorkbenchPage({
mode,
products,
modelConfigs,
onBack,
navigate,
onGenerate,
onResume,
initialProductId,
onProductChange
}: {
mode: WorkMode;
products: Product[];
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string }) => Promise<{ assets: Asset[] } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
onProductChange?: (productId: string) => void;
}) {
const meta = MODE_META[mode];
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
const product = products.find((item) => item.id === productId) || products[0];
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
const [style, setStyle] = useState("auto");
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 行29:模特/平台改单选(原 string[] 多选 → 单个 id;沿用数组结构最小改动,长度恒为 0/1)
const [pickedIds, setPickedIds] = useState<string[]>([]);
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
const [batches, setBatches] = useState<GenBatch[]>([]);
// 加入资产库成功提示(行32④)
const [adoptNotice, setAdoptNotice] = useState<{ count: number } | null>(null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
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 file = event.target.files?.[0];
if (!file) return;
setRefImage({ name: file.name, url: URL.createObjectURL(file) });
event.target.value = "";
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
/* 模特卡数据来源:服务端按 category=person 懒加载(不再吃全局 assets 全量数组);
无则回退到基线占位模特卡 Ava/Luna/Mia/Zoe */
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
useEffect(() => {
let alive = true;
api.assetsPage({ category: "person", pageSize: 200 })
.then((res) => { if (alive) setPersonAssets(res.results); })
.catch(() => { if (alive) setPersonAssets([]); });
return () => { alive = false; };
}, []);
useEffect(() => {
if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置 prompt 与默认比例
setRatio(meta.ratio);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
// 行29:单选——再点同一张取消,点别张替换(长度恒为 0 或 1)
function togglePick(id: string) {
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
}
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));
const anyGenerating = batches.some((b) => b.status === "generating");
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */
const batchKey = `airshelf:imgwb:batches:${mode}`;
function persistBatches(list: GenBatch[]) {
try {
// 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死)
const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status }));
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级 */
}
}
async function runGenerate() {
if (!canGenerate) return;
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newBatch: GenBatch = {
id: batchId,
prompt: prompt.trim(),
ratio,
count: candidateCount,
status: "generating",
results: [],
adopted: false,
ts: Date.now(),
productId: product?.id,
productTitle: product?.title,
modelId: mode === "model" ? pickedIds[0] : undefined
};
// 追加新批次(行32②:不覆盖,在下面新增一行)
setBatches((prev) => [...prev, newBatch]);
try {
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id, model_id: mode === "model" ? pickedIds[0] : undefined, ratio });
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
: b
);
persistBatches(next);
return next;
});
} catch {
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
persistBatches(next);
return next;
});
}
}
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
async function rerunBatch(src: GenBatch) {
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newBatch: GenBatch = {
id: batchId,
prompt: src.prompt,
ratio: src.ratio,
count: src.count,
status: "generating",
results: [],
adopted: false,
ts: Date.now(),
// 重跑归属原批次的商品,而非当前选中商品
productId: src.productId ?? product?.id,
productTitle: src.productTitle ?? product?.title,
modelId: src.modelId
};
setBatches((prev) => [...prev, newBatch]);
try {
const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: src.productId ?? product?.id, model_id: src.modelId, ratio: src.ratio });
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId
? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] }
: b
);
persistBatches(next);
return next;
});
} catch {
setBatches((prev) => {
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
persistBatches(next);
return next;
});
}
}
/* 删除整个批次(行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;
});
}
/* 加入资产库 / 取消加入(行32④:批次级来回切 + 成功提示) */
function toggleAdoptBatch(batchId: string) {
setBatches((prev) => {
const target = prev.find((b) => b.id === batchId);
const willAdopt = !target?.adopted;
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
persistBatches(next);
if (willAdopt && target) setAdoptNotice({ count: target.results.length });
return next;
});
}
/* 加入资产库 / 取消(单图级,行32④:单图气泡"加入资产库",来回切) */
function toggleAdoptImage(batchId: string, _assetId: string) {
// 单图采用以批次级 adopted 表达(后端无单图采用接口),提示单张
setBatches((prev) => {
const target = prev.find((b) => b.id === batchId);
const willAdopt = !target?.adopted;
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
persistBatches(next);
if (willAdopt) setAdoptNotice({ count: 1 });
return next;
});
}
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
useEffect(() => {
let cancelled = false;
// 1) 多批次结果回显
try {
const raw = localStorage.getItem(batchKey);
if (raw) {
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
const restored = (saved.batches || []).filter((b) => b.results && b.results.length);
if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const })));
}
}
} catch {
/* 残留解析失败则忽略 */
}
// 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报)
try {
const raw = localStorage.getItem(`airshelf:imgwb:${mode}`);
if (!raw) return;
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return;
const pending = saved.pending || [];
if (pending.length && onResume) {
const batchId = `b-resume-${Date.now()}`;
setBatches((prev) => {
// 已由多批次残留恢复过结果就不重复加占位
if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev;
return [
...prev,
// 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变,
// 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。
{ id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle }
];
});
onResume(mode, pending)
.then((res) => {
if (cancelled) return;
setBatches((prev) => {
const next = prev.map((b) =>
b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b
);
persistBatches(next);
return next;
});
});
}
} catch {
/* 残留解析失败则忽略 */
}
return () => {
cancelled = true;
};
// 仅在挂载/切换 mode 时恢复一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
const hasResults = batches.length > 0;
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
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}
>
{(batch.results.length
? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id }))
: Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" }))
).map(({ key, index, url, assetId }) => (
<div className={`gen-image ${generating && !url ? "gen" : ""}`} 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 ? (
// 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字
<span className="gen-loading" aria-label="生成中">
<span className="spinner" aria-hidden />
</span>
) : (
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
)}
</div>
)}
{url && (
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
{/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */}
<div className="gen-img-more">
<button className="gen-img-btn" type="button" title="更多">
<MoreHorizontal size={14} />
</button>
<div className="gen-bubble">
<button type="button" className="gen-bubble-item" onClick={() => toggleAdoptImage(batch.id, assetId)}>
<Bookmark size={13} />
{batch.adopted ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => 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:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
<div className="iw-pv-h">
<Quote className="quote-icon" />
<div className="pv-meta">
<b>1 </b>
{mode === "model" ? ` · ${batch.ratio}` : ""}
</div>
<div className="pv-line">
<span className="k">商品</span>
<span className="v">{batchProductTitle(batch)}</span>
</div>
{mode === "cover" && (
<div className="pv-line">
<span className="k">平台</span>
<span className="v">
{pickedIds.length
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
.map((p) => p.name)
.join("、")
: "未选择"}
</span>
</div>
)}
</div>
<div className="gen-card gen-batch-card">
<div className="gen-batch-h">
<span className="b-pic">{batch.count}×</span>
<div className="b-meta">
<div className="b-nm">{batch.count} · {batch.ratio}</div>
<div className="b-info">
{generating ? "生成中" : failed ? "失败" : "已完成"}
{batch.adopted && !generating && !failed ? " · 已加入资产库" : ""}
</div>
</div>
</div>
{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>
);
})}
</>
);
}
/* ════════════════════════════════════════════════
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={() => { setBatches([]); persistBatches([]); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
<Plus size={13} />
新对话
</button>
<div className="ic-side-sec">默认</div>
<div className="ic-conv-list">
<div className="ic-conv-item active">
<div className="thumb default">
<ImagePlus size={13} />
</div>
<span className="nm">默认创作</span>
</div>
</div>
<div className="ic-side-sec">最近</div>
<div className="ic-conv-list">
<div className="ic-conv-empty">
还没有最近会话
<br />
<span className="mono">// NO HISTORY</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>
<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/*" hidden onChange={pickReference} />
{refImage && (
<span className="meta-chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={refImage.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{refImage.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImage(null)}>×</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={ratio}
options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))}
onSelect={setRatio}
/>
<Pill
label="风格"
value={STYLE_OPTIONS.find((s) => s.id === style)?.label || "默认"}
options={STYLE_OPTIONS}
onSelect={setStyle}
/>
<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>
<SuccessModal
open={!!adoptNotice}
title="加入资产库"
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
close={() => setAdoptNotice(null)}
/>
</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>
<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>
{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)}
>
<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>
<div className="m-name">{item.name}</div>
<div className="m-tag">// 真人模特</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)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="placeholder m-thumb">
<span className="ph-frame">{item.name}</span>
</div>
<div className="m-name">{item.name}</div>
<div className="m-tag">{item.tag}</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">
{PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).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 ${ratio === value ? "active" : ""}`}
onClick={() => setRatio(value)}
>
{value}
</button>
))}
</div>
</div>
)}
<div className="iw-sub">
<div className="iw-sub-h">// 提示词 · 描述你想要的画面</div>
<textarea
className="textarea"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
</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>
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
</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)} />
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */}
{renderBatchCards(batches)}
</>
)}
</div>
</div>
</section>
</div>
<SuccessModal
open={!!adoptNotice}
title="加入资产库"
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
close={() => setAdoptNotice(null)}
/>
</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>
);
}
/* ════════════════════════════════════════════════
模特图方案对比 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>
);
}