2873 lines
140 KiB
TypeScript
2873 lines
140 KiB
TypeScript
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import type { ChangeEvent } from "react";
|
||
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
ArrowUpRight,
|
||
Check,
|
||
ChevronDown,
|
||
Download,
|
||
Grid2X2,
|
||
Image,
|
||
ImageOff,
|
||
ImagePlus,
|
||
LayoutGrid,
|
||
PackageSearch,
|
||
List,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
RefreshCw,
|
||
Search,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Trash2,
|
||
Users,
|
||
WandSparkles,
|
||
X
|
||
} from "lucide-react";
|
||
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||
import { api } from "../api";
|
||
import { useFileDrop } from "../components/use-file-drop";
|
||
import { imageModelPickerOptions } from "../model-display";
|
||
import { ModelLibrary } from "../components/model-library";
|
||
import { SkeletonRows, SystemLoading } from "../components/loading";
|
||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||
import { Pager } from "../components/pager";
|
||
import { useViewMode } from "../components/use-view-mode";
|
||
import type { Page } from "./route-config";
|
||
import { statusPill } from "./stage-config";
|
||
import { productMockCoverUrl } from "./products";
|
||
import { isLocalLife } from "../product-business";
|
||
import "../ai-tools-page.css";
|
||
|
||
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
|
||
// 而 task_type 区分不了——cover 与 image 都是 product_image)
|
||
const MODE_LABEL: Record<string, string> = {
|
||
model: "模特上身图",
|
||
cover: "平台套图",
|
||
image: "图片创作"
|
||
};
|
||
const MODE_TAG: Record<string, string> = {
|
||
model: "模特上身",
|
||
cover: "平台套图",
|
||
image: "自由创作",
|
||
};
|
||
|
||
function formatAfTime(iso: string) {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
const now = new Date();
|
||
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`;
|
||
const yest = new Date(now);
|
||
yest.setDate(now.getDate() - 1);
|
||
if (d.toDateString() === yest.toDateString()) return `昨天 ${hm}`;
|
||
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${hm}`;
|
||
}
|
||
|
||
const STATUS_LABEL: Record<string, string> = {
|
||
succeeded: "已完成",
|
||
completed: "已完成",
|
||
done: "已完成",
|
||
ok: "已完成",
|
||
skipped: "已跳过",
|
||
failed: "失败",
|
||
error: "失败",
|
||
running: "生成中",
|
||
queued: "排队中",
|
||
polling: "生成中",
|
||
needs_review: "待确认",
|
||
// 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文)
|
||
created: "待处理",
|
||
reserved: "排队中",
|
||
submitted: "已提交",
|
||
postprocessing: "处理中",
|
||
compensating: "回滚中",
|
||
cancelled: "已取消"
|
||
};
|
||
|
||
function statusText(status: string) {
|
||
return STATUS_LABEL[status] || status;
|
||
}
|
||
|
||
// 下载图片:优先 fetch→blob 触发真实下载;跨域失败则回退到新标签打开(用户仍拿到图)
|
||
async function downloadImage(url: string, filename: string) {
|
||
try {
|
||
const res = await fetch(url, { mode: "cors" });
|
||
const blob = await res.blob();
|
||
const href = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = href;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(href);
|
||
} catch {
|
||
window.open(url, "_blank", "noopener");
|
||
}
|
||
}
|
||
|
||
export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void }) {
|
||
// 任务历史 + 任务→结果图:都在本页懒加载(不再吃全局 bootstrap 的 aiTasks/assets 全量)。
|
||
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
|
||
const [tasksLoading, setTasksLoading] = useState(true);
|
||
const [assets, setAssets] = useState<Asset[]>([]);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
setTasksLoading(true);
|
||
api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); });
|
||
// 任务中心 = 生成历史:要看全部生成图(含未加入资产库的),故 in_library: "all" 旁路过滤
|
||
api.assetsPage({ asset_type: "image", source: "ai_generated", in_library: "all", pageSize: 200 })
|
||
.then((res) => { if (alive) setAssets(res.results); })
|
||
.catch(() => { if (alive) setAssets([]); });
|
||
return () => { alive = false; };
|
||
}, []);
|
||
const taskImage = useMemo(() => {
|
||
const map: Record<string, { url: string; name: string }> = {};
|
||
for (const asset of assets) {
|
||
const taskId = asset.origin_task;
|
||
if (!taskId || map[taskId]) continue;
|
||
const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true));
|
||
if (file?.preview_url) map[taskId] = { url: file.preview_url, name: asset.name || "" };
|
||
}
|
||
return map;
|
||
}, [assets]);
|
||
const cards = [
|
||
{
|
||
page: "modelPhoto" as Page,
|
||
title: "模特上身",
|
||
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
|
||
image: "/assets/yz/tool-model.jpg",
|
||
},
|
||
{
|
||
page: "platformCover" as Page,
|
||
title: "平台套图",
|
||
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
|
||
image: "/assets/yz/tool-cover.jpg",
|
||
},
|
||
{
|
||
page: "imageOptimize" as Page,
|
||
title: "图片创作",
|
||
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
|
||
image: "/assets/yz/tool-studio.jpg",
|
||
}
|
||
];
|
||
|
||
// 任务中心只看「工作台图片生成」(mode∈model/cover/image —— 能分模特上身图/平台套图/图片创作;
|
||
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
|
||
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
|
||
type TaskBatch = {
|
||
key: string; batchId: string | null; firstTaskId: string; label: string; title: string; mode: string;
|
||
count: number; doneCount: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
|
||
};
|
||
const taskBatches = useMemo<TaskBatch[]>(() => {
|
||
const groups = new Map<string, AITask[]>();
|
||
for (const t of aiTasks) {
|
||
if (!t.mode || !MODE_LABEL[t.mode]) continue; // 只保留图片生成模式(model/cover/image)
|
||
const key = t.batch_id || t.id;
|
||
const arr = groups.get(key);
|
||
if (arr) arr.push(t);
|
||
else groups.set(key, [t]);
|
||
}
|
||
const out: TaskBatch[] = [];
|
||
for (const [key, tasks] of groups) {
|
||
const first = tasks[0] as AITask & { product_title?: string; prompt?: string };
|
||
const pills = tasks.map((t) => statusPill(t.status));
|
||
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
|
||
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
|
||
const coverHit = tasks.map((t) => taskImage[t.id]).find(Boolean);
|
||
const doneCount = pills.filter((p) => p === "ok").length;
|
||
const title = first.product_title || first.prompt?.slice(0, 24) || coverHit?.name || MODE_LABEL[first.mode!] || "图片任务";
|
||
out.push({
|
||
key, batchId: first.batch_id || null, firstTaskId: first.id,
|
||
label: MODE_LABEL[first.mode!] || "图片创作",
|
||
title, mode: first.mode!,
|
||
count: tasks.length, doneCount, status, created_at: created,
|
||
cover: coverHit?.url || "",
|
||
});
|
||
}
|
||
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||
return out;
|
||
}, [aiTasks, taskImage]);
|
||
|
||
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
|
||
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
|
||
const [query, setQuery] = useState("");
|
||
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
|
||
const [typeFilter, setTypeFilter] = useState("");
|
||
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
|
||
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
|
||
// 任务中心分页:每页 8 条(4 列网格两行),筛选/搜索变化时回第 1 页
|
||
const TASKS_PER_PAGE = 8;
|
||
const [taskPage, setTaskPage] = useState(1);
|
||
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
|
||
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
|
||
const [batchImgs, setBatchImgs] = useState<Asset[]>([]);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||
useEffect(() => {
|
||
if (!openBatch) { setBatchImgs([]); return; }
|
||
let alive = true;
|
||
setBatchLoading(true);
|
||
// 有 batch_id 按批取整批;旧图无 batch_id 则按 origin_task 取该任务那张
|
||
const params = openBatch.batchId
|
||
? { meta: { batch_id: openBatch.batchId }, in_library: "all", asset_type: "image", pageSize: 50 }
|
||
: { origin_task: openBatch.firstTaskId, in_library: "all", asset_type: "image", pageSize: 50 };
|
||
api.assetsPage(params)
|
||
.then((res) => { if (alive) setBatchImgs(res.results); })
|
||
.catch(() => { if (alive) setBatchImgs([]); })
|
||
.finally(() => { if (alive) setBatchLoading(false); });
|
||
return () => { alive = false; };
|
||
}, [openBatch]);
|
||
useEffect(() => {
|
||
if (!openChip) return;
|
||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".af-select")) setOpenChip(""); };
|
||
document.addEventListener("click", close);
|
||
return () => document.removeEventListener("click", close);
|
||
}, [openChip]);
|
||
|
||
// R109:生成图已「自动入资产库」,任务中心批次弹窗不再提供加入/取消入库操作(纯查看 + 放大)。
|
||
|
||
const typeOptions = Array.from(new Set(taskBatches.map((b) => b.label).filter(Boolean)));
|
||
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
|
||
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
|
||
];
|
||
const visible = taskBatches.filter((batch) => {
|
||
if (filter === "gen" && batch.status !== "info") return false;
|
||
if (filter === "ok" && batch.status !== "ok") return false;
|
||
if (filter === "err" && batch.status !== "err") return false;
|
||
if (typeFilter && batch.label !== typeFilter) return false;
|
||
if (timeFilter !== "all" && batch.created_at) {
|
||
const days = (Date.now() - new Date(batch.created_at).getTime()) / 86400000;
|
||
if (days > Number(timeFilter)) return false;
|
||
}
|
||
if (query) {
|
||
const hay = `${batch.label} ${batch.title} ${batch.key}`.toLowerCase();
|
||
if (!hay.includes(query.toLowerCase())) return false;
|
||
}
|
||
return true;
|
||
});
|
||
// 筛选条件变化时回到第 1 页
|
||
useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]);
|
||
const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE));
|
||
const taskCurPage = Math.min(taskPage, taskTotalPages);
|
||
const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE);
|
||
|
||
const batchMeta = (batch: TaskBatch) => {
|
||
if (batch.status === "info") return `生成中 · ${batch.doneCount} / ${batch.count} 张`;
|
||
if (batch.status === "err") return `${formatAfTime(batch.created_at)} · 生成失败`;
|
||
return `${formatAfTime(batch.created_at)} · ${batch.count} 张`;
|
||
};
|
||
|
||
return (
|
||
<div className="asset-factory">
|
||
<div className="asset-factory-inner">
|
||
<header className="af-page-head">
|
||
<div>
|
||
<h1>图片创作</h1>
|
||
<p>从商品与模特资产出发,快速完成电商图片生产</p>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="af-tools">
|
||
{cards.map((card) => (
|
||
<button className="af-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||
<div className="af-tool-cover">
|
||
<img src={card.image} alt="" />
|
||
</div>
|
||
<div className="af-tool-body">
|
||
<div className="af-tool-title">
|
||
<h3>{card.title}</h3>
|
||
<ArrowUpRight size={21} />
|
||
</div>
|
||
<p>{card.desc}</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="af-section">
|
||
<div>
|
||
<h2>最近创作</h2>
|
||
<p>查看图片任务状态与生成结果</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="af-toolbar">
|
||
<div className="af-seg" role="tablist" aria-label="任务状态">
|
||
<button className={`af-seg-btn${filter === "all" ? " active" : ""}`} type="button" onClick={() => setFilter("all")}>全部</button>
|
||
<button className={`af-seg-btn${filter === "gen" ? " active" : ""}`} type="button" onClick={() => setFilter("gen")}>生成中</button>
|
||
<button className={`af-seg-btn${filter === "ok" ? " active" : ""}`} type="button" onClick={() => setFilter("ok")}>已完成</button>
|
||
<button className={`af-seg-btn${filter === "err" ? " active" : ""}`} type="button" onClick={() => setFilter("err")}>失败</button>
|
||
</div>
|
||
<div className="af-toolbar-right">
|
||
<label className="af-search">
|
||
<Search size={16} />
|
||
<input placeholder="搜索任务名称" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||
</label>
|
||
<div className={`af-select${openChip === "time" ? " open" : ""}`}>
|
||
<button className="af-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "time"} onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
||
<span>{TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间"}</span>
|
||
<ChevronDown size={15} />
|
||
</button>
|
||
<div className="af-select-menu" role="listbox">
|
||
{TIME_OPTS.map((opt) => (
|
||
<button className={`af-select-option${timeFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className={`af-select${openChip === "type" ? " open" : ""}`}>
|
||
<button className="af-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "type"} onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
|
||
<span>{typeFilter || "全部任务"}</span>
|
||
<ChevronDown size={15} />
|
||
</button>
|
||
<div className="af-select-menu" role="listbox">
|
||
<button className={`af-select-option${!typeFilter ? " selected" : ""}`} type="button" onClick={() => { setTypeFilter(""); setOpenChip(""); }}>全部任务</button>
|
||
{typeOptions.map((t) => (
|
||
<button className={`af-select-option${typeFilter === t ? " selected" : ""}`} type="button" key={t} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>{t}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="af-view">
|
||
<button type="button" className={`af-view-btn${view === "grid" ? " active" : ""}`} aria-label="网格显示" onClick={() => setView("grid")}>
|
||
<Grid2X2 size={16} />
|
||
</button>
|
||
<button type="button" className={`af-view-btn${view === "list" ? " active" : ""}`} aria-label="列表显示" onClick={() => setView("list")}>
|
||
<List size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{tasksLoading && aiTasks.length === 0 ? (
|
||
<SkeletonRows count={5} />
|
||
) : aiTasks.length === 0 ? (
|
||
<div className="af-empty" role="status">
|
||
<ImageOff />
|
||
<strong>还没有任务</strong>
|
||
<span>去上方选一个入口开始生成</span>
|
||
</div>
|
||
) : visible.length === 0 ? (
|
||
<div className="af-empty" role="status">
|
||
<ImageOff />
|
||
<strong>没有符合筛选条件的作品</strong>
|
||
<span>请调整状态、时间、任务类型或搜索关键词后重试</span>
|
||
</div>
|
||
) : (
|
||
<div className={`af-media-grid${view === "list" ? " list" : ""}`}>
|
||
{paged.map((batch) => {
|
||
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
|
||
return (
|
||
<article className="af-media" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
|
||
onClick={() => setOpenBatch(batch)}
|
||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
|
||
<div className="af-media-thumb">
|
||
{batch.cover ? <img src={batch.cover} alt={batch.title} loading="lazy" decoding="async" /> : null}
|
||
</div>
|
||
<div className="af-media-meta">
|
||
<h3>{batch.title}</h3>
|
||
<p>{batchMeta(batch)}</p>
|
||
<div className="af-meta-line">
|
||
<span className={`af-tag ${batch.status}`}>{statusLabel}</span>
|
||
<span className={`af-tag type ${batch.mode}`}>{MODE_TAG[batch.mode] || batch.label}</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
|
||
</div>
|
||
|
||
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
|
||
{openBatch && createPortal(
|
||
<div className="modal-bg show" onClick={() => setOpenBatch(null)}>
|
||
<div className="modal with-corners" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 760, width: "92%" }}>
|
||
<span className="corner-tr" aria-hidden />
|
||
<span className="corner-bl" aria-hidden />
|
||
<div className="modal-h">
|
||
<div className="ic-m"><LayoutGrid size={17} /></div>
|
||
<div className="ti">{openBatch.label}<span>{openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}</span></div>
|
||
<span style={{ flex: 1 }} />
|
||
{/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
|
||
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
|
||
</div>
|
||
<div className="modal-b">
|
||
{batchLoading ? (
|
||
<SystemLoading variant="inline" title="正在加载详情" icon="images" />
|
||
) : batchImgs.length === 0 ? (
|
||
<div className="task-empty"><div>这批没有可显示的图片</div></div>
|
||
) : (
|
||
<div className="gen-images" style={{ "--cols": Math.min(4, batchImgs.length), "--ratio": "1 / 1" } as React.CSSProperties}>
|
||
{batchImgs.map((a, i) => {
|
||
const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || "";
|
||
return (
|
||
<div className="gen-image" key={a.id}>
|
||
{u ? (
|
||
<img className="gen-image-img" src={u} alt={a.name} loading="lazy" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: u, name: a.name })} />
|
||
) : (
|
||
<div className="placeholder"><span className="ph-frame">#{i + 1}</span></div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type WorkMode = "image" | "model" | "cover";
|
||
|
||
const MODE_META: Record<
|
||
WorkMode,
|
||
{
|
||
title: string;
|
||
tag: string;
|
||
desc: string;
|
||
ratio: string;
|
||
promptTemplate: (productTitle: string) => string;
|
||
}
|
||
> = {
|
||
image: {
|
||
title: "图片创作",
|
||
tag: "[ IMAGE · STUDIO ]",
|
||
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
|
||
ratio: "1:1",
|
||
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
|
||
},
|
||
model: {
|
||
title: "模特上身",
|
||
tag: "[ MODEL · TRY-ON ]",
|
||
desc: "选择授权模特与生成规格,为商品创建自然真实的上身展示图",
|
||
ratio: "1:1",
|
||
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
|
||
},
|
||
cover: {
|
||
title: "平台套图",
|
||
tag: "[ PLATFORM · KIT ]",
|
||
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
|
||
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
|
||
ratio: "1:1",
|
||
// YYX#4:只出平台头图,去掉「详情排版」
|
||
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
|
||
}
|
||
};
|
||
|
||
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
|
||
const COUNT_OPTIONS = ["1", "2", "4"];
|
||
const MODEL_RATIO_OPTIONS = ["1:1", "3:4", "9:16"];
|
||
const MODEL_COUNT_OPTIONS = ["1", "2", "4"];
|
||
|
||
// 生图模型选择(写入 localStorage,下次进页面读回)。默认火山。
|
||
const GEN_MODEL_KEY = "airshelf:imgwb:gen_model";
|
||
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
|
||
|
||
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
|
||
const IMAGE_SUGGESTIONS = [
|
||
{ label: "晨光护肤品广告", prompt: "净颜精华悬浮在晨光中,蓝白色调,干净高级的护肤品广告摄影" },
|
||
{ label: "极简白底产品图", prompt: "极简北欧风格的白底产品摄影,自然柔光,突出包装质感" },
|
||
{ label: "国风水墨海报", prompt: "围绕商品制作国风水墨海报,主体清晰,留白构图" },
|
||
{ label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" }
|
||
];
|
||
|
||
/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg;
|
||
logo 留作图加载失败时的兜底字符,img 为真 logo) */
|
||
const PLATFORM_OPTIONS = [
|
||
{ id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" },
|
||
{ id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" },
|
||
{ id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" },
|
||
{ id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" },
|
||
{ id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" },
|
||
{ id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" },
|
||
{ id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" },
|
||
{ id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" },
|
||
{ id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" },
|
||
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
|
||
];
|
||
|
||
/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */
|
||
const PLATFORM_ID_MAP: Record<string, string> = {
|
||
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
|
||
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
|
||
};
|
||
|
||
/* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。
|
||
className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */
|
||
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
|
||
const [err, setErr] = useState(false);
|
||
return (
|
||
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
|
||
{p.img && !err
|
||
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
|
||
: p.logo}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。
|
||
行31(并发提交) / 行32(重跑追加 + 气泡菜单) / R100(批次从后端持久任务恢复) / R109(成图自动入库)。 */
|
||
type GenBatch = {
|
||
id: string;
|
||
prompt: string;
|
||
ratio: string;
|
||
count: number;
|
||
status: "generating" | "done" | "failed";
|
||
results: Asset[];
|
||
ts: number;
|
||
/** 该批次归属的商品(用于按商品分组,各商品一个导航头) */
|
||
productId?: string;
|
||
productTitle?: string;
|
||
/** 该批次选中的模特资产 id(模特上身图:重跑时沿用同一模特) */
|
||
modelId?: string;
|
||
/** 该批次选中的模特展示名(导航头显示) */
|
||
modelName?: string;
|
||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
||
platformIds?: string[];
|
||
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
|
||
refs?: { name: string; url: string; assetId?: string }[];
|
||
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
|
||
backendBatchId?: string;
|
||
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
|
||
pendingIds?: string[];
|
||
/** 该批全部后端任务 id:删除/恢复批次时使用稳定锚点,终态批次也必须保留。 */
|
||
taskIds?: string[];
|
||
/** 补图/单图重跑任务:删掉其成图时不应减少原始批次的失败格数量。 */
|
||
rerunTaskIds?: string[];
|
||
failedTaskIds?: string[];
|
||
};
|
||
|
||
/**
|
||
* 判断本地临时卡与后端恢复卡是否代表同一真实任务组。
|
||
* 新提交后先有前端 b- 临时 id,随后才回填后端 batch_id;两者在商品切换时会短暂共存,
|
||
* 必须按真实 batch_id(或任务 id 重叠的提交/恢复交错兜底)合并,不能按前端卡片 id 比较。
|
||
*/
|
||
function isSameWorkbenchBatch(left: GenBatch, right: GenBatch): boolean {
|
||
if (left.backendBatchId && right.backendBatchId && left.backendBatchId === right.backendBatchId) return true;
|
||
const leftIds = left.pendingIds || [];
|
||
const rightIds = new Set(right.pendingIds || []);
|
||
return leftIds.some((id) => rightIds.has(id));
|
||
}
|
||
|
||
/* R100:后端 platform_id(douyin/taobao…) → 前端平台 key(dy/tb…),恢复批次时还原平台分组 */
|
||
const PLATFORM_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||
Object.entries(PLATFORM_ID_MAP).map(([key, canonical]) => [canonical, key])
|
||
);
|
||
|
||
export function ImageWorkbenchPage({
|
||
mode,
|
||
products,
|
||
modelConfigs,
|
||
onBack,
|
||
navigate,
|
||
onGenerate,
|
||
onResume,
|
||
imageProductId,
|
||
initialProductId,
|
||
onProductChange,
|
||
unreadByProduct,
|
||
onProductViewed,
|
||
onNotify
|
||
}: {
|
||
mode: WorkMode;
|
||
products: Product[];
|
||
modelConfigs: ModelConfig[];
|
||
onBack: () => void;
|
||
navigate?: (page: Page) => void;
|
||
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
|
||
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
|
||
imageProductId?: string;
|
||
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
|
||
initialProductId?: string;
|
||
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
|
||
onProductChange?: (productId: string) => void;
|
||
/** YYX#row22:每个商品的未读生成任务数(商品角标) */
|
||
unreadByProduct?: Record<string, number>;
|
||
/** YYX#row22:查看某商品(选中)即标记该商品的生成任务已读 → 清零角标 */
|
||
onProductViewed?: (productId: string) => void;
|
||
/** 批次删除失败时复用应用现有 Toast,避免静默移除卡片。 */
|
||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||
}) {
|
||
const meta = MODE_META[mode];
|
||
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
|
||
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
|
||
useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]);
|
||
// 本地生活没有模特上身图:进模特工作台时如果带入的是本地生活商品,改选第一个电商商品。
|
||
useEffect(() => {
|
||
if (mode !== "model") return;
|
||
const current = products.find((item) => item.id === productId);
|
||
if (!current || !isLocalLife(current)) return;
|
||
const next = products.find((item) => !isLocalLife(item));
|
||
setProductId(next?.id || "");
|
||
}, [mode, productId, products]);
|
||
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
|
||
useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]);
|
||
const product = products.find((item) => item.id === productId) || products[0];
|
||
const imageProduct = products.find((item) => item.id === imageProductId);
|
||
const conversationScopeKey = imageProductId ? `product:${imageProductId}` : "unbound";
|
||
const conversationScopeLabel = imageProductId ? `当前商品 · ${imageProduct?.title || "加载中"}` : "通用创作";
|
||
const conversationScopeKeyRef = useRef(conversationScopeKey);
|
||
conversationScopeKeyRef.current = conversationScopeKey;
|
||
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
||
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
|
||
const [ratio, setRatio] = useState(meta.ratio);
|
||
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
|
||
const [ratioManual, setRatioManual] = useState(false);
|
||
const [ratioW, setRatioW] = useState("");
|
||
const [ratioH, setRatioH] = useState("");
|
||
const updateRatioDimension = (event: ChangeEvent<HTMLInputElement>, dimension: "width" | "height") => {
|
||
const value = event.target.value;
|
||
const next = value === "" || /^[1-9]\d*$/.test(value) ? value : "";
|
||
event.target.value = next;
|
||
|
||
if (dimension === "width") {
|
||
setRatioW(next);
|
||
if (next && ratioH) setRatio(`${next}:${ratioH}`);
|
||
} else {
|
||
setRatioH(next);
|
||
if (ratioW && next) setRatio(`${ratioW}:${next}`);
|
||
}
|
||
};
|
||
const blockNonIntegerKey = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if ([".", "+", "-", "e", "E"].includes(event.key)) event.preventDefault();
|
||
};
|
||
// 生图模型选择:保存后端路由键;展示名由共享映射统一提供。
|
||
const [genModel, setGenModel] = useState<string>(() => {
|
||
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
|
||
});
|
||
useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]);
|
||
const selectedImageModelLabel = imageModelPickerOptions.find((option) => option.id === genModel)?.label || imageModelPickerOptions[0].label;
|
||
const [count, setCount] = useState(mode === "image" ? "4" : "4");
|
||
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
|
||
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
||
// 模特库全屏弹窗:选一个模特回填。
|
||
const [modelLibOpen, setModelLibOpen] = useState(false);
|
||
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
|
||
const [pickedModelName, setPickedModelName] = useState("");
|
||
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
|
||
const [plOpen, setPlOpen] = useState(false);
|
||
const [plDraft, setPlDraft] = useState<string[]>([]);
|
||
const [plQuery, setPlQuery] = useState("");
|
||
const [plCat, setPlCat] = useState("");
|
||
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
|
||
const [openMore, setOpenMore] = useState("");
|
||
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
|
||
const [batches, setBatches] = useState<GenBatch[]>([]);
|
||
// 商品切换后的后端恢复回调不依赖 batches,避免恢复 effect 因批次状态变化反复触发;
|
||
// 用 ref 读取最新内存卡,识别仍由首次提交轮询的临时批次。
|
||
const batchesRef = useRef<GenBatch[]>(batches);
|
||
batchesRef.current = batches;
|
||
/* ── 图片创作「对话」(真实体,后端 ImageConversation)──
|
||
conversations = 左栏会话列表;activeConvId = 当前选中对话(空 = 还没建,首次发送时后端自动开)。
|
||
activeConvRef 让 startBatch 在不进 deps 的情况下读到最新 active id。 */
|
||
const [conversations, setConversations] = useState<ImageConversation[]>([]);
|
||
const [activeConvId, setActiveConvId] = useState<string>("");
|
||
const activeConvRef = useRef<string>("");
|
||
useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]);
|
||
const conversationLoadSeqRef = useRef(0);
|
||
const conversationBatchLoadSeqRef = useRef(0);
|
||
const [convLoading, setConvLoading] = useState(false);
|
||
// 对话操作失败的可见提示(替代原来的静默吞错)
|
||
const [convError, setConvError] = useState("");
|
||
// 重命名:正在改名的对话 id + 草稿
|
||
const [renamingId, setRenamingId] = useState("");
|
||
const [renameDraft, setRenameDraft] = useState("");
|
||
// 整条对话删除独立确认,不与单图/批次删除状态耦合。
|
||
const [pendingDeleteConversation, setPendingDeleteConversation] = useState<ImageConversation | null>(null);
|
||
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
||
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
|
||
const resultsEndRef = useRef<HTMLDivElement | null>(null);
|
||
// 生成结果图片放大预览
|
||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
|
||
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
|
||
const [gridQuery, setGridQuery] = useState("");
|
||
const [searchOpen, setSearchOpen] = useState(false);
|
||
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
|
||
const [filterOpen, setFilterOpen] = useState(false);
|
||
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
|
||
const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item)));
|
||
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
||
const productCoverUrl = (p: Product): string => {
|
||
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
|
||
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
|
||
// YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位
|
||
return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title);
|
||
};
|
||
function acceptReferences(files: File[]) {
|
||
if (!files.length) return;
|
||
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
|
||
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
|
||
}
|
||
|
||
function pickReference(event: ChangeEvent<HTMLInputElement>) {
|
||
acceptReferences(Array.from(event.target.files || []));
|
||
event.target.value = "";
|
||
}
|
||
|
||
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
|
||
|
||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
|
||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||
useEffect(() => {
|
||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||
}, []);
|
||
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型,
|
||
// 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。
|
||
const perImagePrice = (() => {
|
||
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
|
||
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
|
||
const p = Number(m?.unit_price);
|
||
return Number.isFinite(p) && p > 0 ? p : 20;
|
||
})();
|
||
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
|
||
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认)
|
||
const perImageFinal = Math.max(1, Math.round(Number((Math.round(perImagePrice) * priceMultiplier).toFixed(6))));
|
||
const estimateFee = (n: number) => `${n * perImageFinal} 积分`;
|
||
|
||
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
||
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
|
||
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ModelLibrary 弹窗同源。 */
|
||
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
|
||
const loadModels = useCallback(() => {
|
||
api.listModels({ pageSize: 200 })
|
||
.then((res) => {
|
||
const mapped: Asset[] = res.results
|
||
.filter((m: ModelEntity) => m.portrait_asset)
|
||
.map((m: ModelEntity) => ({
|
||
id: m.portrait_asset as string,
|
||
name: m.name,
|
||
asset_type: "image",
|
||
source: m.source === "upload" ? "upload" : "ai_generated",
|
||
category: "model_portrait",
|
||
description: m.description || "",
|
||
metadata: { model_entity_id: m.id },
|
||
files: m.portrait
|
||
? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }]
|
||
: [],
|
||
created_at: m.created_at,
|
||
updated_at: m.updated_at,
|
||
}));
|
||
setPersonAssets(mapped);
|
||
})
|
||
.catch(() => setPersonAssets([]));
|
||
}, []);
|
||
useEffect(() => { loadModels(); }, [loadModels]);
|
||
|
||
useEffect(() => {
|
||
// image 模式默认留空(不预填模板);模特/平台仍随商品预填模板
|
||
if (mode === "image") setPrompt("");
|
||
else if (product) setPrompt(meta.promptTemplate(product.title));
|
||
// mode 或商品切换都重置默认比例
|
||
setRatio(meta.ratio);
|
||
setRatioManual(false);
|
||
setRatioW("");
|
||
setRatioH("");
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [productId, mode]);
|
||
|
||
// 模特 / 平台都单选(YYX#1:平台只能单选):再点同一张取消,点别张替换
|
||
function togglePick(id: string, name?: string) {
|
||
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
|
||
if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
|
||
}
|
||
// 点击空白收起单图「更多」气泡
|
||
useEffect(() => {
|
||
if (!openMore) return;
|
||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".gen-img-more")) setOpenMore(""); };
|
||
document.addEventListener("click", close);
|
||
return () => document.removeEventListener("click", close);
|
||
}, [openMore]);
|
||
// YYX#12:点空白收起平台筛选弹窗
|
||
useEffect(() => {
|
||
if (!filterOpen) return;
|
||
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); };
|
||
document.addEventListener("click", close);
|
||
return () => document.removeEventListener("click", close);
|
||
}, [filterOpen]);
|
||
|
||
const ratioVar = ratio.replace(":", " / ");
|
||
const candidateCount = Math.max(1, Number(count) || 4);
|
||
// PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。
|
||
// 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。
|
||
const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount;
|
||
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
|
||
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
||
const canGenerate =
|
||
prompt.trim().length > 0 &&
|
||
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
|
||
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
|
||
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
|
||
|
||
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
|
||
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
|
||
image 模式仍由后端「对话」驱动 —— 两边与任务中心同源(同一批 AITask)。 */
|
||
|
||
/* ════════ 图片创作「对话」实体的增删改查 + 历史回填 ════════ */
|
||
|
||
// 后端对话任务流 → 前端批次:按 batch_id 把同一次提交的多张图归到一个 GenBatch
|
||
function batchesFromConvTasks(tasks: ImageConversationTask[]): GenBatch[] {
|
||
const groups = new Map<string, ImageConversationTask[]>();
|
||
for (const t of tasks) {
|
||
const key = t.batch_id || t.id; // 老任务可能无 batch_id,各自成批
|
||
const list = groups.get(key) || [];
|
||
list.push(t);
|
||
groups.set(key, list);
|
||
}
|
||
// 回滚中仍可能继续变更任务与资产,和其它在途状态一致,不允许删除。
|
||
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
|
||
const result: GenBatch[] = [];
|
||
for (const [key, list] of groups) {
|
||
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定
|
||
const coveredFailureIds = new Set(
|
||
list.filter((t) => t.rerun && t.status === "succeeded" && t.retry_of_task_id).map((t) => t.retry_of_task_id!)
|
||
);
|
||
// 旧补图任务没有记录它替代的失败格;按一张补图抵一张失败格兼容,避免历史批次显示超过原始张数。
|
||
const legacyReplacementCount = list.filter((t) => t.rerun && t.status === "succeeded" && !t.retry_of_task_id).length;
|
||
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
|
||
if (!live.length) continue;
|
||
const assets = live.flatMap((t) => t.assets || []);
|
||
// 有任务还在跑(含重跑补图)就是 generating,不能只看「有没有图」——
|
||
// 否则「已有部分好图 + 重跑在途」被判 done,轮询不接续,补的图要手动刷新才出现
|
||
const hasRunning = live.some((t) => !TERMINAL.has(t.status));
|
||
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length > 0 ? "done" : "failed";
|
||
// 应出张数只数原始任务:重跑/补图任务(rerun)是替补,计入会把失败格越滚越多
|
||
// (原失败任务仍在批里,重跑一次多一个格)。成功补图后 results 增长,失败格自然收掉。
|
||
const failedTaskIds = list
|
||
.filter((t) => !t.rerun && ["failed", "cancelled"].includes(t.status) && !(t.assets || []).length && !coveredFailureIds.has(t.id))
|
||
.map((t) => t.id)
|
||
.slice(legacyReplacementCount);
|
||
const intended = live.filter((t) => !t.rerun).length;
|
||
const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.length);
|
||
// 该批用过的参考图(后端解析回 {id,name,url}):批次头回显 + 重跑凭 assetId 原样复用
|
||
const refSrc = live.find((t) => (t.reference_images || []).length)?.reference_images || [];
|
||
result.push({
|
||
id: key,
|
||
prompt: live[0]?.prompt || "",
|
||
ratio: live[0]?.ratio || meta.ratio,
|
||
count,
|
||
status,
|
||
results: assets,
|
||
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
|
||
productId: live[0]?.product_id || undefined,
|
||
productTitle: products.find((item) => item.id === live[0]?.product_id)?.title,
|
||
refs: refSrc.length ? refSrc.map((r) => ({ name: r.name, url: r.url, assetId: r.id })) : undefined,
|
||
// 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端
|
||
backendBatchId: live[0]?.batch_id || undefined,
|
||
taskIds: live.map((t) => t.id),
|
||
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
|
||
failedTaskIds,
|
||
});
|
||
}
|
||
// 旧批次在上、新批次在下(对话流自上而下时间序)
|
||
return result.sort((a, b) => a.ts - b.ts);
|
||
}
|
||
|
||
/* R100:后端工作台任务 → 前端批次(与 batchesFromConvTasks 同思路,多带商品/模特/平台归属)。
|
||
仍在跑的任务留在 pendingIds,挂载后按批续轮询;成功但成图已全删的任务不回显(R109 绑定)。 */
|
||
function batchesFromWorkbenchTasks(tasks: WorkbenchTask[]): GenBatch[] {
|
||
const groups = new Map<string, WorkbenchTask[]>();
|
||
for (const t of tasks) {
|
||
const key = t.batch_id || t.id;
|
||
const list = groups.get(key) || [];
|
||
list.push(t);
|
||
groups.set(key, list);
|
||
}
|
||
// 回滚中仍可能继续变更任务与资产,和其它在途状态一致,不允许删除。
|
||
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
|
||
const result: GenBatch[] = [];
|
||
for (const [key, list] of groups) {
|
||
const coveredFailureIds = new Set(
|
||
list.filter((t) => t.rerun && t.status === "succeeded" && t.retry_of_task_id).map((t) => t.retry_of_task_id!)
|
||
);
|
||
// 同图片创作:历史补图没有目标格关联时,仍按一张补图替代一张失败格回放。
|
||
const legacyReplacementCount = list.filter((t) => t.rerun && t.status === "succeeded" && !t.retry_of_task_id).length;
|
||
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
|
||
if (!live.length) continue;
|
||
const assets = live.flatMap((t) => t.assets || []);
|
||
const hasRunning = live.some((t) => !TERMINAL.has(t.status));
|
||
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length ? "done" : "failed";
|
||
const first = live[0];
|
||
const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : "";
|
||
// 应出张数只数原始任务(重跑/补图任务是替补,不计入,与 batchesFromConvTasks 同理)
|
||
const failedTaskIds = list
|
||
.filter((t) => !t.rerun && ["failed", "cancelled"].includes(t.status) && !(t.assets || []).length && !coveredFailureIds.has(t.id))
|
||
.map((t) => t.id)
|
||
.slice(legacyReplacementCount);
|
||
const intended = live.filter((t) => !t.rerun).length;
|
||
const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.length);
|
||
result.push({
|
||
id: key,
|
||
prompt: first.prompt || "",
|
||
ratio: first.ratio || meta.ratio,
|
||
count,
|
||
status,
|
||
results: assets,
|
||
ts: new Date(first.created_at || Date.now()).getTime(),
|
||
productId: first.product_id || undefined,
|
||
modelId: first.model_id || undefined,
|
||
platformIds: platformKey ? [platformKey] : undefined,
|
||
backendBatchId: first.batch_id || undefined,
|
||
taskIds: live.map((t) => t.id),
|
||
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
|
||
failedTaskIds,
|
||
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
|
||
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
|
||
pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
|
||
});
|
||
}
|
||
return result.sort((a, b) => a.ts - b.ts);
|
||
}
|
||
|
||
// 拉某对话的历史批次并回显;非终态批次继续轮询补齐
|
||
const loadConvBatches = useCallback(async (convId: string) => {
|
||
const batchLoadSeq = ++conversationBatchLoadSeqRef.current;
|
||
try {
|
||
const res = await api.conversationTasks(convId);
|
||
if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return;
|
||
const next = batchesFromConvTasks(res.tasks);
|
||
setBatches(next);
|
||
// 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐
|
||
if (onResume) {
|
||
for (const b of next.filter((x) => x.status === "generating")) {
|
||
const ids = res.tasks.filter((t) => (t.batch_id || t.id) === b.id).map((t) => t.id);
|
||
if (!ids.length) continue;
|
||
onResume(mode, ids).then((r) => {
|
||
if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return;
|
||
if (!r?.assets) return;
|
||
setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x)));
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
if (batchLoadSeq === conversationBatchLoadSeqRef.current) setBatches([]);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mode, onResume]);
|
||
|
||
// 切换对话:置为 active 并回填它的批次
|
||
const selectConversation = useCallback((convId: string) => {
|
||
if (convId === activeConvRef.current) return;
|
||
activeConvRef.current = convId;
|
||
setActiveConvId(convId);
|
||
setRenamingId("");
|
||
void loadConvBatches(convId);
|
||
}, [loadConvBatches]);
|
||
|
||
// 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流
|
||
async function handleNewConversation() {
|
||
try {
|
||
const conv = await api.createConversation({ mode, product: imageProductId || null });
|
||
setConvError("");
|
||
setConversations((prev) => [conv, ...prev]);
|
||
activeConvRef.current = conv.id;
|
||
setActiveConvId(conv.id);
|
||
setBatches([]);
|
||
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
|
||
setPickedIds([]);
|
||
} catch (err) {
|
||
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
|
||
setConvError(err instanceof Error ? err.message : "新建对话失败,请稍后重试");
|
||
}
|
||
}
|
||
|
||
// 提交重命名
|
||
async function commitRename(convId: string) {
|
||
const title = renameDraft.trim();
|
||
setRenamingId("");
|
||
if (!title) return;
|
||
setConversations((prev) => prev.map((c) => (c.id === convId ? { ...c, title } : c)));
|
||
try { await api.renameConversation(convId, title); } catch { void loadConversations(); }
|
||
}
|
||
|
||
// 删除对话(软删):接口成功后再从列表移除;删的是当前对话则切到剩下第一条或清空。
|
||
async function handleDeleteConversation(convId: string) {
|
||
try {
|
||
await api.deleteConversation(convId);
|
||
setConvError("");
|
||
} catch (error) {
|
||
setConvError(error instanceof Error ? error.message : "删除对话失败,请稍后重试");
|
||
void loadConversations();
|
||
return;
|
||
}
|
||
const remaining = conversations.filter((c) => c.id !== convId);
|
||
setConversations(remaining);
|
||
if (convId === activeConvId) {
|
||
const nextActive = remaining[0]?.id || "";
|
||
activeConvRef.current = nextActive;
|
||
setActiveConvId(nextActive);
|
||
if (nextActive) void loadConvBatches(nextActive);
|
||
else setBatches([]);
|
||
}
|
||
}
|
||
|
||
async function confirmDeleteConversation() {
|
||
const target = pendingDeleteConversation;
|
||
setPendingDeleteConversation(null);
|
||
if (!target) return;
|
||
await handleDeleteConversation(target.id);
|
||
}
|
||
|
||
// 列表加载:image 模式才用对话(model/cover 走商品空间布局)。
|
||
// autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。
|
||
const loadConversations = useCallback(async (autoSelect = true) => {
|
||
if (mode !== "image") return;
|
||
const loadSeq = ++conversationLoadSeqRef.current;
|
||
setConvLoading(true);
|
||
try {
|
||
const res = await api.listConversations(mode, imageProductId);
|
||
if (loadSeq !== conversationLoadSeqRef.current) return;
|
||
setConversations(res.results);
|
||
if (autoSelect && res.results.length && !activeConvRef.current) {
|
||
activeConvRef.current = res.results[0].id;
|
||
setActiveConvId(res.results[0].id);
|
||
void loadConvBatches(res.results[0].id);
|
||
}
|
||
} catch {
|
||
if (loadSeq !== conversationLoadSeqRef.current) return;
|
||
setConversations([]);
|
||
} finally {
|
||
if (loadSeq === conversationLoadSeqRef.current) setConvLoading(false);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mode, imageProductId, loadConvBatches]);
|
||
|
||
// 图片创作范围变化时先同步清掉旧活动会话,再加载当前商品/通用范围;序号防止旧请求晚到覆盖新列表。
|
||
useEffect(() => {
|
||
if (mode !== "image") return;
|
||
conversationLoadSeqRef.current += 1;
|
||
conversationBatchLoadSeqRef.current += 1;
|
||
activeConvRef.current = "";
|
||
setActiveConvId("");
|
||
setConversations([]);
|
||
setBatches([]);
|
||
setRenamingId("");
|
||
setConvError("");
|
||
void loadConversations();
|
||
return () => { conversationLoadSeqRef.current += 1; };
|
||
}, [mode, conversationScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
|
||
async function startBatch(opts: {
|
||
prompt: string;
|
||
ratio: string;
|
||
count: number;
|
||
productId?: string;
|
||
productTitle?: string;
|
||
modelId?: string;
|
||
modelName?: string;
|
||
platformIds?: string[];
|
||
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
|
||
platformId?: string;
|
||
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
|
||
refs?: { name: string; url: string; file?: File; assetId?: string }[];
|
||
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
|
||
reuseBatchId?: string;
|
||
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
|
||
appendToBatchId?: string;
|
||
/** 重跑/补图:原批次的后端 batch_id,带给后端沿用 → 新任务归回原批次(刷新后不裂新记录) */
|
||
batchId?: string;
|
||
/** 单格重跑要替代的原失败任务。 */
|
||
retryOfTaskId?: string;
|
||
}) {
|
||
const submittedScopeKey = conversationScopeKey;
|
||
// 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片
|
||
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||
if (opts.reuseBatchId) {
|
||
// 全批重跑:把原卡复位为「生成中」,清掉旧(失败/旧)结果,保留其归属与参数
|
||
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b)));
|
||
} else if (opts.appendToBatchId) {
|
||
// 单图重跑:原卡转「生成中」补图,保留已有好图
|
||
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b)));
|
||
} else {
|
||
const newBatch: GenBatch = {
|
||
id: batchId,
|
||
prompt: opts.prompt,
|
||
ratio: opts.ratio,
|
||
count: opts.count,
|
||
status: "generating",
|
||
results: [],
|
||
ts: Date.now(),
|
||
productId: opts.productId,
|
||
productTitle: opts.productTitle,
|
||
modelId: opts.modelId,
|
||
modelName: opts.modelName,
|
||
platformIds: opts.platformIds,
|
||
// 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId)
|
||
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId }))
|
||
};
|
||
setBatches((prev) => [...prev, newBatch]);
|
||
}
|
||
// YYX#16:新批次提交即把面板滚到底部新任务处;原地重跑滚到该卡也无妨
|
||
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
||
try {
|
||
// 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。
|
||
// 上传任一失败不阻断:跳过该张,有几张算几张。
|
||
let referenceImageIds: string[] | undefined;
|
||
if (opts.refs?.length) {
|
||
const ids = await Promise.all(
|
||
opts.refs.map(async (r) => {
|
||
if (r.assetId) return r.assetId;
|
||
if (!r.file) return null;
|
||
const form = new FormData();
|
||
form.append("file", r.file);
|
||
form.append("asset_type", "image");
|
||
return api.uploadAsset(form).then((a) => a.id).catch(() => null);
|
||
})
|
||
);
|
||
referenceImageIds = ids.filter((x): x is string => !!x);
|
||
// 上传得到的 assetId 回写进批次 refs:重跑时凭它原样复用参考图(不再重新上传、不丢参考)
|
||
const refsWithIds = opts.refs.map((r, i) => ({ name: r.name, url: r.url, assetId: r.assetId || ids[i] || undefined }));
|
||
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, refs: refsWithIds } : b)));
|
||
}
|
||
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
|
||
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
|
||
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
|
||
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
|
||
onSubmitted: (taskIds, submittedBatchId) => {
|
||
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
||
setBatches((prev) => prev.map((b) => {
|
||
if (b.id !== batchId) return b;
|
||
const allTaskIds = opts.appendToBatchId ? [...new Set([...(b.taskIds || []), ...taskIds])] : taskIds;
|
||
const rerunTaskIds = opts.appendToBatchId ? [...new Set([...(b.rerunTaskIds || []), ...taskIds])] : b.rerunTaskIds;
|
||
return { ...b, pendingIds: taskIds, taskIds: allTaskIds, rerunTaskIds, backendBatchId: b.backendBatchId || submittedBatchId };
|
||
}));
|
||
}
|
||
});
|
||
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
||
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
|
||
const convId = result?.conversation_id;
|
||
if (convId && convId !== activeConvRef.current) {
|
||
activeConvRef.current = convId;
|
||
setActiveConvId(convId);
|
||
void loadConversations(false);
|
||
}
|
||
setBatches((prev) => prev.map((b) => {
|
||
if (b.id !== batchId) return b;
|
||
const newAssets = result?.assets || [];
|
||
// 后端批次 id 落进批次卡:之后重跑这张/重跑整批都带它回原批次
|
||
const backendBatchId = b.backendBatchId || result?.batch_id;
|
||
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
|
||
if (opts.appendToBatchId) {
|
||
const seen = new Set(b.results.map((a) => a.id));
|
||
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
|
||
const failedTaskIds = opts.retryOfTaskId && newAssets.length
|
||
? b.failedTaskIds?.filter((id) => id !== opts.retryOfTaskId)
|
||
: b.failedTaskIds;
|
||
return {
|
||
...b,
|
||
backendBatchId,
|
||
status: merged.length ? ("done" as const) : ("failed" as const),
|
||
results: merged,
|
||
failedTaskIds,
|
||
count: failedTaskIds ? Math.max(1, merged.length + failedTaskIds.length) : b.count,
|
||
pendingIds: undefined
|
||
};
|
||
}
|
||
return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
|
||
}));
|
||
} catch {
|
||
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
||
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
|
||
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b)));
|
||
}
|
||
}
|
||
|
||
async function runGenerate() {
|
||
if (!canGenerate) return;
|
||
const requestedScopeKey = conversationScopeKey;
|
||
// 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回——
|
||
// 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。
|
||
if (mode === "image" && !activeConvRef.current) {
|
||
try {
|
||
const conv = await api.createConversation({ mode, product: imageProductId || null, title: prompt.trim().slice(0, 24) || undefined });
|
||
if (requestedScopeKey === conversationScopeKeyRef.current) {
|
||
activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到
|
||
setActiveConvId(conv.id);
|
||
setConversations((prev) => [conv, ...prev]);
|
||
}
|
||
} catch {
|
||
/* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */
|
||
}
|
||
}
|
||
const base = {
|
||
prompt: prompt.trim(),
|
||
ratio,
|
||
count: candidateCount,
|
||
productId: mode === "image" ? imageProductId : product?.id,
|
||
productTitle: mode === "image" ? imageProduct?.title : product?.title
|
||
};
|
||
if (mode === "cover") {
|
||
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
|
||
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
|
||
for (const pid of pickedIds) {
|
||
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
|
||
}
|
||
return;
|
||
}
|
||
void startBatch({
|
||
...base,
|
||
modelId: mode === "model" ? pickedIds[0] : undefined,
|
||
modelName: mode === "model" ? pickedModelName : undefined,
|
||
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
|
||
refs: mode === "image" && refImages.length ? refImages : undefined
|
||
});
|
||
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
|
||
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
|
||
if (mode === "image") { setRefImages([]); setPrompt(""); }
|
||
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
|
||
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
||
}
|
||
|
||
/* 重跑指定批次(PMC#25:在原任务卡上原地重跑,不新开任务卡)。 */
|
||
async function rerunBatch(src: GenBatch) {
|
||
await startBatch({
|
||
prompt: src.prompt,
|
||
ratio: src.ratio,
|
||
count: src.count,
|
||
// 重跑归属原批次的商品,而非当前选中商品
|
||
productId: mode === "image" ? src.productId : (src.productId ?? product?.id),
|
||
productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title),
|
||
modelId: src.modelId,
|
||
modelName: src.modelName,
|
||
platformIds: src.platformIds,
|
||
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
||
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
|
||
refs: src.refs,
|
||
batchId: src.backendBatchId,
|
||
reuseBatchId: src.id
|
||
});
|
||
}
|
||
|
||
/* 工作台删除:单张保持既有资产垃圾桶;整批交给后端判断。
|
||
全成功批次继续逐图进入资产垃圾桶;含失败/取消任务的批次进入“图片异常批次”,
|
||
任务与成图可整批恢复,避免失败卡刷新后复活。 */
|
||
const [confirmDel, setConfirmDel] = useState<{ batchId: string; assetId?: string } | null>(null);
|
||
async function doConfirmedDelete() {
|
||
const target = confirmDel;
|
||
setConfirmDel(null);
|
||
if (!target) return;
|
||
const batch = batches.find((b) => b.id === target.batchId);
|
||
if (!batch) return;
|
||
try {
|
||
if (target.assetId) {
|
||
await api.deleteAsset(target.assetId);
|
||
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
|
||
setBatches((prev) => prev.flatMap((b) => {
|
||
if (b.id !== target.batchId) return [b];
|
||
const deletedAsset = b.results.find((a) => a.id === target.assetId);
|
||
const deletedTaskId = deletedAsset?.origin_task || undefined;
|
||
const results = b.results.filter((a) => a.id !== target.assetId);
|
||
const taskIds = deletedTaskId ? b.taskIds?.filter((id) => id !== deletedTaskId) : b.taskIds;
|
||
const rerunTaskIds = deletedTaskId ? b.rerunTaskIds?.filter((id) => id !== deletedTaskId) : b.rerunTaskIds;
|
||
// 单图删除只移除该图对应的任务;同批仍有失败、成功或补图任务时,批次卡必须保留。
|
||
if (taskIds && taskIds.length === 0) return [];
|
||
return [{
|
||
...b,
|
||
results,
|
||
taskIds,
|
||
rerunTaskIds,
|
||
count: b.failedTaskIds ? Math.max(1, results.length + b.failedTaskIds.length) : Math.max(1, b.count - 1),
|
||
status: b.status === "generating" ? "generating" : results.length ? "done" : "failed"
|
||
}];
|
||
}));
|
||
return;
|
||
}
|
||
const anchorTaskId = batch.taskIds?.[0] || batch.pendingIds?.[0];
|
||
if (!anchorTaskId) throw new Error("当前批次暂无法删除,请刷新页面后重试");
|
||
await api.deleteWorkbenchImageBatch(anchorTaskId);
|
||
setBatches((prev) => prev.filter((b) => b.id !== target.batchId));
|
||
} catch (error) {
|
||
onNotify?.("error", error instanceof Error ? error.message : "删除失败,请稍后重试");
|
||
}
|
||
}
|
||
|
||
/* 图片创作「加入模特库」:把该图复用为模特形象图建 Model 条目(后端不二次扣费;幂等)。
|
||
成功后该图气泡项变「已加入模特库」禁用,与「加入资产库」同款单图粒度操作。 */
|
||
const [enrolledModelIds, setEnrolledModelIds] = useState<Set<string>>(new Set());
|
||
const [enrollingModelId, setEnrollingModelId] = useState("");
|
||
async function enrollModelFromImage(assetId: string) {
|
||
if (!assetId || enrollingModelId) return;
|
||
setEnrollingModelId(assetId);
|
||
try {
|
||
await api.enrollModelFromAsset(assetId);
|
||
setEnrolledModelIds((prev) => new Set(prev).add(assetId));
|
||
} catch { /* 失败静默,可重试 */ }
|
||
finally { setEnrollingModelId(""); }
|
||
}
|
||
|
||
/* 单图「再生成」/「重跑这张」(§4.18 悬浮① / YYX#5):
|
||
PMC#25——补一张图进原批次卡(不新开任务、不覆盖已有好图)。 */
|
||
async function regenSingleImage(src: GenBatch, retryOfTaskId?: string) {
|
||
await startBatch({
|
||
prompt: src.prompt,
|
||
ratio: src.ratio,
|
||
count: 1,
|
||
productId: mode === "image" ? src.productId : (src.productId ?? product?.id),
|
||
productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title),
|
||
modelId: src.modelId,
|
||
modelName: src.modelName,
|
||
platformIds: src.platformIds,
|
||
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
||
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
|
||
refs: src.refs,
|
||
batchId: src.backendBatchId,
|
||
appendToBatchId: src.id,
|
||
retryOfTaskId
|
||
});
|
||
}
|
||
|
||
/* R100:工作台记录从后端持久任务恢复(与任务中心同源)—— 原 localStorage 残留 1 小时即丢、
|
||
换浏览器/清缓存即空,造成「任务中心有记录、工作台丢」。挂载 / 切换商品时按 mode+product 拉取
|
||
该商品的全部生成记录;仍在跑的批次带回 pendingIds,各自续轮询补齐(刷新也不断)。 */
|
||
useEffect(() => {
|
||
// image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,避免双源打架
|
||
if (mode === "image") return;
|
||
if (!productId) { setBatches([]); return; }
|
||
let cancelled = false;
|
||
api.workbenchTasks(mode, productId)
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
const next = batchesFromWorkbenchTasks(res.tasks || []);
|
||
// 竞态保护:拉取在途时用户又提交了新批次(本地 id 以 b- 开头、还在生成中)→ 合并保留,别被整体替换冲掉。
|
||
// 同时,切走再回来时 next 会带回这张临时卡对应的真实 batch_id:若直接追加,
|
||
// 页面会出现两张同一任务卡,并对同一 pendingIds 启动两次轮询。
|
||
const inMemoryGenerating = batchesRef.current.filter((b) => b.id.startsWith("b-") && b.status === "generating");
|
||
const recoveredToResume = next.filter((b) => !inMemoryGenerating.some((local) => isSameWorkbenchBatch(local, b)));
|
||
setBatches((prev) => {
|
||
const localGenerating = prev.filter((b) => b.id.startsWith("b-") && b.status === "generating");
|
||
const recovered = next.filter((b) => !localGenerating.some((local) => isSameWorkbenchBatch(local, b)));
|
||
return [...recovered, ...localGenerating];
|
||
});
|
||
if (onResume) {
|
||
// 仅恢复当前内存中不存在的批次;已匹配的临时卡仍由首次提交流程继续轮询。
|
||
for (const b of recoveredToResume) {
|
||
if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue;
|
||
onResume(mode, b.pendingIds).then((r) => {
|
||
if (cancelled || !r?.assets) return;
|
||
setBatches((prev) => prev.map((x) =>
|
||
x.id === b.id ? { ...x, status: "done" as const, results: r.assets, pendingIds: undefined } : x
|
||
));
|
||
});
|
||
}
|
||
}
|
||
})
|
||
.catch(() => { /* 拉取失败(后端未更新/网络抖动):保留当前内存批次,不清空 */ });
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// 仅在挂载 / 切 mode / 切商品时恢复(不依赖 onResume 身份,避免反复重拉)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mode, productId]);
|
||
|
||
// YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次
|
||
// (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。
|
||
// YYX#12:右上搜索框(gridQuery)在平台套图下用来筛「工作台图片」(按提示词匹配),不再清空左侧平台网格。
|
||
const productBatches = useMemo(() => {
|
||
let list = mode === "image" ? batches : batches.filter((b) => !b.productId || b.productId === productId);
|
||
if (mode === "cover" && gridQuery.trim()) {
|
||
const q = gridQuery.trim().toLowerCase();
|
||
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
|
||
}
|
||
// YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些)
|
||
if (mode === "cover" && platformFilter.length) {
|
||
list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p)));
|
||
}
|
||
return list;
|
||
}, [batches, productId, mode, gridQuery, platformFilter]);
|
||
const hasResults = productBatches.length > 0;
|
||
|
||
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
|
||
每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→删除)
|
||
② 失败格占位 + 单张重跑。R109:成图自动入资产库,不再有「已入库」角标/入库切换。 */
|
||
function renderBatchGrid(batch: GenBatch) {
|
||
const generating = batch.status === "generating";
|
||
const n = generating
|
||
? Math.max(batch.results.length, batch.count)
|
||
: Math.max(1, batch.results.length + (batch.failedTaskIds?.length ?? Math.max(0, batch.count - batch.results.length)));
|
||
const cols = n >= 4 ? 4 : 2;
|
||
const ratioBatchVar = batch.ratio.replace(":", " / ");
|
||
return (
|
||
<div
|
||
className="gen-images"
|
||
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
|
||
>
|
||
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败,
|
||
给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */}
|
||
{Array.from({ length: n }).map((_, index) => {
|
||
const asset = batch.results[index];
|
||
const failedTaskId = batch.failedTaskIds?.[index - batch.results.length];
|
||
const url = asset?.files?.[0]?.preview_url;
|
||
const assetId = asset?.id || "";
|
||
const failed = !generating && !asset && (Boolean(failedTaskId) || !batch.failedTaskIds); // 非生成中且该格无图 → 这张失败了
|
||
const key = assetId || `ph-${index}`;
|
||
const cellKey = `${batch.id}:${assetId || key}`;
|
||
return (
|
||
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}`} key={key}>
|
||
{url ? (
|
||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||
) : (
|
||
<div className="placeholder">
|
||
{generating ? (
|
||
<span className="gen-loading" aria-label="生成中">
|
||
<span className="spinner" aria-hidden />
|
||
</span>
|
||
) : failed ? (
|
||
/* YYX#5:这张失败了 —— 占位提示 + 单张重跑 */
|
||
<span className="gen-failed">
|
||
<X size={18} />
|
||
<span className="gf-text">这张生成失败</span>
|
||
<button type="button" className="gf-retry" onClick={() => regenSingleImage(batch, failedTaskId)}>
|
||
<RefreshCw size={12} />
|
||
重跑这张
|
||
</button>
|
||
</span>
|
||
) : (
|
||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* R109:成图自动入资产库 —— 去掉「已入库」角标与加入/取消入口 */}
|
||
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
|
||
{url && (
|
||
<div className="gen-image-actions">
|
||
<button className="gen-img-btn" type="button" title="再生成" disabled={generating} onClick={() => regenSingleImage(batch)}>
|
||
<RefreshCw size={14} />
|
||
</button>
|
||
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
|
||
<Download size={14} />
|
||
</button>
|
||
{/* row39:鼠标移出「更多按钮 + 气泡」整体区域自动收起(气泡是本容器后代,悬停气泡不算移出;
|
||
按钮与气泡间 6px 空隙由 .gen-bubble::before 透明桥接补上,移过去不会误收起)。
|
||
仅当收起的是本格气泡才清空,避免误关他格。保留点击切换 + 点空白收起。 */}
|
||
<div className="gen-img-more" onMouseLeave={() => setOpenMore((k) => (k === cellKey ? "" : k))}>
|
||
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
|
||
<MoreHorizontal size={14} />
|
||
</button>
|
||
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
|
||
{/* row41:仅图片创作(mode=image)显示「加入模特库」;模特上身图/平台套图不显示 */}
|
||
{mode === "image" && (
|
||
<button type="button" className="gen-bubble-item" disabled={enrolledModelIds.has(assetId) || enrollingModelId === assetId} onClick={() => { setOpenMore(""); enrollModelFromImage(assetId); }}>
|
||
<Users size={13} />
|
||
{enrolledModelIds.has(assetId) ? "已加入模特库" : (enrollingModelId === assetId ? "加入中…" : "加入模特库")}
|
||
</button>
|
||
)}
|
||
{/* R108/R109:删除 = 真删资产(回收站软删),先二次确认 */}
|
||
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); setConfirmDel({ batchId: batch.id, assetId }); }}>
|
||
<Trash2 size={13} />
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* 批次所属商品名:优先生成时记下的 productTitle;旧批次缺失则用 productId 或成图的 asset.product
|
||
(后端已解析归属商品)反查 products——绝不回退到「当前选中商品」(那正是会显示错名的根因)。 */
|
||
function batchProductTitle(batch: GenBatch): string {
|
||
if (batch.productTitle) return batch.productTitle;
|
||
const pid = batch.productId || batch.results.find((a) => a.product)?.product || "";
|
||
return products.find((p) => p.id === pid)?.title || "未选择";
|
||
}
|
||
|
||
/* ── 批次列表(行31/32/33):每批各一个商品导航头 + 一张卡片 + 底部操作/气泡 ── */
|
||
function renderBatchCards(list: GenBatch[]) {
|
||
return (
|
||
<>
|
||
{list.map((batch) => {
|
||
const generating = batch.status === "generating";
|
||
const failed = batch.status === "failed";
|
||
return (
|
||
<Fragment key={batch.id}>
|
||
{/* YYX#18:结果卡头部精简 —— 去容器/引号/平台/小标题,只留可折叠提示词 + 灰色张数文案 */}
|
||
<BatchHeader
|
||
prompt={batch.prompt}
|
||
count={batch.count}
|
||
statusText={generating ? "生成中" : failed ? "失败" : "已完成"}
|
||
/>
|
||
<div className="gen-card gen-batch-card">
|
||
{renderBatchGrid(batch)}
|
||
{/* 行30/32:批次底部操作行(R109:成图自动入资产库,去掉「加入资产库」按钮) */}
|
||
<div className="gen-batch-actions">
|
||
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => rerunBatch(batch)}>
|
||
<RefreshCw size={13} />
|
||
重跑
|
||
</button>
|
||
{!generating && (
|
||
<div className="gen-batch-more">
|
||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||
<MoreHorizontal size={13} />
|
||
</button>
|
||
<div className="gen-bubble gen-bubble-up">
|
||
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
|
||
<Trash2 size={13} />
|
||
删除当前批次
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ── 平台套图 · 多平台分组结果(P0③)──
|
||
每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */
|
||
function renderCoverGrouped(list: GenBatch[]) {
|
||
// 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现)
|
||
const groups: Array<{ pid: string; batches: GenBatch[] }> = [];
|
||
for (const batch of list) {
|
||
const pid = batch.platformIds?.[0] || "_";
|
||
let g = groups.find((x) => x.pid === pid);
|
||
if (!g) { g = { pid, batches: [] }; groups.push(g); }
|
||
g.batches.push(batch);
|
||
}
|
||
return (
|
||
<>
|
||
{groups.map((g) => {
|
||
const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid);
|
||
return (
|
||
<Fragment key={g.pid}>
|
||
<div className="iw-cover-group-h">
|
||
{plat ? (
|
||
<>
|
||
<PlatformLogo p={plat} className="cg-logo" />
|
||
<span className="cg-name">{plat.name}</span>
|
||
</>
|
||
) : (
|
||
<span className="cg-name">未分类平台</span>
|
||
)}
|
||
<span className="cg-ct">{g.batches.length} 批</span>
|
||
</div>
|
||
{renderBatchCards(g.batches)}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const featuredProducts = (() => {
|
||
const selected = visibleProducts.find((item) => item.id === productId);
|
||
const rest = visibleProducts.filter((item) => item.id !== productId);
|
||
return (selected ? [selected, ...rest] : rest).slice(0, 2);
|
||
})();
|
||
const featuredModels = (() => {
|
||
const selected = pickedIds[0] ? personAssets.find((item) => item.id === pickedIds[0]) : undefined;
|
||
const rest = personAssets.filter((item) => item.id !== pickedIds[0]);
|
||
return (selected ? [selected, ...rest] : rest).slice(0, 3);
|
||
})();
|
||
const selectedPlatform = PLATFORM_OPTIONS.find((item) => item.id === pickedIds[0]);
|
||
const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
|
||
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
|
||
const openProductLibrary = () => {
|
||
setPlDraft(productId ? [productId] : []);
|
||
setPlQuery("");
|
||
setPlCat("");
|
||
setPlOpen(true);
|
||
};
|
||
const productRefLabel = (item: Product) => {
|
||
const n = item.images?.length || 0;
|
||
return `${item.category || "未分类"} · ${n} 张参考图`;
|
||
};
|
||
|
||
/* ════════════════════════════════════════════════
|
||
mode === "image" → 影擎图片创作工作台
|
||
════════════════════════════════════════════════ */
|
||
if (mode === "image") {
|
||
return (
|
||
<div className="yz-image image-workbench">
|
||
<header className="image-subpage-header">
|
||
<div className="image-title-row">
|
||
<button type="button" className="image-back-button" aria-label="返回" onClick={onBack}>
|
||
<ArrowLeft />
|
||
</button>
|
||
<div>
|
||
<h1>图片创作</h1>
|
||
<p>使用提示词与参考图,自由生成或修改电商视觉素材</p>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="secondary-action" onClick={handleNewConversation}>
|
||
<Plus />
|
||
<span>新建对话</span>
|
||
</button>
|
||
</header>
|
||
|
||
<div className="studio-layout">
|
||
<aside className="studio-history">
|
||
<button type="button" className="new-conversation" onClick={handleNewConversation}>
|
||
<Plus />
|
||
<span>新对话</span>
|
||
</button>
|
||
{convError && <div className="ic-conv-error">{convError}</div>}
|
||
<span className="history-label">最近创作 · {conversationScopeLabel}</span>
|
||
{conversations.length === 0 ? (
|
||
<p className="history-empty">
|
||
{convLoading ? "加载中…" : <>还没有历史对话<br />从一个灵感开始。</>}
|
||
</p>
|
||
) : (
|
||
<div className="history-list">
|
||
{conversations.map((conv) => (
|
||
<div
|
||
className={`history-item ${conv.id === activeConvId ? "active" : ""}`}
|
||
key={conv.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => selectConversation(conv.id)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") selectConversation(conv.id); }}
|
||
>
|
||
{renamingId === conv.id ? (
|
||
<input
|
||
className="ic-conv-rename"
|
||
autoFocus
|
||
value={renameDraft}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onChange={(e) => setRenameDraft(e.target.value)}
|
||
onBlur={() => commitRename(conv.id)}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
if (e.key === "Enter") commitRename(conv.id);
|
||
if (e.key === "Escape") setRenamingId("");
|
||
}}
|
||
/>
|
||
) : (
|
||
<span className="nm">{conv.title || "未命名创作"}</span>
|
||
)}
|
||
<span className="ic-conv-acts">
|
||
<button
|
||
type="button"
|
||
title="重命名"
|
||
onClick={(e) => { e.stopPropagation(); setRenamingId(conv.id); setRenameDraft(conv.title); }}
|
||
>
|
||
<Pencil size={12} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="删除对话"
|
||
onClick={(e) => { e.stopPropagation(); setPendingDeleteConversation(conv); }}
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="history-tip">支持上传参考图、引用商品与模特资产;生成结果会自动保存到成品库。</div>
|
||
</aside>
|
||
|
||
<section className="studio-stage">
|
||
<div className={`studio-canvas${hasResults ? " has-results" : ""}`}>
|
||
{hasResults ? (
|
||
<>
|
||
{batches.map((batch) => (
|
||
<div className="gen-card gen-batch-card" key={batch.id}>
|
||
<BatchHeader
|
||
prompt={batch.prompt}
|
||
count={batch.count}
|
||
statusText={batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}
|
||
/>
|
||
{batch.refs && batch.refs.length > 0 && (
|
||
<div className="pt-refs" style={{ display: "flex", gap: 6, marginBottom: 8, alignItems: "center" }}>
|
||
{batch.refs.map((r, i) => (
|
||
<span className="pt-ref" key={`${r.name}-${i}`} title={r.name}>
|
||
<img src={r.url} alt={r.name} style={{ width: 28, height: 28, objectFit: "cover", borderRadius: 6 }} />
|
||
</span>
|
||
))}
|
||
<span className="pt-ref-label">参考图 ×{batch.refs.length}</span>
|
||
</div>
|
||
)}
|
||
{renderBatchGrid(batch)}
|
||
<div className="gen-batch-actions">
|
||
<button className="btn btn-sm" type="button" disabled={batch.status === "generating"} onClick={() => rerunBatch(batch)}>
|
||
<RefreshCw size={13} />
|
||
重跑
|
||
</button>
|
||
{batch.status !== "generating" && (
|
||
<div className="gen-batch-more">
|
||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||
<MoreHorizontal size={13} />
|
||
</button>
|
||
<div className="gen-bubble gen-bubble-up">
|
||
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
|
||
<Trash2 size={13} />
|
||
删除当前批次
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||
<div ref={resultsEndRef} aria-hidden="true" />
|
||
</>
|
||
) : (
|
||
<div className="studio-empty">
|
||
<span className="result-empty-icon"><Sparkles /></span>
|
||
<p className="studio-kicker">IMAGE STUDIO</p>
|
||
<h2>开始你的创作</h2>
|
||
<p>输入想法或上传参考图,把灵感快速变成可用于电商的视觉素材。</p>
|
||
<div className="suggestion-list">
|
||
{IMAGE_SUGGESTIONS.map((item) => (
|
||
<button className="suggestion-chip" type="button" key={item.label} onClick={() => setPrompt(item.prompt)}>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className={`image-composer${refDrop.dragging ? " is-dragover" : ""}`} {...refDrop.dropProps}>
|
||
<div className="image-composer-main">
|
||
<div>
|
||
<button type="button" className="image-reference-button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
|
||
<ImagePlus />
|
||
</button>
|
||
<input ref={refInputRef} type="file" accept="image/*" multiple hidden onChange={pickReference} />
|
||
{refImages.length > 0 && (
|
||
<div className="image-reference-thumbs">
|
||
{refImages.map((img, index) => (
|
||
<span key={`${img.name}-${index}`}>
|
||
<img src={img.url} alt={img.name} />
|
||
<button type="button" aria-label="移除参考图" onClick={() => setRefImages((prev) => prev.filter((_, i) => i !== index))}>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<textarea
|
||
className="studio-prompt"
|
||
value={prompt}
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
|
||
/>
|
||
</div>
|
||
<div className="image-composer-footer">
|
||
<div className="image-composer-options">
|
||
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
||
<Pill label="比例" value={ratio} options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))} onSelect={setRatio} />
|
||
<Pill label="张数" value={count} options={COUNT_OPTIONS.map((value) => ({ id: value, label: value }))} onSelect={setCount} />
|
||
</div>
|
||
<button className="studio-submit" type="button" onClick={runGenerate} disabled={!canGenerate}>
|
||
<span>预计 {estimateFee(candidateCount)} · 生成</span>
|
||
<ArrowRight />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<ConfirmModal
|
||
open={!!pendingDeleteConversation}
|
||
title="删除当前对话"
|
||
|
||
icon={<Trash2 size={16} />}
|
||
detail="删除后可在垃圾桶恢复,是否需要删除?"
|
||
confirmText="删除"
|
||
onCancel={() => setPendingDeleteConversation(null)}
|
||
onConfirm={confirmDeleteConversation}
|
||
/>
|
||
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
|
||
<ConfirmModal
|
||
open={!!confirmDel}
|
||
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
|
||
|
||
icon={<Trash2 size={16} />}
|
||
detail="删除后可在垃圾桶恢复,是否需要删除?"
|
||
confirmText="删除"
|
||
onCancel={() => setConfirmDel(null)}
|
||
onConfirm={doConfirmedDelete}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
mode === "model" / "cover" → 影擎生成台
|
||
════════════════════════════════════════════════ */
|
||
const productCover = product ? productCoverUrl(product) : "";
|
||
return (
|
||
<div className="yz-image image-workbench">
|
||
<header className="image-subpage-header">
|
||
<div className="image-title-row">
|
||
<button type="button" className="image-back-button" aria-label="返回" onClick={onBack}>
|
||
<ArrowLeft />
|
||
</button>
|
||
<div>
|
||
<h1>{meta.title}</h1>
|
||
<p>{meta.desc}</p>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="product-context" onClick={openProductLibrary}>
|
||
{productCover ? <img src={productCover} alt={product?.title || "商品"} /> : <span className="thumb-fallback">{(product?.title || "商").slice(0, 2)}</span>}
|
||
<div className="product-context-copy">
|
||
<span>当前商品</span>
|
||
<strong>{product ? `${product.title} · ${mode === "cover" ? `${product.images?.length || 0} 张参考图` : (product.category || "未分类")}` : "从商品库选择"}</strong>
|
||
</div>
|
||
</button>
|
||
</header>
|
||
|
||
<div className="generator-layout">
|
||
<aside className="generator-panel">
|
||
<div className="asset-pair-grid">
|
||
<section className="control-section">
|
||
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
|
||
<div className="product-choice-list">
|
||
{featuredProducts.map((item) => {
|
||
const cover = productCoverUrl(item);
|
||
const unread = unreadByProduct?.[item.id] || 0;
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`product-choice ${productId === item.id ? "active" : ""}`}
|
||
key={item.id}
|
||
onClick={() => setProductId(item.id)}
|
||
>
|
||
<span className="choice-check" />
|
||
{unread > 0 && <span className="product-choice-unread">{unread > 99 ? "99+" : unread}</span>}
|
||
{cover ? <img src={cover} alt={item.title} /> : <span className="thumb-fallback">{item.title.slice(0, 2)}</span>}
|
||
<span className="product-choice-copy">
|
||
<strong>{item.title}</strong>
|
||
<span>{productRefLabel(item)}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
className="product-library-choice"
|
||
onClick={openProductLibrary}
|
||
>
|
||
<PackageSearch />
|
||
<span>从商品库选择</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{mode === "model" ? (
|
||
<section className="control-section">
|
||
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
|
||
<div className="model-choice-grid">
|
||
{featuredModels.map((item) => {
|
||
const url = item.files?.[0]?.preview_url;
|
||
const name = item.name || "模特";
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`model-choice ${pickedIds[0] === item.id ? "active" : ""}`}
|
||
key={item.id}
|
||
onClick={() => togglePick(item.id, name)}
|
||
>
|
||
<span className="choice-check" />
|
||
{url ? <img src={url} alt={name} /> : <span className="thumb-fallback">{name.slice(0, 2)}</span>}
|
||
<span className="model-choice-copy">
|
||
<strong>{name}</strong>
|
||
<span>{item.description || "授权模特"}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
<button type="button" className="upload-choice model-upload" onClick={() => setModelLibOpen(true)}>
|
||
<Plus />
|
||
<span>从模特库选择</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<section className="control-section">
|
||
<div className="control-heading"><span className="step-number">2</span><strong>选择平台</strong></div>
|
||
<div className="yz-platform-grid">
|
||
{PLATFORM_OPTIONS.map((item) => (
|
||
<button
|
||
type="button"
|
||
key={item.id}
|
||
className={`platform-choice ${pickedIds.includes(item.id) ? "active" : ""}`}
|
||
onClick={() => togglePick(item.id)}
|
||
>
|
||
<span className="choice-check" />
|
||
<PlatformLogo p={item} />
|
||
<span>{item.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
|
||
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
|
||
<div className="field-group">
|
||
<div className="field-label">
|
||
<span>生成数量</span>
|
||
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
|
||
</div>
|
||
<div className="choice-row">
|
||
{countOptions.map((value) => (
|
||
<button type="button" key={value} className={count === value ? "active" : ""} onClick={() => setCount(value)}>
|
||
{value} 张
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{mode === "model" && (
|
||
<div className="field-group">
|
||
<div className="field-label"><span>图片比例</span><small>电商常用比例</small></div>
|
||
<div className="choice-row four">
|
||
{ratioOptions.map((value) => (
|
||
<button
|
||
type="button"
|
||
key={value}
|
||
className={!ratioManual && ratio === value ? "active" : ""}
|
||
onClick={() => { setRatioManual(false); setRatio(value); }}
|
||
>
|
||
{value}
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className={ratioManual ? "active" : ""}
|
||
onClick={() => {
|
||
setRatioManual(true);
|
||
if (ratioW && ratioH) setRatio(`${ratioW}:${ratioH}`);
|
||
}}
|
||
>
|
||
自定义
|
||
</button>
|
||
</div>
|
||
{ratioManual && (
|
||
<div className="iw-ratio-manual">
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
placeholder="宽"
|
||
value={ratioW}
|
||
onKeyDown={blockNonIntegerKey}
|
||
onChange={(event) => updateRatioDimension(event, "width")}
|
||
/>
|
||
<span className="sep">:</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
placeholder="高"
|
||
value={ratioH}
|
||
onKeyDown={blockNonIntegerKey}
|
||
onChange={(event) => updateRatioDimension(event, "height")}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="field-group">
|
||
<label className="field-label">
|
||
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
|
||
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
|
||
</label>
|
||
<textarea
|
||
className="image-prompt"
|
||
value={prompt}
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
|
||
/>
|
||
<div className="model-line">
|
||
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
||
<span>
|
||
{mode === "cover"
|
||
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
|
||
: "自动匹配商品参考图"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="generator-footer">
|
||
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
|
||
<WandSparkles />
|
||
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
|
||
</button>
|
||
<div className="cost-line">
|
||
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
|
||
<strong>预计 {estimateFee(estimateCount)}</strong>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section className="result-panel">
|
||
<div className="result-topbar">
|
||
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
|
||
<div className="result-topbar-tools">
|
||
<div className="result-tags">
|
||
{mode === "cover" ? (
|
||
<>
|
||
<span>{selectedPlatform?.name || "未选平台"}</span>
|
||
<span>{count} 个图位</span>
|
||
<span>统一视觉</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span>{ratio}</span>
|
||
<span>{count} 张</span>
|
||
<span>自动保存至成品库</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{mode === "cover" && (
|
||
<>
|
||
<div className="tb-search-wrap">
|
||
{searchOpen && (
|
||
<div className="iw-search-box">
|
||
<input className="input" autoFocus placeholder="搜索图片提示词" value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} />
|
||
{gridQuery && (
|
||
<button className="iw-search-clear" type="button" aria-label="清空搜索" onClick={() => setGridQuery("")}>
|
||
<X size={13} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title="搜索" onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
|
||
<Search size={14} />
|
||
</button>
|
||
</div>
|
||
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
|
||
<button className={`search-btn${platformFilter.length ? " active" : ""}`} type="button" title="按平台筛选" onClick={() => setFilterOpen((v) => !v)}>
|
||
<SlidersHorizontal size={14} />
|
||
{platformFilter.length > 0 && <span className="iw-filter-count">{platformFilter.length}</span>}
|
||
</button>
|
||
<div className="chip-menu align-right iw-filter-menu">
|
||
<div className="iw-filter-h">
|
||
<span className="mono">按平台筛选</span>
|
||
<button type="button" className="iw-filter-clear" disabled={!platformFilter.length} onClick={() => setPlatformFilter([])}>清空</button>
|
||
</div>
|
||
<div className="iw-filter-grid">
|
||
{PLATFORM_OPTIONS.map((p) => (
|
||
<button
|
||
type="button"
|
||
key={p.id}
|
||
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
|
||
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
|
||
>
|
||
<PlatformLogo p={p} />
|
||
<span className="nm">{p.name}</span>
|
||
<Check className="ck" size={12} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
|
||
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
|
||
<div className="iw-filter-notice">
|
||
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
|
||
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
|
||
<X size={12} />
|
||
清空筛选
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!hasResults ? (
|
||
<div className="result-empty">
|
||
<span className="result-empty-icon">{mode === "cover" ? <LayoutGrid /> : <Image />}</span>
|
||
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
|
||
<p>
|
||
{mode === "cover"
|
||
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
|
||
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
|
||
<div ref={resultsEndRef} aria-hidden="true" />
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="result-footer">
|
||
{mode === "cover" ? (
|
||
<>
|
||
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 张</strong></div>
|
||
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</strong></div>
|
||
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
|
||
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
|
||
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{/* 模特库全屏弹窗:只选 Model,新增模特后刷新并回填。 */}
|
||
<ModelLibrary
|
||
|
||
open={modelLibOpen}
|
||
mode="replace"
|
||
onClose={() => setModelLibOpen(false)}
|
||
onPick={(assetId, assetName) => {
|
||
setPickedIds([assetId]);
|
||
setPickedModelName(assetName || "");
|
||
loadModels();
|
||
setModelLibOpen(false);
|
||
}}
|
||
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
|
||
onUpload={(file) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
fd.append("name", file.name);
|
||
fd.append("asset_type", "image");
|
||
fd.append("category", "model_portrait");
|
||
return api.uploadAsset(fd).catch(() => null);
|
||
}}
|
||
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
|
||
onRefresh={() => { loadModels(); }}
|
||
/>
|
||
|
||
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
|
||
{plOpen && (
|
||
<ProductLibraryModal
|
||
products={products}
|
||
draft={plDraft}
|
||
setDraft={setPlDraft}
|
||
query={plQuery}
|
||
setQuery={setPlQuery}
|
||
cat={plCat}
|
||
setCat={setPlCat}
|
||
coverUrl={productCoverUrl}
|
||
onClose={() => setPlOpen(false)}
|
||
onConfirm={() => {
|
||
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
|
||
if (plDraft.length) {
|
||
setProductId(plDraft[0]);
|
||
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
|
||
for (const pid of plDraft.slice(1)) {
|
||
const p = products.find((x) => x.id === pid);
|
||
if (mode === "cover") {
|
||
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
|
||
} else {
|
||
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
setPlOpen(false);
|
||
}}
|
||
navigate={navigate}
|
||
/>
|
||
)}
|
||
|
||
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
|
||
<ConfirmModal
|
||
open={!!confirmDel}
|
||
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
|
||
icon={<Trash2 size={16} />}
|
||
detail="删除后可在垃圾桶恢复,是否需要删除?"
|
||
confirmText="删除"
|
||
onCancel={() => setConfirmDel(null)}
|
||
onConfirm={doConfirmedDelete}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* YYX#18:结果卡精简头部 —— 提示词单行折叠(超出给「展开/收起」)+ 灰色张数文案 */
|
||
function BatchHeader({ prompt, count, statusText }: { prompt: string; count: number; statusText: string }) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const [overflow, setOverflow] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
useLayoutEffect(() => {
|
||
const el = ref.current;
|
||
if (el) setOverflow(el.scrollWidth > el.clientWidth + 1); // 折叠态(单行)是否被截断
|
||
}, [prompt]);
|
||
return (
|
||
<div className="iw-batch-head">
|
||
<div className={`ibh-prompt${expanded ? " expanded" : ""}`} ref={ref}>{prompt || "—"}</div>
|
||
{(overflow || expanded) && (
|
||
<button type="button" className="ibh-toggle" onClick={() => setExpanded((v) => !v)}>
|
||
{expanded ? "收起提示词" : "展开提示词"}
|
||
</button>
|
||
)}
|
||
<div className="ibh-count">
|
||
{count} 张 · {statusText}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* 底部 chat 输入栏 · 参数胶囊(基线 image-optimize .param + 下拉气泡) */
|
||
function Pill({
|
||
label,
|
||
value,
|
||
options,
|
||
onSelect
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
options: Array<{ id: string; label: string }>;
|
||
onSelect: (id: string) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<div
|
||
className={`ic-param ${open ? "open" : ""}`}
|
||
tabIndex={0}
|
||
onBlur={() => setOpen(false)}
|
||
>
|
||
<button className="ic-param-btn" type="button" onClick={() => setOpen((prev) => !prev)}>
|
||
<span className="lbl-mono">{label}</span>
|
||
<span>{value}</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
<div className="ic-param-menu">
|
||
{options.map((opt) => (
|
||
<button
|
||
type="button"
|
||
key={opt.id}
|
||
className={`mi ${opt.label === value || opt.id === value ? "selected" : ""}`}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
onSelect(opt.id);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
{opt.label}
|
||
<Check className="mi-check" size={12} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
P0④ · 商品库全屏选择器(pl-modal · 基线 model-photo.html .pl-modal)
|
||
左 = 分类侧栏;右 = 搜索 toolbar + 商品多选网格(右上勾)+ 底部已选汇总 + 确定。
|
||
全 restraint token,不复用 styles.css 旧版 .pl-card(暖米)。
|
||
════════════════════════════════════════════════ */
|
||
function ProductLibraryModal({
|
||
products,
|
||
draft,
|
||
setDraft,
|
||
query,
|
||
setQuery,
|
||
cat,
|
||
setCat,
|
||
coverUrl,
|
||
onClose,
|
||
onConfirm,
|
||
navigate
|
||
}: {
|
||
products: Product[];
|
||
draft: string[];
|
||
setDraft: (next: string[]) => void;
|
||
query: string;
|
||
setQuery: (v: string) => void;
|
||
cat: string;
|
||
setCat: (v: string) => void;
|
||
coverUrl: (p: Product) => string;
|
||
onClose: () => void;
|
||
onConfirm: () => void;
|
||
navigate?: (page: Page) => void;
|
||
}) {
|
||
// Esc 关闭 + body 滚动锁(与 ModelLibrary 一致体感)
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||
window.addEventListener("keydown", onKey);
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
||
}, [onClose]);
|
||
|
||
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
|
||
const visible = products.filter((p) => {
|
||
if (cat && p.category !== cat) return false;
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return `${p.title} ${p.category || ""} ${p.brand || ""}`.toLowerCase().includes(q);
|
||
});
|
||
function toggle(id: string) {
|
||
setDraft(draft.includes(id) ? draft.filter((x) => x !== id) : [...draft, id]);
|
||
}
|
||
|
||
return createPortal(
|
||
<div className="pl-modal-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||
<div className="pl-modal" role="dialog" aria-modal="true" aria-label="商品库">
|
||
<div className="pl-modal-h">
|
||
<h2>商品库</h2>
|
||
<span className="ct">选择商品 · 可多选</span>
|
||
<div className="actions">
|
||
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="pl-modal-body">
|
||
<aside className="pl-side">
|
||
<div className="pl-side-h">分类</div>
|
||
<div className={`pl-side-item${!cat ? " active" : ""}`} role="button" tabIndex={0} onClick={() => setCat("")}>
|
||
全部商品
|
||
<span className="ct">{products.length}</span>
|
||
</div>
|
||
{categories.map((c) => (
|
||
<div className={`pl-side-item${cat === c ? " active" : ""}`} key={c} role="button" tabIndex={0} onClick={() => setCat(c)}>
|
||
{c}
|
||
<span className="ct">{products.filter((p) => p.category === c).length}</span>
|
||
</div>
|
||
))}
|
||
</aside>
|
||
<div className="pl-main">
|
||
<div className="pl-toolbar">
|
||
<div className="search">
|
||
<Search size={14} />
|
||
<input placeholder="搜索商品 / 分类 / 品牌" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||
</div>
|
||
{navigate && (
|
||
<button className="btn-new" type="button" onClick={() => navigate("productCreateUpload")}>
|
||
<Plus size={13} />
|
||
新建商品
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="pl-scroll">
|
||
{visible.length === 0 ? (
|
||
<div className="pl-empty">
|
||
<div>没有符合条件的商品</div>
|
||
</div>
|
||
) : (
|
||
<div className="pl-grid">
|
||
{visible.map((p) => {
|
||
const url = coverUrl(p);
|
||
const on = draft.includes(p.id);
|
||
return (
|
||
<div className={`pl-card${on ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => toggle(p.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(p.id); } }}>
|
||
<span className="pl-check"><Check size={11} /></span>
|
||
{url ? (
|
||
<div className="pl-thumb has-real-media"><img src={url} alt={p.title} loading="lazy" /></div>
|
||
) : (
|
||
<div className="placeholder pl-thumb"><span className="ph-frame">{p.title.slice(0, 2)}</span></div>
|
||
)}
|
||
<div className="pl-name">{p.title}</div>
|
||
<div className="pl-meta">{p.category || "未分类"}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="pl-modal-f">
|
||
<span className="summary">已选 <b>{draft.length}</b> 个商品</span>
|
||
<button className="btn" type="button" onClick={onClose}>取消</button>
|
||
<button className="btn btn-primary" type="button" disabled={draft.length === 0} onClick={onConfirm}>确定 ({draft.length})</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
模特图方案对比 Demo(纯静态展示 · 像素还原)
|
||
variant="A" → public/exact/model-photo-demo-a.html
|
||
variant="B" → public/exact/model-photo-demo-b.html
|
||
左栏 = 商品空间(搜索 + 最近 6 条 + 全部入口),由真 products 填充,空则回退基线占位。
|
||
主区为静态方案展示(A:参数面板 + 结果双栏;B:任务流 + 底部 fixed 参数栏)。
|
||
════════════════════════════════════════════════ */
|
||
|
||
/* 基线左栏占位商品(无真 products 时兜底) */
|
||
const DEMO_SIDE_PRODUCTS = [
|
||
{ id: "d1", title: "透真补水面膜", category: "美妆个护", batches: 6 },
|
||
{ id: "d2", title: "透真清透防晒霜", category: "美妆个护", batches: 3 },
|
||
{ id: "d3", title: "南卡 Lite Pro 蓝牙耳机", category: "数码 3C", batches: 2 },
|
||
{ id: "d4", title: "滋啦速食牛肉面", category: "食品饮料", batches: 1 },
|
||
{ id: "d5", title: "三顿半同款冻干咖啡", category: "食品饮料", batches: 1 },
|
||
{ id: "d6", title: "小熊 4L 可视空气炸锅", category: "家居家电", batches: 0 }
|
||
];
|
||
|
||
export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { variant: "A" | "B"; products: Product[]; onBack: () => void; navigate?: (page: Page) => void }) {
|
||
// 方案 A/B 是设计展示页(mock 数据,无生成后端);所有动作按钮导向真实工具 / 改本地选中态,杜绝死按钮
|
||
const [demoCount, setDemoCount] = useState("4 张");
|
||
const [demoRatio, setDemoRatio] = useState("3:4");
|
||
const toRealTool = () => onBack();
|
||
// 左栏商品空间:优先真 products(最近 6 条),空则回退基线占位
|
||
const sideProducts =
|
||
products.length > 0
|
||
? products.slice(0, 6).map((item, index) => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
category: item.category || "未分类",
|
||
batches: DEMO_SIDE_PRODUCTS[index % DEMO_SIDE_PRODUCTS.length].batches
|
||
}))
|
||
: DEMO_SIDE_PRODUCTS;
|
||
|
||
const [activeId, setActiveId] = useState(sideProducts[0].id);
|
||
const active = sideProducts.find((item) => item.id === activeId) || sideProducts[0];
|
||
const totalCount = products.length > 0 ? products.length : 24;
|
||
|
||
// 共享左栏(两版一致)
|
||
const side = (
|
||
<aside className="dm-side">
|
||
<div className="dm-side-h">
|
||
<div className="ti-row">
|
||
<span className="ti">商品空间</span>
|
||
<button className="add" type="button" title="新建商品" onClick={() => navigate?.("productCreateUpload")}>
|
||
<Plus size={11} />
|
||
</button>
|
||
</div>
|
||
<div className="dm-search">
|
||
<Search size={13} />
|
||
<input type="text" placeholder="搜索商品 / 分类" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dm-prod-list">
|
||
{sideProducts.map((item) => (
|
||
<button
|
||
type="button"
|
||
key={item.id}
|
||
className={`dm-prod ${item.id === active.id ? "active" : ""}`}
|
||
onClick={() => setActiveId(item.id)}
|
||
>
|
||
<div className="thumb">主图</div>
|
||
<div className="body">
|
||
<div className="nm">{item.title}</div>
|
||
<div className="sub">{item.category} · {item.batches} 批</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button className="dm-all" type="button" onClick={() => navigate?.("products")}>
|
||
<LayoutGrid size={12} />
|
||
全部商品
|
||
<span className="ct">{totalCount} 个</span>
|
||
<ArrowRight className="arrow" size={12} />
|
||
</button>
|
||
</aside>
|
||
);
|
||
|
||
// ── 返回(基线无,挂在主区头部,保留 onBack)──
|
||
const backBtn = (
|
||
<button className="dm-back" type="button" onClick={onBack}>
|
||
<ArrowLeft size={14} />
|
||
返回模特图
|
||
</button>
|
||
);
|
||
|
||
if (variant === "A") {
|
||
return (
|
||
<div className="model-demo dm-a">
|
||
<div className="dm-banner">
|
||
// DEMO · 方案 A · <b>商品 = 项目空间</b>。左栏仅商品空间:搜索 + 最近 6 条 + <b>全部商品</b>兜底入口;
|
||
历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。
|
||
</div>
|
||
|
||
<div className="dm-grid">
|
||
{side}
|
||
|
||
<section className="dm-main">
|
||
{/* 顶部 · 商品名 + 统计 */}
|
||
<div className="dm-main-h">
|
||
<div className="cur">
|
||
<div className="crumb">商品空间</div>
|
||
<h2>{active.title}</h2>
|
||
</div>
|
||
<div className="stats">
|
||
<span>本商品 <b>6</b> 批</span>
|
||
<span className="sep">·</span>
|
||
<span>累计 <b>22</b> 张图</span>
|
||
<span className="sep">·</span>
|
||
<span>最近 <b>3 分钟前</b></span>
|
||
</div>
|
||
{backBtn}
|
||
</div>
|
||
|
||
{/* 参数面板 + 结果双栏 */}
|
||
<div className="dm-body">
|
||
{/* 左 · 参数面板 */}
|
||
<div className="dm-form">
|
||
<div className="dm-form-scroll">
|
||
<div className="dm-field">
|
||
<div className="dm-field-h">
|
||
选择模特<span className="opt">(已锁定商品 · {active.title})</span>
|
||
</div>
|
||
<div className="dm-models">
|
||
{["Ava", "Zoe", "Ben", "Lin", "Mia"].map((name, index) => (
|
||
<div className={`dm-model ${index === 0 ? "selected" : ""}`} key={name}>
|
||
<div className="ph">{name} · 3:4</div>
|
||
<div className="nm">{name}</div>
|
||
</div>
|
||
))}
|
||
<div className="dm-model add">
|
||
<Plus size={18} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dm-field">
|
||
<div className="dm-field-h">生成张数</div>
|
||
<div className="dm-chip-row">
|
||
{["1 张", "2 张", "4 张", "8 张"].map((label) => (
|
||
<button type="button" key={label} className={`dm-chip ${label === demoCount ? "active" : ""}`} onClick={() => setDemoCount(label)}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dm-field">
|
||
<div className="dm-field-h">画面比例</div>
|
||
<div className="dm-chip-row">
|
||
{["1:1", "3:4", "9:16", "16:9"].map((label) => (
|
||
<button type="button" key={label} className={`dm-chip ${label === demoRatio ? "active" : ""}`} onClick={() => setDemoRatio(label)}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dm-field">
|
||
<div className="dm-field-h">补充提示词<span className="opt">(选填)</span></div>
|
||
<textarea className="dm-textarea" placeholder="例:户外阳光、敷面膜的特写、白底产品摄影" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dm-form-cta">
|
||
<div className="dm-cost">
|
||
<span>预估扣费 <span className="v">≈ 20 积分/张</span></span>
|
||
<span>余额以账单库为准</span>
|
||
</div>
|
||
<button className="dm-gen" type="button" onClick={toRealTool}>
|
||
<Sparkles size={15} />
|
||
立即生成 · {active.title} × Ava
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右 · 结果(当前商品全部批次) */}
|
||
<div className="dm-result">
|
||
<div className="dm-result-h">
|
||
<span className="ti">最近批次 · Ava × 4 张</span>
|
||
<span className="sub">3 分钟前 · 已完成</span>
|
||
</div>
|
||
|
||
{/* 批次 1 */}
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">4×</div>
|
||
<div className="meta">
|
||
<div className="nm">Ava × 4 张</div>
|
||
<div className="info">
|
||
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 3 分钟前{" "}
|
||
<span className="sep">·</span> 20 积分/张
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
|
||
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<div className="dm-cell" key={n}>
|
||
<div className="ph">Ava · #{n}</div>
|
||
<span className="tag">3:4</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 批次 2 */}
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">4×</div>
|
||
<div className="meta">
|
||
<div className="nm">Zoe × 4 张</div>
|
||
<div className="info">
|
||
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 12 分钟前{" "}
|
||
<span className="sep">·</span> 20 积分/张
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
|
||
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<div className="dm-cell" key={n}>
|
||
<div className="ph">Zoe · #{n}</div>
|
||
<span className="tag">3:4</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 批次 3 · 生成中 */}
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">2×</div>
|
||
<div className="meta">
|
||
<div className="nm">Ben × 2 张</div>
|
||
<div className="info">
|
||
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 刚刚{" "}
|
||
<span className="sep">·</span> 生成中
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
|
||
</div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
<div className="dm-cell"><div className="ph">生成中…</div></div>
|
||
<div className="dm-cell"><div className="ph">生成中…</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── variant === "B" · v2:任务流主区 + 底部 fixed 参数栏 ──
|
||
return (
|
||
<div className="model-demo dm-b">
|
||
<div className="dm-banner">
|
||
// DEMO v2 · 方案 A · <b>商品空间 + 任务流主区</b>。左栏只保留商品空间(搜索+最近6条+全部入口),
|
||
任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed 化(类 image-optimize)。
|
||
</div>
|
||
|
||
<div className="dm-grid">
|
||
{side}
|
||
|
||
<section className="dm-main">
|
||
{/* 顶部 标题 + stats + toolbar */}
|
||
<div className="dm-main-h">
|
||
<div className="crumb">商品空间 · 模特上身图</div>
|
||
<div className="title-row">
|
||
<h2>{active.title}</h2>
|
||
{backBtn}
|
||
</div>
|
||
<div className="row">
|
||
<div className="stats">
|
||
<span>{active.category}</span><span className="sep">·</span>
|
||
<span>本商品 <b>6</b> 批</span><span className="sep">·</span>
|
||
<span>累计 <b>22</b> 张图</span><span className="sep">·</span>
|
||
<span>最近 <b>3 分钟前</b></span>
|
||
</div>
|
||
<span className="spacer" />
|
||
<div className="dm-tb">
|
||
<button className="icbtn" type="button" title="搜索批次" onClick={toRealTool}><Search size={13} /></button>
|
||
<button className="chip" type="button" onClick={toRealTool}>时间 <ChevronDown size={10} /></button>
|
||
<button className="chip" type="button" onClick={toRealTool}>状态 <ChevronDown size={10} /></button>
|
||
<button className="chip" type="button" onClick={toRealTool}>模特 <ChevronDown size={10} /></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 任务流 */}
|
||
<div className="dm-stream">
|
||
<div className="dm-day-h">
|
||
<span>今天</span>
|
||
<span className="ct">3 批 · 10 张</span>
|
||
</div>
|
||
|
||
{/* 批次 1 */}
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">4×</div>
|
||
<div className="meta">
|
||
<div className="nm">Ava × 4 张 <span className="pill ok stat-pill"><span className="dot" />已完成</span></div>
|
||
<div className="info">
|
||
<span>3:4</span><span className="sep">·</span>
|
||
<span>3 分钟前</span><span className="sep">·</span>
|
||
<span>20 积分</span>
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
|
||
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<div className="dm-cell" key={n}><div className="ph">Ava · #{n}</div><span className="tag">3:4</span></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 批次 2 */}
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">4×</div>
|
||
<div className="meta">
|
||
<div className="nm">Zoe × 4 张 <span className="pill ok stat-pill"><span className="dot" />已完成</span></div>
|
||
<div className="info">
|
||
<span>3:4</span><span className="sep">·</span>
|
||
<span>12 分钟前</span><span className="sep">·</span>
|
||
<span>20 积分</span>
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
|
||
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<div className="dm-cell" key={n}><div className="ph">Zoe · #{n}</div><span className="tag">3:4</span></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 批次 3 · 生成中 */}
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">2×</div>
|
||
<div className="meta">
|
||
<div className="nm">Ben × 2 张 <span className="pill info stat-pill"><span className="dot" />生成中</span></div>
|
||
<div className="info">
|
||
<span>3:4</span><span className="sep">·</span>
|
||
<span>刚刚</span><span className="sep">·</span>
|
||
<span>20 积分</span>
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
|
||
</div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
<div className="dm-cell gen"><div className="ph">生成中…</div></div>
|
||
<div className="dm-cell gen"><div className="ph">生成中…</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 昨天 */}
|
||
<div className="dm-day-h">
|
||
<span>昨天</span>
|
||
<span className="ct">2 批 · 8 张</span>
|
||
</div>
|
||
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">4×</div>
|
||
<div className="meta">
|
||
<div className="nm">Lin × 4 张 <span className="pill ok stat-pill"><span className="dot" />已完成</span></div>
|
||
<div className="info">
|
||
<span>3:4</span><span className="sep">·</span>
|
||
<span>昨天 18:24</span><span className="sep">·</span>
|
||
<span>20 积分</span>
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
|
||
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button> </div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
{[1, 2, 3, 4].map((n) => (
|
||
<div className="dm-cell" key={n}><div className="ph">Lin · #{n}</div><span className="tag">3:4</span></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 更早 */}
|
||
<div className="dm-day-h">
|
||
<span>更早</span>
|
||
<span className="ct">1 批 · 2 张 · 含 1 失败</span>
|
||
</div>
|
||
|
||
<div className="dm-batch">
|
||
<div className="dm-batch-h">
|
||
<div className="pic">2×</div>
|
||
<div className="meta">
|
||
<div className="nm">Ava × 2 张 <span className="pill err stat-pill"><span className="dot" />失败</span></div>
|
||
<div className="info">
|
||
<span>3:4</span><span className="sep">·</span>
|
||
<span>2 天前</span>
|
||
</div>
|
||
</div>
|
||
<div className="ops">
|
||
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
|
||
<button type="button" title="删除"><Trash2 size={13} /></button>
|
||
</div>
|
||
</div>
|
||
<div className="dm-batch-grid">
|
||
<div className="dm-cell err"><div className="ph">失败 · 点重跑</div></div>
|
||
<div className="dm-cell err"><div className="ph">失败 · 点重跑</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部 fixed 参数面板 */}
|
||
<div className="dm-param-wrap">
|
||
<div className="dm-param">
|
||
<button className="pchip active" type="button" onClick={toRealTool}>
|
||
<span className="lbl-mono">模特</span>
|
||
<span>Ava</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
<button className="pchip" type="button" onClick={toRealTool}>
|
||
<span className="lbl-mono">张数</span>
|
||
<span>4</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
<button className="pchip" type="button" onClick={toRealTool}>
|
||
<span className="lbl-mono">比例</span>
|
||
<span>3:4</span>
|
||
<ChevronDown size={10} />
|
||
</button>
|
||
<button className="pchip" type="button" onClick={toRealTool}>
|
||
<span className="lbl-mono">补充提示词</span>
|
||
<span className="muted">+ 添加</span>
|
||
</button>
|
||
<span className="spacer" />
|
||
<span className="meta-right">预估 <span className="v">20 积分/张</span></span>
|
||
<button className="gen-btn" type="button" onClick={toRealTool}>
|
||
<Sparkles size={14} />
|
||
生成 · {active.title} × Ava
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|