后端: - 独立生图三类归类:model+product→model_tryon(模特上身图,引用模特库,不送审) / cover→platform_kit(平台套图) / image→free_create(自由创作);生成演员(model 无 product)仍→person(视频角色,留期3) - 成组:每次提交一个 batch_id 串起整批,落 asset.metadata;另记 mode + model_entity_id(上身图溯源模特库) - generate-image 端点透传 model_entity_id;资产库 _tab_q+summary 加 tryon/kits/creations 三类桶 - 单测 StandaloneCategoryTests 直跑 worker 验四态归类全绿(绕开异步 .delay) 前端: - 资产库 library.tsx 加三类 tab(模特上身图/平台套图/自由创作)+ 计数 + 筛选维度 - ai-tools 模特选择器数据源 person→模特库(listModels 映射,选中传形象图当参考 = 引用模特库),ActorLibrary 同源 - api.ts submitGenerateImage 加 model_entity_id 验收:tsc+build 全绿;无头 0 console error(资产库三类 tab 各归类正确、模特选择器 5 张取自模特库) 基线既有 3 失败(StandaloneImageReferenceTests 异步漂移)零新增 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2246 lines
103 KiB
TypeScript
2246 lines
103 KiB
TypeScript
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import type { ChangeEvent } from "react";
|
||
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
Bookmark,
|
||
Check,
|
||
ChevronDown,
|
||
Download,
|
||
Grid2X2,
|
||
ImagePlus,
|
||
LayoutGrid,
|
||
List,
|
||
MoreHorizontal,
|
||
Plus,
|
||
Quote,
|
||
RefreshCw,
|
||
Search,
|
||
Sparkles,
|
||
Trash2,
|
||
Users,
|
||
WandSparkles,
|
||
X
|
||
} from "lucide-react";
|
||
import type { AITask, Asset, ModelConfig, ModelEntity, Product } from "../types";
|
||
import { api } from "../api";
|
||
import { ActorLibrary } from "../components/actor-library";
|
||
import { SkeletonRows } from "../components/loading";
|
||
import { MediaLightbox } from "../components/overlays";
|
||
import { Pager } from "../components/pager";
|
||
import type { Page } from "./route-config";
|
||
import { statusPill } from "./stage-config";
|
||
import "../ai-tools-page.css";
|
||
|
||
const TASK_TYPE_LABEL: Record<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>
|
||
{/* 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">
|
||
// {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} loading="lazy" decoding="async" /> : <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} loading="lazy" decoding="async" /> : <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;
|
||
/** 该批次选中的模特展示名(导航头显示) */
|
||
modelName?: string;
|
||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
||
platformIds?: string[];
|
||
};
|
||
|
||
export function ImageWorkbenchPage({
|
||
mode,
|
||
products,
|
||
modelConfigs,
|
||
onBack,
|
||
navigate,
|
||
onGenerate,
|
||
onResume,
|
||
initialProductId,
|
||
onProductChange
|
||
}: {
|
||
mode: WorkMode;
|
||
products: Product[];
|
||
modelConfigs: ModelConfig[];
|
||
onBack: () => void;
|
||
navigate?: (page: Page) => void;
|
||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string }) => Promise<{ assets: Asset[] } | null>;
|
||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
||
initialProductId?: string;
|
||
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
|
||
onProductChange?: (productId: string) => void;
|
||
}) {
|
||
const meta = MODE_META[mode];
|
||
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
||
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
||
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
|
||
const product = products.find((item) => item.id === productId) || products[0];
|
||
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
|
||
const [ratio, setRatio] = useState(meta.ratio);
|
||
const [style, setStyle] = useState("auto");
|
||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
||
const [pickedIds, setPickedIds] = useState<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[]>([]);
|
||
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"));
|
||
|
||
/* 模特卡数据来源:期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(() => {
|
||
if (product) setPrompt(meta.promptTemplate(product.title));
|
||
// mode 或商品切换都重置 prompt 与默认比例
|
||
setRatio(meta.ratio);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [productId, mode]);
|
||
|
||
// 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组)
|
||
function togglePick(id: string, name?: string) {
|
||
if (mode === "cover") {
|
||
setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||
return;
|
||
}
|
||
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
|
||
setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
|
||
}
|
||
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
|
||
function flashFeedback(key: string) {
|
||
setFeedbackKey(key);
|
||
window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500);
|
||
}
|
||
// 点击空白收起单图「更多」气泡
|
||
useEffect(() => {
|
||
if (!openMore) return;
|
||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".gen-img-more")) setOpenMore(""); };
|
||
document.addEventListener("click", close);
|
||
return () => document.removeEventListener("click", close);
|
||
}, [openMore]);
|
||
|
||
const ratioVar = ratio.replace(":", " / ");
|
||
const candidateCount = Math.max(1, Number(count) || 4);
|
||
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
|
||
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
||
const canGenerate =
|
||
prompt.trim().length > 0 &&
|
||
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
|
||
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
|
||
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
|
||
const anyGenerating = batches.some((b) => b.status === "generating");
|
||
|
||
/* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。
|
||
已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */
|
||
const batchKey = `airshelf:imgwb:batches:${mode}`;
|
||
function persistBatches(list: GenBatch[]) {
|
||
try {
|
||
// 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死)
|
||
const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status }));
|
||
localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() }));
|
||
} catch {
|
||
/* localStorage 不可用时静默降级 */
|
||
}
|
||
}
|
||
|
||
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
||
async function startBatch(opts: {
|
||
prompt: string;
|
||
ratio: string;
|
||
count: number;
|
||
productId?: string;
|
||
productTitle?: string;
|
||
modelId?: string;
|
||
modelName?: string;
|
||
platformIds?: string[];
|
||
}) {
|
||
const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||
const newBatch: GenBatch = {
|
||
id: batchId,
|
||
prompt: opts.prompt,
|
||
ratio: opts.ratio,
|
||
count: opts.count,
|
||
status: "generating",
|
||
results: [],
|
||
adopted: false,
|
||
ts: Date.now(),
|
||
productId: opts.productId,
|
||
productTitle: opts.productTitle,
|
||
modelId: opts.modelId,
|
||
modelName: opts.modelName,
|
||
platformIds: opts.platformIds
|
||
};
|
||
setBatches((prev) => [...prev, newBatch]);
|
||
try {
|
||
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio });
|
||
setBatches((prev) => {
|
||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b));
|
||
persistBatches(next);
|
||
return next;
|
||
});
|
||
} catch {
|
||
setBatches((prev) => {
|
||
const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b));
|
||
persistBatches(next);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
|
||
async function runGenerate() {
|
||
if (!canGenerate) return;
|
||
const base = {
|
||
prompt: prompt.trim(),
|
||
ratio,
|
||
count: candidateCount,
|
||
productId: product?.id,
|
||
productTitle: product?.title
|
||
};
|
||
if (mode === "cover") {
|
||
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
|
||
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
|
||
for (const pid of pickedIds) {
|
||
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
|
||
void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台套图版式` : base.prompt, platformIds: [pid] });
|
||
}
|
||
return;
|
||
}
|
||
void startBatch({
|
||
...base,
|
||
modelId: mode === "model" ? pickedIds[0] : undefined,
|
||
modelName: mode === "model" ? pickedModelName : undefined
|
||
});
|
||
}
|
||
|
||
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
|
||
async function rerunBatch(src: GenBatch) {
|
||
await startBatch({
|
||
prompt: src.prompt,
|
||
ratio: src.ratio,
|
||
count: src.count,
|
||
// 重跑归属原批次的商品,而非当前选中商品
|
||
productId: src.productId ?? product?.id,
|
||
productTitle: src.productTitle ?? product?.title,
|
||
modelId: src.modelId,
|
||
modelName: src.modelName,
|
||
platformIds: src.platformIds
|
||
});
|
||
}
|
||
|
||
/* 删除整个批次(行32③:批次气泡"删除当前批次") */
|
||
function removeBatch(id: string) {
|
||
setBatches((prev) => {
|
||
const next = prev.filter((b) => b.id !== id);
|
||
persistBatches(next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/* 删除批次内单张图(行32③:单图气泡"删除") */
|
||
function removeImageFromBatch(batchId: string, assetId: string) {
|
||
setBatches((prev) => {
|
||
const next = prev.map((b) =>
|
||
b.id === batchId ? { ...b, results: b.results.filter((a) => a.id !== assetId) } : b
|
||
);
|
||
persistBatches(next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/* 加入资产库 / 取消加入(批次级来回切;§4.18:就地反馈,亮在该批首图上,不弹全局窗) */
|
||
function toggleAdoptBatch(batchId: string) {
|
||
setBatches((prev) => {
|
||
const target = prev.find((b) => b.id === batchId);
|
||
const willAdopt = !target?.adopted;
|
||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||
persistBatches(next);
|
||
const firstId = target?.results[0]?.id;
|
||
if (willAdopt && firstId) flashFeedback(`${batchId}:${firstId}`);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/* 加入资产库 / 取消(单图级,§4.18:单图气泡"加入资产库",就地反馈不弹全局窗) */
|
||
function toggleAdoptImage(batchId: string, assetId: string) {
|
||
// 单图采用以批次级 adopted 表达(后端无单图采用接口),反馈落在被点的那张图上
|
||
setBatches((prev) => {
|
||
const target = prev.find((b) => b.id === batchId);
|
||
const willAdopt = !target?.adopted;
|
||
const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b));
|
||
persistBatches(next);
|
||
if (willAdopt) flashFeedback(`${batchId}:${assetId}`);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
/* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */
|
||
async function regenSingleImage(src: GenBatch) {
|
||
await startBatch({
|
||
prompt: src.prompt,
|
||
ratio: src.ratio,
|
||
count: 1,
|
||
productId: src.productId ?? product?.id,
|
||
productTitle: src.productTitle ?? product?.title,
|
||
modelId: src.modelId,
|
||
modelName: src.modelName,
|
||
platformIds: src.platformIds
|
||
});
|
||
}
|
||
|
||
/* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次;
|
||
再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
// 1) 多批次结果回显
|
||
try {
|
||
const raw = localStorage.getItem(batchKey);
|
||
if (raw) {
|
||
const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number };
|
||
if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) {
|
||
const restored = (saved.batches || []).filter((b) => b.results && b.results.length);
|
||
if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const })));
|
||
}
|
||
}
|
||
} catch {
|
||
/* 残留解析失败则忽略 */
|
||
}
|
||
// 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报)
|
||
try {
|
||
const raw = localStorage.getItem(`airshelf:imgwb:${mode}`);
|
||
if (!raw) return;
|
||
const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
|
||
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return;
|
||
const pending = saved.pending || [];
|
||
if (pending.length && onResume) {
|
||
const batchId = `b-resume-${Date.now()}`;
|
||
setBatches((prev) => {
|
||
// 已由多批次残留恢复过结果就不重复加占位
|
||
if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev;
|
||
return [
|
||
...prev,
|
||
// 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变,
|
||
// 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。
|
||
{ id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle }
|
||
];
|
||
});
|
||
onResume(mode, pending)
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
setBatches((prev) => {
|
||
const next = prev.map((b) =>
|
||
b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b
|
||
);
|
||
persistBatches(next);
|
||
return next;
|
||
});
|
||
});
|
||
}
|
||
} catch {
|
||
/* 残留解析失败则忽略 */
|
||
}
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// 仅在挂载/切换 mode 时恢复一次
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mode]);
|
||
|
||
const hasResults = batches.length > 0;
|
||
|
||
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
|
||
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除)
|
||
② 中央 .gen-image-feedback 就地反馈(采用后亮 1.5s)③ 左上「已采用」绿角标。 */
|
||
function renderBatchGrid(batch: GenBatch) {
|
||
const generating = batch.status === "generating";
|
||
const n = batch.results.length || batch.count;
|
||
const cols = n >= 4 ? 4 : 2;
|
||
const ratioBatchVar = batch.ratio.replace(":", " / ");
|
||
return (
|
||
<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 }) => {
|
||
const cellKey = `${batch.id}:${assetId || key}`;
|
||
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
|
||
return (
|
||
<div className={`gen-image ${generating && !url ? "gen" : ""}${batch.adopted && 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>
|
||
) : (
|
||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* ③ 左上「已采用」绿角标(批次 adopted 时常驻) */}
|
||
{url && batch.adopted && (
|
||
<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} />
|
||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||
</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:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
|
||
<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 === "model" && batch.modelName && (
|
||
<div className="pv-line">
|
||
<span className="k">模特</span>
|
||
<span className="v">{batch.modelName}</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>
|
||
)}
|
||
</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>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ── 平台套图 · 多平台分组结果(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={() => { 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>
|
||
</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>
|
||
{/* 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>
|
||
<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, item.name)}
|
||
>
|
||
<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)} />
|
||
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头 */}
|
||
{mode === "cover" ? renderCoverGrouped(batches) : renderBatchCards(batches)}
|
||
</>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|