Files
yingqing/core/frontend/src/routes/ai-tools.tsx
T
zycandClaude Sonnet 5 bf20de956c feat(billing): 计费商业化重构(积分制)+ 团队差异化调价
统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账,
视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。
Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类
按下单时的价格/汇率快照结算(中途改配不影响在途任务)。

BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale
迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0,
商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 10:04:00 +08:00

2785 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
Bookmark,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Download,
Grid2X2,
ImagePlus,
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Plus,
Quote,
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 { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } 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 "../ai-tools-page.css";
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
// 而 task_type 区分不了——cover 与 image 都是 product_image)
const MODE_LABEL: Record<string, string> = {
model: "模特上身图",
cover: "平台套图",
image: "图片创作"
};
const STATUS_LABEL: Record<string, string> = {
succeeded: "已完成",
completed: "已完成",
done: "已完成",
ok: "已完成",
skipped: "已跳过",
failed: "失败",
error: "失败",
running: "生成中",
queued: "排队中",
polling: "生成中",
needs_review: "待确认",
// 后端 AITask.Status 全量中文化(原先缺这些会直接透出英文)
created: "待处理",
reserved: "排队中",
submitted: "已提交",
postprocessing: "处理中",
compensating: "回滚中",
cancelled: "已取消"
};
function statusText(status: string) {
return STATUS_LABEL[status] || status;
}
// 下载图片:优先 fetch→blob 触发真实下载;跨域失败则回退到新标签打开(用户仍拿到图)
async function downloadImage(url: string, filename: string) {
try {
const res = await fetch(url, { mode: "cors" });
const blob = await res.blob();
const href = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
} catch {
window.open(url, "_blank", "noopener");
}
}
export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void }) {
// 任务历史 + 任务→结果图:都在本页懒加载(不再吃全局 bootstrap 的 aiTasks/assets 全量)。
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
const [tasksLoading, setTasksLoading] = useState(true);
const [assets, setAssets] = useState<Asset[]>([]);
useEffect(() => {
let alive = true;
setTasksLoading(true);
api.aiTasks().then((res) => { if (alive) setAiTasks(res.results || []); }).catch(() => {}).finally(() => { if (alive) setTasksLoading(false); });
// 任务中心 = 生成历史:要看全部生成图(含未加入资产库的),故 in_library: "all" 旁路过滤
api.assetsPage({ asset_type: "image", source: "ai_generated", in_library: "all", pageSize: 200 })
.then((res) => { if (alive) setAssets(res.results); })
.catch(() => { if (alive) setAssets([]); });
return () => { alive = false; };
}, []);
const taskImage = useMemo(() => {
const map: Record<string, string> = {};
for (const asset of assets) {
const taskId = asset.origin_task;
if (!taskId || map[taskId]) continue;
const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true));
if (file?.preview_url) map[taskId] = file.preview_url;
}
return map;
}, [assets]);
const cards = [
{
page: "modelPhoto" as Page,
tag: "[ MODEL · TRY-ON ]",
title: "模特上身图",
desc: "选择模特,AI 生成商品模特上身效果图",
cost: "≈ 20 积分 / 张"
},
{
page: "platformCover" as Page,
tag: "[ PLATFORM · KIT ]",
title: "平台套图",
desc: "选择平台模板,AI 生成电商平台套图",
cost: "≈ 20 积分 / 张"
},
{
page: "imageOptimize" as Page,
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
cost: "≈ 20 积分 / 张"
}
];
// 任务中心只看「工作台图片生成」(mode∈model/cover/image —— 能分模特上身图/平台套图/图片创作;
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
type TaskBatch = {
key: string; batchId: string | null; firstTaskId: string; label: string; mode: string;
count: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
};
const taskBatches = useMemo<TaskBatch[]>(() => {
const groups = new Map<string, AITask[]>();
for (const t of aiTasks) {
if (!t.mode || !MODE_LABEL[t.mode]) continue; // 只保留图片生成模式(model/cover/image)
const key = t.batch_id || t.id;
const arr = groups.get(key);
if (arr) arr.push(t);
else groups.set(key, [t]);
}
const out: TaskBatch[] = [];
for (const [key, tasks] of groups) {
const first = tasks[0];
const pills = tasks.map((t) => statusPill(t.status));
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
const cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
}
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
return out;
}, [aiTasks, taskImage]);
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const b of taskBatches) {
if (b.status === "ok") acc.ok += 1;
else if (b.status === "err") acc.err += 1;
else acc.gen += 1;
}
return acc;
}, [taskBatches]);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
const [taskPage, setTaskPage] = useState(1);
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
const [batchImgs, setBatchImgs] = useState<Asset[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
useEffect(() => {
if (!openBatch) { setBatchImgs([]); return; }
let alive = true;
setBatchLoading(true);
// 有 batch_id 按批取整批;旧图无 batch_id 则按 origin_task 取该任务那张
const params = openBatch.batchId
? { meta: { batch_id: openBatch.batchId }, in_library: "all", asset_type: "image", pageSize: 50 }
: { origin_task: openBatch.firstTaskId, in_library: "all", asset_type: "image", pageSize: 50 };
api.assetsPage(params)
.then((res) => { if (alive) setBatchImgs(res.results); })
.catch(() => { if (alive) setBatchImgs([]); })
.finally(() => { if (alive) setBatchLoading(false); });
return () => { alive = false; };
}, [openBatch]);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
// 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.key}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
});
// 筛选条件变化时回到第 1 页
useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]);
const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE));
const taskCurPage = Math.min(taskPage, taskTotalPages);
const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE);
return (
<div className="asset-factory">
<div className="page-head">
<div>
<h1>图片生成</h1>
<div className="sub">
<span className="mono">// 一键生成</span>
<span>·</span>
<span>电商视觉素材,提升内容制作效率</span>
</div>
</div>
</div>
<div className="factory-hero">
{cards.map((card) => (
<article className="factory-card with-corners" key={card.title}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="factory-body">
<div className="factory-text">
<span className="factory-tag">{card.tag}</span>
<div className="factory-title">{card.title}</div>
<div className="factory-desc">{card.desc}</div>
{/* CTA 放在 factory-text 内(对齐 asset-factory.html):按钮紧跟描述,
而非被 factory-body 的 height:100% + margin-top:auto 顶到卡片最底部 */}
<div className="factory-cta">
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
开始生成
<ArrowRight size={14} />
</button>
<span className="cost">[ {card.cost} ]</span>
</div>
</div>
</div>
</article>
))}
</div>
<div className="section-h">
<h2>任务中心</h2>
<span className="sub-mono">
// {taskBatches.length} 批 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
</span>
</div>
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
<div className="tabs" id="tc-tabs">
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}>全部 <span className="count">{taskBatches.length}</span></div>
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}>生成中 <span className="count">{counts.gen}</span></div>
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}>已完成 <span className="count">{counts.ok}</span></div>
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}>失败 <span className="count">{counts.err}</span></div>
</div>
<div className="toolbar">
<div className="search-inline">
<Search size={14} />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{TIME_OPTS.find((o) => o.value === timeFilter)?.label !== "全部时间" ? TIME_OPTS.find((o) => o.value === timeFilter)?.label : "时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter || "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
</div>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{t}
</div>
))}
</div>
</div>
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && (
<button className="clear-filters" type="button" onClick={() => { setFilter("all"); setQuery(""); setTimeFilter("all"); setTypeFilter(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
清空筛选
</button>
)}
<span className="spacer" />
<div className="view-toggle">
<button type="button" className={view === "grid" ? "active" : ""} data-view="grid" onClick={() => setView("grid")}>
<Grid2X2 size={13} />
网格
</button>
<button type="button" className={view === "list" ? "active" : ""} data-view="list" onClick={() => setView("list")}>
<List size={13} />
列表
</button>
</div>
</div>
<div className="result-meta">
// 显示 {paged.length} / {visible.length} 批
</div>
{tasksLoading && aiTasks.length === 0 ? (
<SkeletonRows count={5} />
) : aiTasks.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO TASKS YET</div>
<div>还没有任务,去上方选一个工序开始生成吧</div>
</div>
) : visible.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO MATCH</div>
<div>没有符合筛选条件的任务</div>
</div>
) : view === "grid" ? (
<div className="history-grid">
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<article className="task-card history-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<div className={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
<div className="history-body">
<div className="history-name">{batch.label}</div>
<div className="history-type">// {batch.count} 张</div>
<div className="history-foot">
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="task-list-view">
<table className="t">
<thead>
<tr>
<th style={{ width: "42%" }}>任务</th>
<th style={{ width: 160 }}>进度</th>
<th>状态</th>
<th style={{ width: 120 }}>创建于</th>
<th style={{ width: 48 }} />
</tr>
</thead>
<tbody>
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<tr key={batch.key} role="button" tabIndex={0} title="查看本批图片" style={{ cursor: "pointer" }}
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<td>
<div className="task-name-cell">
<div className={`placeholder task-thumb${batch.cover ? " has-img" : ""}`}>
{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label.slice(0, 2)}</span>}
</div>
<div>
<div className="task-name">{batch.label}</div>
<div className="task-sub">// {batch.count} 张</div>
</div>
</div>
</td>
<td>
{batch.status === "info" ? (
<div className="task-list-prog">
<div className="bar">
<span style={{ width: "60%" }} />
</div>
<span className="pct">生成中</span>
</div>
) : (
<span className="muted-2 mono" style={{ fontSize: 12 }}>
{batch.status === "ok" ? "已完成" : "—"}
</span>
)}
</td>
<td>
<span className={`pill ${batch.status}`}>
<span className="dot" />
{statusLabel}
</span>
</td>
<td className="muted-2 mono" style={{ fontSize: 12 }}>{(batch.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{openBatch && createPortal(
<div className="modal-bg show" onClick={() => setOpenBatch(null)}>
<div className="modal with-corners" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 760, width: "92%" }}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="modal-h">
<div className="ic-m"><LayoutGrid size={17} /></div>
<div className="ti">{openBatch.label}<span>// {openBatch.count} 张 · {(openBatch.created_at || "").slice(0, 10)}</span></div>
<span style={{ flex: 1 }} />
{/* R109:成图自动入资产库,批次弹窗不再放「一键加入/移出资产库」按钮 */}
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}><X size={16} /></button>
</div>
<div className="modal-b">
{batchLoading ? (
<div className="task-empty"><div className="mono">// LOADING…</div></div>
) : batchImgs.length === 0 ? (
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
) : (
<div className="gen-images" style={{ "--cols": Math.min(4, batchImgs.length), "--ratio": "1 / 1" } as React.CSSProperties}>
{batchImgs.map((a, i) => {
const u = a.files?.find((f) => f.preview_url)?.preview_url || a.files?.[0]?.preview_url || "";
return (
<div className="gen-image" key={a.id}>
{u ? (
<img className="gen-image-img" src={u} alt={a.name} loading="lazy" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: u, name: a.name })} />
) : (
<div className="placeholder"><span className="ph-frame">#{i + 1}</span></div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</div>
);
}
type WorkMode = "image" | "model" | "cover";
const MODE_META: Record<
WorkMode,
{
title: string;
tag: string;
desc: string;
ratio: string;
promptTemplate: (productTitle: string) => string;
}
> = {
image: {
title: "图片创作",
tag: "[ IMAGE · STUDIO ]",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。",
ratio: "1:1",
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
},
model: {
title: "模特上身图",
tag: "[ MODEL · TRY-ON ]",
desc: "选择模特和商品,生成电商模特上身图。",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "选择平台模板,一键生成平台商品头图。",
// 优化版:商品上架主图默认 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 GEN_MODEL_OPTIONS: { value: string; label: string }[] = [
{ value: "volcano", label: "火山 Seedream" },
{ value: "gpt-image", label: "gpt-image-2" },
];
const COVER_COUNT_OPTIONS = ["4", "8", "12"];
/* 图片创作 · 空态提示词建议 chip(基线 image-optimize EXAMPLES) */
const IMAGE_SUGGESTIONS = [
"一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风",
"极简北欧风格的茶杯,白底,自然柔光,产品摄影",
"国风水墨海报,主体一只白鹤立于水边,留白构图",
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
];
/* 平台套图 · 平台卡(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[];
};
/* 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,
initialProductId,
onProductChange,
unreadByProduct,
onProductViewed
}: {
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; 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>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
/** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */
onProductChange?: (productId: string) => void;
/** YYX#row22:每个商品的未读生成任务数(商品角标) */
unreadByProduct?: Record<string, number>;
/** YYX#row22:查看某商品(选中)即标记该商品的生成任务已读 → 清零角标 */
onProductViewed?: (productId: string) => void;
}) {
const meta = MODE_META[mode];
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]);
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
useEffect(() => { if (productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [productId, unreadByProduct, onProductViewed]);
const product = products.find((item) => item.id === productId) || products[0];
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
const [ratio, setRatio] = useState(meta.ratio);
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
const [ratioManual, setRatioManual] = useState(false);
const [ratioW, setRatioW] = useState("");
const [ratioH, setRatioH] = useState("");
// 生图模型选择:默认火山(内衣等敏感品类不被审核拦,还原也更好),可切 gpt-image-2。持久化到 localStorage。
const [genModel, setGenModel] = useState<string>(() => {
try { return localStorage.getItem(GEN_MODEL_KEY) || "volcano"; } catch { return "volcano"; }
});
useEffect(() => { try { localStorage.setItem(GEN_MODEL_KEY, genModel); } catch { /* 忽略 */ } }, [genModel]);
const [count, setCount] = useState(mode === "image" ? "4" : "4");
// 模特单选(长度恒 0/1);平台改回多选(P0③:可选多个平台,各出一组结果)
const [pickedIds, setPickedIds] = useState<string[]>([]);
// 模特库全屏弹窗(P0①:复用现成 ActorLibrary,mode=replace 选一个回填)
const [actorLibOpen, setActorLibOpen] = useState(false);
// 选中模特的展示名(从模特库/内联卡记下,导航头/CTA 显示用)
const [pickedModelName, setPickedModelName] = useState("");
// 商品库全屏选择器(P0④:pl-modal 多选 → 每个选中商品各起一批)
const [plOpen, setPlOpen] = useState(false);
const [plDraft, setPlDraft] = useState<string[]>([]);
const [plQuery, setPlQuery] = useState("");
const [plCat, setPlCat] = useState("");
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
const [openMore, setOpenMore] = useState("");
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
const [batches, setBatches] = useState<GenBatch[]>([]);
/* ── 图片创作「对话」(真实体,后端 ImageConversation)──
conversations = 左栏会话列表;activeConvId = 当前选中对话(空 = 还没建,首次发送时后端自动开)。
activeConvRef 让 startBatch 在不进 deps 的情况下读到最新 active id。 */
const [conversations, setConversations] = useState<ImageConversation[]>([]);
const [activeConvId, setActiveConvId] = useState<string>("");
const activeConvRef = useRef<string>("");
useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]);
const [convLoading, setConvLoading] = useState(false);
// 对话操作失败的可见提示(替代原来的静默吞错)
const [convError, setConvError] = useState("");
// 重命名:正在改名的对话 id + 草稿
const [renamingId, setRenamingId] = useState("");
const [renameDraft, setRenameDraft] = useState("");
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 生成后把结果面板滚到最新批次用的哨兵(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#17:左侧商品栏折叠(收起后只剩商品图)
const [sideCollapsed, setSideCollapsed] = useState(false);
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
const [filterOpen, setFilterOpen] = useState(false);
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
const [prodQuery, setProdQuery] = useState("");
const visibleProducts = products.filter((item) => {
const q = prodQuery.trim().toLowerCase();
if (!q) return true;
return `${item.title} ${item.category || ""}`.toLowerCase().includes(q);
});
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
// YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位
return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title);
};
function pickReference(event: ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files || []);
if (!files.length) return;
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
event.target.value = "";
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 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。深埋 ActorLibrary 弹窗同源,逻辑不动。 */
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
const loadModels = useCallback(() => {
api.listModels({ pageSize: 200 })
.then((res) => {
const mapped: Asset[] = res.results
.filter((m: ModelEntity) => m.portrait_asset)
.map((m: ModelEntity) => ({
id: m.portrait_asset as string,
name: m.name,
asset_type: "image",
source: m.source === "upload" ? "upload" : "ai_generated",
category: "model_portrait",
description: m.description || "",
metadata: { model_entity_id: m.id },
files: m.portrait
? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }]
: [],
created_at: m.created_at,
updated_at: m.updated_at,
}));
setPersonAssets(mapped);
})
.catch(() => setPersonAssets([]));
}, []);
useEffect(() => { loadModels(); }, [loadModels]);
useEffect(() => {
// image 模式默认留空(不预填模板);模特/平台仍随商品预填模板
if (mode === "image") setPrompt("");
else if (product) setPrompt(meta.promptTemplate(product.title));
// mode 或商品切换都重置默认比例
setRatio(meta.ratio);
setRatioManual(false);
setRatioW("");
setRatioH("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, mode]);
// 模特 / 平台都单选(YYX#1:平台只能单选):再点同一张取消,点别张替换
function togglePick(id: string, name?: string) {
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
}
// 点击空白收起单图「更多」气泡
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", "compensating"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定
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 intended = live.filter((t) => !t.rerun).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: Math.max(1, intended),
status,
results: assets,
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
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,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
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", "compensating"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
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 intended = live.filter((t) => !t.rerun).length;
result.push({
id: key,
prompt: first.prompt || "",
ratio: first.ratio || meta.ratio,
count: Math.max(1, intended),
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,
// 续轮询要带上整批任务 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) => {
try {
const res = await api.conversationTasks(convId);
const next = batchesFromConvTasks(res.tasks);
setBatches(next);
// 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐
if (onResume) {
for (const b of next.filter((x) => x.status === "generating")) {
const ids = res.tasks.filter((t) => (t.batch_id || t.id) === b.id).map((t) => t.id);
if (!ids.length) continue;
onResume(mode, ids).then((r) => {
if (!r?.assets) return;
setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x)));
});
}
}
} catch {
setBatches([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, onResume]);
// 切换对话:置为 active 并回填它的批次
const selectConversation = useCallback((convId: string) => {
if (convId === activeConvRef.current) return;
setActiveConvId(convId);
setRenamingId("");
void loadConvBatches(convId);
}, [loadConvBatches]);
// 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流
async function handleNewConversation() {
try {
const conv = await api.createConversation({ mode, product: product?.id || null });
setConvError("");
setConversations((prev) => [conv, ...prev]);
setActiveConvId(conv.id);
setBatches([]);
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
setPickedIds([]);
} catch (err) {
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
setConvError(err instanceof Error ? err.message : "新建对话失败,请稍后重试");
}
}
// 提交重命名
async function commitRename(convId: string) {
const title = renameDraft.trim();
setRenamingId("");
if (!title) return;
setConversations((prev) => prev.map((c) => (c.id === convId ? { ...c, title } : c)));
try { await api.renameConversation(convId, title); } catch { void loadConversations(); }
}
// 删除对话(软删):从列表移除;删的是当前对话则切到剩下第一条或清空
async function handleDeleteConversation(convId: string) {
const remaining = conversations.filter((c) => c.id !== convId);
setConversations(remaining);
if (convId === activeConvId) {
const nextActive = remaining[0]?.id || "";
setActiveConvId(nextActive);
if (nextActive) void loadConvBatches(nextActive);
else setBatches([]);
}
try { await api.deleteConversation(convId); } catch { void loadConversations(); }
}
// 列表加载:image 模式才用对话(model/cover 走商品空间布局)。
// autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。
const loadConversations = useCallback(async (autoSelect = true) => {
if (mode !== "image") return;
setConvLoading(true);
try {
const res = await api.listConversations(mode);
setConversations(res.results);
if (autoSelect && res.results.length && !activeConvRef.current) {
setActiveConvId(res.results[0].id);
void loadConvBatches(res.results[0].id);
}
} catch {
setConversations([]);
} finally {
setConvLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, loadConvBatches]);
// 只在挂载 / 切 mode 时加载一次对话列表(不依赖 callback 身份,否则 onResume 每次渲染变更会触发反复重拉)
useEffect(() => { void loadConversations(); }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps
/* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */
async function startBatch(opts: {
prompt: string;
ratio: string;
count: number;
productId?: string;
productTitle?: string;
modelId?: string;
modelName?: string;
platformIds?: string[];
/** 优化版:规范化平台 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;
}) {
// 原地重跑/追加:沿用原批次 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,
onSubmitted: (taskIds, submittedBatchId) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds, backendBatchId: b.backendBatchId || submittedBatchId } : b))) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
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))];
return { ...b, backendBatchId, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, pendingIds: undefined };
}
return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
}));
} catch {
// 追加重跑失败:保留原有好图,只把状态落回(有图=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;
// 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回——
// 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。
if (mode === "image" && !activeConvRef.current) {
try {
const conv = await api.createConversation({ mode, title: prompt.trim().slice(0, 24) || undefined });
activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到
setActiveConvId(conv.id);
setConversations((prev) => [conv, ...prev]);
} catch {
/* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */
}
}
const base = {
prompt: prompt.trim(),
ratio,
count: candidateCount,
productId: product?.id,
productTitle: product?.title
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 优化版:不再把平台名拼进 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: src.productId ?? product?.id,
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
});
}
/* R108/R109:工作台的「删除」= 真删资产(软删进回收站)—— 生成记录与资产库数据绑定,
删了才不会刷新后从后端记录里"复活"。删除前二次确认(ConfirmModal),文案与资产库一致。
confirmDel.assetId 有值 = 删单张;无值 = 删整批(该批全部存活成图)。 */
const [confirmDel, setConfirmDel] = useState<{ batchId: string; assetId?: string } | null>(null);
function doConfirmedDelete() {
const target = confirmDel;
setConfirmDel(null);
if (!target) return;
const batch = batches.find((b) => b.id === target.batchId);
if (!batch) return;
const ids = (target.assetId ? [target.assetId] : batch.results.map((a) => a.id)).filter(Boolean);
ids.forEach((id) => { void api.deleteAsset(id).catch(() => undefined); });
if (target.assetId) {
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [b];
const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return [];
return [{ ...b, results, count: Math.max(1, b.count - 1) }];
}));
} else {
setBatches((prev) => prev.filter((b) => b.id !== target.batchId));
}
}
/* 图片创作「加入模特库」:把该图复用为模特形象图建 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) {
await startBatch({
prompt: src.prompt,
ratio: src.ratio,
count: 1,
productId: src.productId ?? product?.id,
productTitle: src.productTitle ?? product?.title,
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
refs: src.refs,
batchId: src.backendBatchId,
appendToBatchId: src.id
});
}
/* 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- 开头、还在生成中)→ 合并保留,别被整体替换冲掉
setBatches((prev) => [...next, ...prev.filter((b) => b.id.startsWith("b-") && b.status === "generating")]);
if (onResume) {
for (const b of next) {
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 = batch.results.length || batch.count;
const cols = n >= 4 ? 4 : 2;
const ratioBatchVar = batch.ratio.replace(":", " / ");
return (
<div
className="gen-images"
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
>
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败,
给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */}
{Array.from({ length: Math.max(batch.count, batch.results.length) }).map((_, index) => {
const asset = batch.results[index];
const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || "";
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
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)}>
<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>
<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">
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<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>
);
})}
</>
);
}
/* ════════════════════════════════════════════════
mode === "image" → 对话流形态(基线 image-optimize · design.md §4.13)
左会话列表 + 中央 hero/对话流 + 底部 chat 输入栏
════════════════════════════════════════════════ */
if (mode === "image") {
return (
<div className="image-workbench iw-chat">
{/* 左 · 会话列表 */}
<aside className="ic-side">
<div className="ic-side-h">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回
</button>
</div>
<button className="ic-new-conv" type="button" onClick={handleNewConversation}>
<Plus size={13} />
新对话
</button>
{convError && <div className="ic-conv-error">{convError}</div>}
<div className="ic-side-sec">最近</div>
<div className="ic-conv-list">
{conversations.length === 0 ? (
<div className="ic-conv-empty">
{convLoading ? "加载中…" : "还没有最近会话"}
<br />
<span className="mono">// NO HISTORY</span>
</div>
) : (
conversations.map((conv) => (
<div
className={`ic-conv-item ${conv.id === activeConvId ? "active" : ""}`}
key={conv.id}
role="button"
tabIndex={0}
onClick={() => selectConversation(conv.id)}
onKeyDown={(e) => { if (e.key === "Enter") selectConversation(conv.id); }}
>
<div className={`thumb ${conv.id === activeConvId ? "default" : ""}`}>
<ImagePlus size={13} />
</div>
{renamingId === conv.id ? (
<input
className="ic-conv-rename"
autoFocus
value={renameDraft}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={() => commitRename(conv.id)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitRename(conv.id);
if (e.key === "Escape") setRenamingId("");
}}
/>
) : (
<span className="nm">{conv.title || "未命名创作"}</span>
)}
<span className="ic-conv-acts">
<button
type="button"
title="重命名"
onClick={(e) => { e.stopPropagation(); setRenamingId(conv.id); setRenameDraft(conv.title); }}
>
<Pencil size={12} />
</button>
<button
type="button"
title="删除对话"
onClick={(e) => { e.stopPropagation(); handleDeleteConversation(conv.id); }}
>
<Trash2 size={12} />
</button>
</span>
</div>
))
)}
</div>
</aside>
{/* 右 · 对话流 + 底部输入栏 */}
<section className="ic-main">
<div className="ic-stream">
{hasResults ? (
<div className="ic-stream-inner">
{/* 行31/32/33:每个批次独立一条对话,各自展示自己的图与操作 */}
{batches.map((batch) => (
<div className="ic-msg" key={batch.id}>
<div className="ic-msg-prompt">
<span className="quote">
<Quote size={13} />
</span>
<div className="pt">
<div className="pt-text">{batch.prompt}</div>
{batch.refs && batch.refs.length > 0 && (
<div className="pt-refs">
{batch.refs.map((r, i) => (
<span className="pt-ref" key={`${r.name}-${i}`} title={r.name}>
<img src={r.url} alt={r.name} />
</span>
))}
<span className="pt-ref-label">参考图 ×{batch.refs.length}</span>
</div>
)}
<div className="pt-tags">
<span className="meta-chip">{batch.ratio}</span>
<span className="sep">·</span>
<span className="meta-chip">{batch.count} </span>
<span className="sep">·</span>
<span className="meta-chip">{batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}</span>
</div>
</div>
</div>
<div className="gen-card">{renderBatchGrid(batch)}</div>
<div className="gen-card-actions">
<button className="btn btn-sm" type="button" disabled={batch.status === "generating"} onClick={() => rerunBatch(batch)}>
<RefreshCw size={13} />
重跑
</button>
{/* R109:成图自动入资产库,去掉「加入资产库」按钮 */}
<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">
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<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)} />
{/* 生成后自动滚到这里(最新批次末尾),用户不必手动往下拖(PMC#14/#22) */}
<div ref={resultsEndRef} aria-hidden="true" />
</div>
) : (
<div className="ic-empty">
<div className="ic">
<Sparkles size={28} />
</div>
<div className="badge">// IMAGE · STUDIO</div>
<h2>开始你的创作</h2>
<p>输入想法、剧本或上传参考,和 Agent 一起把灵感变成电商视觉素材。</p>
<div className="examples">
{IMAGE_SUGGESTIONS.map((text) => (
<button className="ex" type="button" key={text} onClick={() => setPrompt(text)}>
{text}
</button>
))}
</div>
</div>
)}
</div>
{/* 底部 · chat 输入栏(textarea + 比例/风格/张数 + 发送) */}
<div className="ic-input-wrap">
<div className="ic-input">
<div className="ic-input-top">
<button className="add-btn" type="button" title="上传参考图(可多张)" onClick={() => refInputRef.current?.click()}>
<Plus size={22} />
</button>
<input ref={refInputRef} type="file" accept="image/*" multiple hidden onChange={pickReference} />
{refImages.map((img, index) => (
<span className="meta-chip" key={`${img.name}-${index}`} style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={img.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{img.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImages((prev) => prev.filter((_, i) => i !== index))}>×</button>
</span>
))}
</div>
<textarea
className="ic-input-text"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="输入想法、剧本或上传参考,和 Agent 一起创作"
/>
<div className="ic-input-bottom">
<Pill
label="模型"
value={GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}
options={GEN_MODEL_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
onSelect={setGenModel}
/>
<Pill
label="比例"
value={ratio}
options={RATIO_OPTIONS.map((value) => ({ id: value, label: value }))}
onSelect={setRatio}
/>
<Pill
label="张数"
value={count}
options={COUNT_OPTIONS.map((value) => ({ id: value, label: value }))}
onSelect={setCount}
/>
<span className="right-meta">
预估 <span className="val">{estimateFee(candidateCount)}</span>
</span>
<button className="send-btn" type="button" onClick={runGenerate} disabled={!canGenerate} title="生成">
<ArrowRight size={15} />
</button>
</div>
</div>
</div>
</section>
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
<ConfirmModal
open={!!confirmDel}
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
/>
</div>
);
}
/* ════════════════════════════════════════════════
mode === "model" / "cover" → 商品列表栏 + 表单 + 预览
(基线 model-photo / platform-cover)
════════════════════════════════════════════════ */
const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
return (
<div className="image-workbench iw-prod">
<div className="iw-layout">
{/* 最左 · 商品空间(YYX#17:可折叠 → 收起后只剩商品图) */}
<aside className={`iw-prod-space${sideCollapsed ? " collapsed" : ""}`}>
{/* YYX#17:折叠/展开左侧商品栏 —— 复用导航栏(app-shell .sidebar-toggle)同款交互:
贴右边缘的竖条按钮,hover 才显 chevron,点击切换;收起后只剩商品图。 */}
<button
className="iw-side-toggle"
type="button"
aria-pressed={sideCollapsed}
title={sideCollapsed ? "展开商品栏" : "收起商品栏"}
aria-label={sideCollapsed ? "展开商品栏" : "收起商品栏"}
onClick={() => setSideCollapsed((v) => !v)}
>
<span className="iw-side-toggle-icon iw-side-toggle-icon--collapse"><ChevronLeft size={18} strokeWidth={1.8} /></span>
<span className="iw-side-toggle-icon iw-side-toggle-icon--expand"><ChevronRight size={18} strokeWidth={1.8} /></span>
</button>
<div className="iw-side-top">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回
</button>
</div>
<div className="iw-ps-search">
<Search size={13} />
<input placeholder="搜索商品 / 分类" value={prodQuery} onChange={(event) => setProdQuery(event.target.value)} />
</div>
<div className="iw-list-h">
<span className="mono">// 商品空间</span>
{navigate && (
<button className="new-prod" type="button" title="新建商品" onClick={() => navigate("productCreateUpload")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<span>新建商品</span>
</button>
)}
</div>
<div className="iw-ps-list">
{products.length === 0 ? (
<div className="iw-ps-empty">
还没有商品
<br />
// NO PRODUCTS
</div>
) : visibleProducts.length === 0 ? (
<div className="iw-ps-empty">
没有匹配的商品
<br />
// NO MATCH
</div>
) : (
visibleProducts.slice(0, 12).map((item) => {
const cover = productCoverUrl(item);
const unread = unreadByProduct?.[item.id] || 0; // YYX#row22:该商品未读生成任务数
return (
<button
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
type="button"
key={item.id}
onClick={() => setProductId(item.id)}
>
{cover ? (
<div className="thumb has-real-media">
<img src={cover} alt={item.title} loading="lazy" />
{unread > 0 && <span className="iw-prod-unread">{unread > 99 ? "99+" : unread}</span>}
</div>
) : (
<div className="placeholder thumb">
<span className="ph-frame">{item.title.slice(0, 2)}</span>
{unread > 0 && <span className="iw-prod-unread">{unread > 99 ? "99+" : unread}</span>}
</div>
)}
<div className="body">
<div className="nm">{item.title}</div>
{/* YYX#15:商品分类去掉前缀「//」 */}
<div className="sub">{item.category || "未分类"}</div>
</div>
</button>
);
})
)}
</div>
</aside>
{/* 主区 · 头部 + 参数/结果双栏 */}
<section className="iw-main">
<div className="iw-main-h">
<div className="cur-title">
<span className="crumb">// 商品空间</span>
<span className={`nm ${product ? "" : "placeholder"}`}>
{product?.title || "未选择 · 请在左侧商品空间选一个"}
</span>
</div>
{/* YYX#10:生图模型选择已移到下方提示词输入框内(胶囊靠左置底),此处不再放 */}
{/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */}
{/* <button className="iw-pl-btn" type="button" onClick={() => { setPlDraft(productId ? [productId] : []); setPlQuery(""); setPlCat(""); setPlOpen(true); }} title="从商品库选择">
<LayoutGrid size={13} />
商品库
</button> */}
<span className="spacer" />
{/* 搜索仅平台套图用(按提示词筛结果图);模特模式改单卡「+」选择,无网格可搜,不再显示 */}
{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)} />
{/* YYX#12:输入框内 × 清空已输入内容 */}
{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>
)}
{/* YYX#12:平台套图 —— 搜索 icon 右侧加筛选按钮,点开悬浮弹窗按平台筛选结果图 */}
{mode === "cover" && (
<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>
)}
{/* row40:模特模式已改单卡「+」选择,原「时间排序 / 全部模特」下拉随网格一并移除 */}
</div>
<div className="iw-main-body">
{/* 左 · 参数表单 */}
<div className="iw-form">
{mode === "model" ? (
<div className="iw-step">
<div className="iw-step-h">
<span className="num">1</span>
<span className="title">选择模特</span>
</div>
{/* row40:模特改单选 —— 只显示一张卡。已选则展示该模特(点卡片换模特);
未选则「+」占位卡。两者点击都打开模特库(ActorLibrary,mode=replace),
onPick 回填 pickedIds=[id] 单选,提交逻辑不变。 */}
<div className="model-grid model-grid-single">
{(() => {
const selected = pickedIds[0] ? personAssets.find((a) => a.id === pickedIds[0]) : undefined;
const url = selected?.files?.[0]?.preview_url;
const name = selected?.name || pickedModelName;
if (pickedIds[0]) {
return (
<button type="button" className="model-card selected" title="点击打开模特库更换模特" onClick={() => setActorLibOpen(true)}>
<div className="m-thumb">
{url ? (
<img className="m-thumb-img" src={url} alt={name} loading="lazy" />
) : (
<div className="placeholder">
<span className="ph-frame">{(name || "模特").slice(0, 4)}</span>
</div>
)}
<div className="m-name">{name || "已选模特"}</div>
</div>
</button>
);
}
return (
<button type="button" className="model-card model-card-empty" title="从模特库选择模特" onClick={() => setActorLibOpen(true)}>
<div className="m-thumb m-thumb-empty">
<Plus size={26} />
<span className="m-empty-label">从模特库选择</span>
</div>
</button>
);
})()}
</div>
</div>
) : (
<div className="iw-step">
<div className="iw-step-h">
<span className="num">1</span>
<span className="title">选择平台</span>
</div>
<div className="platform-grid">
{/* YYX#12:平台网格恒显全部,右上搜索只筛右侧结果图,不再把平台过滤没 */}
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`platform-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id)}
>
<span className="p-check" />
<PlatformLogo p={item} />
<span className="p-name">{item.name}</span>
</button>
))}
</div>
</div>
)}
<div className="iw-step">
<div className="iw-step-h">
<span className="num">2</span>
<span className="title">生成设置</span>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 生成数量</div>
<div className="pill-row">
{countOptions.map((value) => (
<button
type="button"
key={value}
className={`opt ${count === value ? "active" : ""}`}
onClick={() => setCount(value)}
>
{value}
</button>
))}
</div>
</div>
{mode === "model" && (
<div className="iw-sub">
<div className="iw-sub-h">// 图片比例</div>
<div className="pill-row">
{ratioOptions.map((value) => (
<button
type="button"
key={value}
className={`opt ${!ratioManual && ratio === value ? "active" : ""}`}
onClick={() => { setRatioManual(false); setRatio(value); }}
>
{value}
</button>
))}
<button
type="button"
className={`opt ${ratioManual ? "active" : ""}`}
onClick={() => {
setRatioManual(true);
if (ratioW && ratioH) setRatio(`${ratioW}:${ratioH}`);
}}
>
手动输入
</button>
</div>
{ratioManual && (
<div className="iw-ratio-manual">
<input
className="input"
type="number"
min={1}
placeholder="宽"
value={ratioW}
onChange={(event) => {
const w = event.target.value;
setRatioW(w);
if (w && ratioH) setRatio(`${w}:${ratioH}`);
}}
/>
<span className="sep">:</span>
<input
className="input"
type="number"
min={1}
placeholder="高"
value={ratioH}
onChange={(event) => {
const h = event.target.value;
setRatioH(h);
if (ratioW && h) setRatio(`${ratioW}:${h}`);
}}
/>
</div>
)}
</div>
)}
<div className="iw-sub">
<div className="iw-sub-h">// 提示词 · 描述你想要的画面</div>
{/* YYX#7/R83:提示词框加大 —— 外层容器即视觉上的「输入框」,内部无边框 textarea(默认 4-5 行起步,随内容自动增高,可手动拉伸)+ 底部工具条,胶囊不再绝对定位盖在文字上;
YYX#10/R86:生图模型选择作胶囊嵌在框内工具条靠左置底(复用图片创作 chat 输入栏同款 Pill,菜单向上弹) */}
<div className="iw-prompt-box">
<textarea
className="iw-prompt-text"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="iw-prompt-bar">
<Pill
label="模型"
value={GEN_MODEL_OPTIONS.find((o) => o.value === genModel)?.label || "火山 Seedream"}
options={GEN_MODEL_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
onSelect={setGenModel}
/>
</div>
</div>
</div>
</div>
<div className="iw-cta">
{/* 行31:并发提交——有批次在跑时仍可再次提交,只在提交瞬间不重复触发 */}
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles size={13} />
立即生成 (预估 {estimateFee(estimateCount)})
</button>
{/* YYX#9:去掉生成按钮下方那行说明文字 */}
</div>
</div>
{/* 右 · 结果预览(行31/32/33:多批次列表) */}
<div className="iw-preview">
{/* YYX#G23:平台套图筛选已开启 → 置顶提示栏(部分内容隐藏),点「清空筛选」退出筛选视图 */}
{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="iw-pv-empty">
<div className="mono">// EMPTY STATE</div>
<div className="title">还没有生成结果</div>
<div className="hint">
先选商品、选{mode === "model" ? "模特" : "平台"},点击 <b>立即生成</b> 后,效果图会出现在这里
</div>
</div>
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{/* P0③:平台套图按平台分组出 section;模特图沿用每批一个导航头。按商品隔离 → productBatches */}
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
{/* YYX#16:新任务滚到底锚点 */}
<div ref={resultsEndRef} aria-hidden="true" />
</>
)}
</div>
</div>
</section>
</div>
{/* P0①:模特库全屏弹窗(复用现成 ActorLibrary,mode=replace 选一个回填到「选择模特」) */}
<ActorLibrary
open={actorLibOpen}
mode="replace"
assets={personAssets}
onClose={() => setActorLibOpen(false)}
onPick={(assetId, assetName) => {
setPickedIds([assetId]);
setPickedModelName(assetName || "");
loadModels();
setActorLibOpen(false);
}}
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
onUpload={(file) => {
const fd = new FormData();
fd.append("file", file);
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "person");
return api.uploadAsset(fd).catch(() => null);
}}
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
onRefresh={() => { loadModels(); }}
/>
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
{plOpen && (
<ProductLibraryModal
products={products}
draft={plDraft}
setDraft={setPlDraft}
query={plQuery}
setQuery={setPlQuery}
cat={plCat}
setCat={setPlCat}
coverUrl={productCoverUrl}
onClose={() => setPlOpen(false)}
onConfirm={() => {
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
if (plDraft.length) {
setProductId(plDraft[0]);
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, 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 ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
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 滚动锁(与 ActorLibrary 一致体感)
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
}, [onClose]);
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
const visible = products.filter((p) => {
if (cat && p.category !== cat) return false;
const q = query.trim().toLowerCase();
if (!q) return true;
return `${p.title} ${p.category || ""} ${p.brand || ""}`.toLowerCase().includes(q);
});
function toggle(id: string) {
setDraft(draft.includes(id) ? draft.filter((x) => x !== id) : [...draft, id]);
}
return createPortal(
<div className="pl-modal-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="pl-modal" role="dialog" aria-modal="true" aria-label="商品库">
<div className="pl-modal-h">
<h2>商品库</h2>
<span className="ct">// 选择商品 · 可多选</span>
<div className="actions">
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div className="pl-modal-body">
<aside className="pl-side">
<div className="pl-side-h">// 分类</div>
<div className={`pl-side-item${!cat ? " active" : ""}`} role="button" tabIndex={0} onClick={() => setCat("")}>
全部商品
<span className="ct">{products.length}</span>
</div>
{categories.map((c) => (
<div className={`pl-side-item${cat === c ? " active" : ""}`} key={c} role="button" tabIndex={0} onClick={() => setCat(c)}>
{c}
<span className="ct">{products.filter((p) => p.category === c).length}</span>
</div>
))}
</aside>
<div className="pl-main">
<div className="pl-toolbar">
<div className="search">
<Search size={14} />
<input placeholder="搜索商品 / 分类 / 品牌" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
{navigate && (
<button className="btn-new" type="button" onClick={() => navigate("productCreateUpload")}>
<Plus size={13} />
新建商品
</button>
)}
</div>
<div className="pl-scroll">
{visible.length === 0 ? (
<div className="pl-empty">
<div className="mono">// NO MATCH</div>
<div>没有符合条件的商品</div>
</div>
) : (
<div className="pl-grid">
{visible.map((p) => {
const url = coverUrl(p);
const on = draft.includes(p.id);
return (
<div className={`pl-card${on ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => toggle(p.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(p.id); } }}>
<span className="pl-check"><Check size={11} /></span>
{url ? (
<div className="pl-thumb has-real-media"><img src={url} alt={p.title} loading="lazy" /></div>
) : (
<div className="placeholder pl-thumb"><span className="ph-frame">{p.title.slice(0, 2)}</span></div>
)}
<div className="pl-name">{p.title}</div>
<div className="pl-meta">// {p.category || "未分类"}</div>
</div>
);
})}
</div>
)}
</div>
<div className="pl-modal-f">
<span className="summary">已选 <b>{draft.length}</b> 个商品</span>
<button className="btn" type="button" onClick={onClose}>取消</button>
<button className="btn btn-primary" type="button" disabled={draft.length === 0} onClick={onConfirm}>确定 ({draft.length})</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
/* ════════════════════════════════════════════════
模特图方案对比 Demo(纯静态展示 · 像素还原)
variant="A" → public/exact/model-photo-demo-a.html
variant="B" → public/exact/model-photo-demo-b.html
左栏 = 商品空间(搜索 + 最近 6 条 + 全部入口),由真 products 填充,空则回退基线占位。
主区为静态方案展示(A:参数面板 + 结果双栏;B:任务流 + 底部 fixed 参数栏)。
════════════════════════════════════════════════ */
/* 基线左栏占位商品(无真 products 时兜底) */
const DEMO_SIDE_PRODUCTS = [
{ id: "d1", title: "透真补水面膜", category: "美妆个护", batches: 6 },
{ id: "d2", title: "透真清透防晒霜", category: "美妆个护", batches: 3 },
{ id: "d3", title: "南卡 Lite Pro 蓝牙耳机", category: "数码 3C", batches: 2 },
{ id: "d4", title: "滋啦速食牛肉面", category: "食品饮料", batches: 1 },
{ id: "d5", title: "三顿半同款冻干咖啡", category: "食品饮料", batches: 1 },
{ id: "d6", title: "小熊 4L 可视空气炸锅", category: "家居家电", batches: 0 }
];
export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { variant: "A" | "B"; products: Product[]; onBack: () => void; navigate?: (page: Page) => void }) {
// 方案 A/B 是设计展示页(mock 数据,无生成后端);所有动作按钮导向真实工具 / 改本地选中态,杜绝死按钮
const [demoCount, setDemoCount] = useState("4 张");
const [demoRatio, setDemoRatio] = useState("3:4");
const toRealTool = () => onBack();
// 左栏商品空间:优先真 products(最近 6 条),空则回退基线占位
const sideProducts =
products.length > 0
? products.slice(0, 6).map((item, index) => ({
id: item.id,
title: item.title,
category: item.category || "未分类",
batches: DEMO_SIDE_PRODUCTS[index % DEMO_SIDE_PRODUCTS.length].batches
}))
: DEMO_SIDE_PRODUCTS;
const [activeId, setActiveId] = useState(sideProducts[0].id);
const active = sideProducts.find((item) => item.id === activeId) || sideProducts[0];
const totalCount = products.length > 0 ? products.length : 24;
// 共享左栏(两版一致)
const side = (
<aside className="dm-side">
<div className="dm-side-h">
<div className="ti-row">
<span className="ti">商品空间</span>
<button className="add" type="button" title="新建商品" onClick={() => navigate?.("productCreateUpload")}>
<Plus size={11} />
</button>
</div>
<div className="dm-search">
<Search size={13} />
<input type="text" placeholder="搜索商品 / 分类" />
</div>
</div>
<div className="dm-prod-list">
{sideProducts.map((item) => (
<button
type="button"
key={item.id}
className={`dm-prod ${item.id === active.id ? "active" : ""}`}
onClick={() => setActiveId(item.id)}
>
<div className="thumb">主图</div>
<div className="body">
<div className="nm">{item.title}</div>
<div className="sub">// {item.category} · {item.batches} 批</div>
</div>
</button>
))}
</div>
<button className="dm-all" type="button" onClick={() => navigate?.("products")}>
<LayoutGrid size={12} />
全部商品
<span className="ct">{totalCount} </span>
<ArrowRight className="arrow" size={12} />
</button>
</aside>
);
// ── 返回(基线无,挂在主区头部,保留 onBack)──
const backBtn = (
<button className="dm-back" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回模特图
</button>
);
if (variant === "A") {
return (
<div className="model-demo dm-a">
<div className="dm-banner">
// DEMO · 方案 A · <b>商品 = 项目空间</b>。左栏仅商品空间:搜索 + 最近 6 条 + <b>全部商品</b>兜底入口;
历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。
</div>
<div className="dm-grid">
{side}
<section className="dm-main">
{/* 顶部 · 商品名 + 统计 */}
<div className="dm-main-h">
<div className="cur">
<div className="crumb">// 商品空间</div>
<h2>{active.title}</h2>
</div>
<div className="stats">
<span>本商品 <b>6</b> </span>
<span className="sep">·</span>
<span>累计 <b>22</b> 张图</span>
<span className="sep">·</span>
<span>最近 <b>3 分钟前</b></span>
</div>
{backBtn}
</div>
{/* 参数面板 + 结果双栏 */}
<div className="dm-body">
{/* 左 · 参数面板 */}
<div className="dm-form">
<div className="dm-form-scroll">
<div className="dm-field">
<div className="dm-field-h">
选择模特<span className="opt">(已锁定商品 · {active.title})</span>
</div>
<div className="dm-models">
{["Ava", "Zoe", "Ben", "Lin", "Mia"].map((name, index) => (
<div className={`dm-model ${index === 0 ? "selected" : ""}`} key={name}>
<div className="ph">{name} · 3:4</div>
<div className="nm">{name}</div>
</div>
))}
<div className="dm-model add">
<Plus size={18} />
</div>
</div>
</div>
<div className="dm-field">
<div className="dm-field-h">生成张数</div>
<div className="dm-chip-row">
{["1 张", "2 张", "4 张", "8 张"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === demoCount ? "active" : ""}`} onClick={() => setDemoCount(label)}>
{label}
</button>
))}
</div>
</div>
<div className="dm-field">
<div className="dm-field-h">画面比例</div>
<div className="dm-chip-row">
{["1:1", "3:4", "9:16", "16:9"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === demoRatio ? "active" : ""}`} onClick={() => setDemoRatio(label)}>
{label}
</button>
))}
</div>
</div>
<div className="dm-field">
<div className="dm-field-h">补充提示词<span className="opt">(选填)</span></div>
<textarea className="dm-textarea" placeholder="例:户外阳光、敷面膜的特写、白底产品摄影" />
</div>
</div>
<div className="dm-form-cta">
<div className="dm-cost">
<span>预估扣费 <span className="v"> 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>
);
}