fix: 平台套图/新建商品 YYX 第二轮(清单4 未完成项)

平台套图:
- 商品列表无真封面时回退 mock 图(与商品库一致显图)
- 商品分类去掉前缀「//」
- 左侧商品栏可折叠(收起只剩商品图)
- 结果搜索框加 × 清空 + 平台筛选悬浮弹窗(可清空)
- 结果卡精简:去容器/引号/平台/小标题,提示词可折叠展开、张数灰色
- 删悬浮「更多」里重复的加入资产库入口
- 去掉加入资产库后的中央遮罩,只保留左上角已入库标签
- 重跑/单图再生成的新批次置底并自动滚动到底

新建商品抽屉:
- 上传失败:黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊
- 填了内容中途退出:二次确认(复用 ConfirmModal)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-27 16:51:49 +08:00
co-authored by Claude Opus 4.8
parent 217d24f316
commit 98b6ba948e
5 changed files with 265 additions and 74 deletions
+119 -69
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent } from "react";
import {
@@ -13,11 +13,14 @@ import {
LayoutGrid,
List,
MoreHorizontal,
PanelLeftClose,
PanelLeftOpen,
Pencil,
Plus,
Quote,
RefreshCw,
Search,
SlidersHorizontal,
Sparkles,
Trash2,
Users,
@@ -32,6 +35,7 @@ import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import { productMockCoverUrl } from "./products";
import "../ai-tools-page.css";
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
@@ -640,8 +644,6 @@ export function ImageWorkbenchPage({
const [plDraft, setPlDraft] = useState<string[]>([]);
const [plQuery, setPlQuery] = useState("");
const [plCat, setPlCat] = useState("");
// gen-card 就地反馈(§4.18:采用反馈不走全局弹窗,改卡内 show-feedback 1.5s)
const [feedbackKey, setFeedbackKey] = useState("");
// 单图「更多」气泡当前展开的 key(点击切换,点空收起)
const [openMore, setOpenMore] = useState("");
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
@@ -662,6 +664,8 @@ export function ImageWorkbenchPage({
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 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);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
@@ -669,6 +673,11 @@ export function ImageWorkbenchPage({
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
const [tbOpen, setTbOpen] = useState<"" | "time" | "model">("");
const [searchOpen, setSearchOpen] = useState(false);
// YYX#17:左侧商品栏折叠(收起后只剩商品图)
const [sideCollapsed, setSideCollapsed] = useState(false);
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
const [filterOpen, setFilterOpen] = useState(false);
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
// 模特网格默认只露 6 个;「全部模特 →」展开后露全部,再点收起(超过 6 个时第 7 个之后本来选不到)
const [showAllModels, setShowAllModels] = useState(false);
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
@@ -682,7 +691,8 @@ export function ImageWorkbenchPage({
const productCoverUrl = (p: Product): string => {
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
return p.cover_preview_url || primary?.preview_url || "";
// 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 || []);
@@ -740,11 +750,6 @@ export function ImageWorkbenchPage({
setPickedIds((prev) => (prev[0] === id ? [] : [id]));
if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || ""));
}
// 单图就地反馈(§4.18):显示 1.5s 后自动撤掉
function flashFeedback(key: string) {
setFeedbackKey(key);
window.setTimeout(() => setFeedbackKey((k) => (k === key ? "" : k)), 1500);
}
// 点击空白收起单图「更多」气泡
useEffect(() => {
if (!openMore) return;
@@ -752,6 +757,13 @@ export function ImageWorkbenchPage({
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);
@@ -939,6 +951,8 @@ export function ImageWorkbenchPage({
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
};
setBatches((prev) => [...prev, newBatch]);
// YYX#16:新批次(含重跑/单图再生成)一律追加到末尾,提交即把面板滚到底部新任务处
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
try {
// 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。
// 上传任一失败不阻断:跳过该张,有几张算几张。
@@ -1017,8 +1031,11 @@ export function ImageWorkbenchPage({
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
refs: mode === "image" && refImages.length ? refImages : undefined
});
// 参考图已交给本批,清空输入栏待下次(批次头会保留这次用过的参考图)
if (mode === "image") setRefImages([]);
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
if (mode === "image") { setRefImages([]); setPrompt(""); }
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
}
/* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */
@@ -1088,17 +1105,15 @@ export function ImageWorkbenchPage({
const withId = batch.results.filter((a) => a.id);
if (!withId.length) return;
const next = !withId.every((a) => a.in_library);
if (next) flashFeedback(`${batchId}:${withId[0].id}`);
applyLibraryState(batchId, withId.map((a) => a.id), next);
}
/* 加入资产库 / 取消(单图级:只改被点的那一张,§4.18 就地反馈不弹全局窗) */
/* 加入资产库 / 取消(单图级:只改被点的那一张) */
function toggleAdoptImage(batchId: string, assetId: string) {
if (!assetId) return;
const asset = batches.find((b) => b.id === batchId)?.results.find((a) => a.id === assetId);
if (!asset) return;
const next = !asset.in_library;
if (next) flashFeedback(`${batchId}:${assetId}`);
applyLibraryState(batchId, [assetId], next);
}
@@ -1169,8 +1184,12 @@ export function ImageWorkbenchPage({
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]);
}, [batches, productId, mode, gridQuery, platformFilter]);
const hasResults = productBatches.length > 0;
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
@@ -1196,9 +1215,8 @@ export function ImageWorkbenchPage({
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`;
const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId;
return (
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}${showFeedback ? " show-feedback" : ""}`} key={key}>
<div className={`gen-image ${generating && !url ? "gen" : ""}${failed ? " failed" : ""}${inLib && url ? " adopted" : ""}`} 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}` })} />
) : (
@@ -1229,13 +1247,7 @@ export function ImageWorkbenchPage({
已入库
</span>
)}
{/* ② 中央就地反馈(采用瞬间亮 1.5s,§4.18 禁全局 toast) */}
{url && (
<div className="gen-image-feedback" aria-hidden={!showFeedback}>
<Check size={20} />
<span>已加入资产库</span>
</div>
)}
{/* YYX#20:去掉「加入资产库」后的中央遮罩反馈,只保留左上角「已入库」标签 */}
{/* ① 右上悬浮操作组:再生成 / 下载 / 更多 */}
{url && (
<div className="gen-image-actions">
@@ -1249,11 +1261,8 @@ export function ImageWorkbenchPage({
<button className="gen-img-btn" type="button" title="更多" onClick={() => setOpenMore((k) => (k === cellKey ? "" : cellKey))}>
<MoreHorizontal size={14} />
</button>
{/* YYX#19:删掉「更多」气泡里的加入资产库(与批次操作行重复),只保留删除 */}
<div className={`gen-bubble${openMore === cellKey ? " open" : ""}`}>
<button type="button" className="gen-bubble-item" onClick={() => { setOpenMore(""); toggleAdoptImage(batch.id, assetId); }}>
<Bookmark size={13} />
{inLib ? "取消加入资产库" : "加入资产库"}
</button>
<button type="button" className="gen-bubble-item danger" onClick={() => { setOpenMore(""); removeImageFromBatch(batch.id, assetId); }}>
<Trash2 size={13} />
删除
@@ -1286,43 +1295,13 @@ export function ImageWorkbenchPage({
const failed = batch.status === "failed";
return (
<Fragment key={batch.id}>
{/* 行34:每批一个合并导航头——商品/模特(平台)+ 结果(数量·比例·状态)左对齐一块;
原右上「1 批·比例」与「N×」徽标系重复信息,已剔除 */}
<div className="iw-pv-h">
<Quote className="quote-icon" />
{/* YYX#13:参考即梦 —— 显示用户输入的提示词 / 选择的平台 / 生成的张数 */}
<div className="pv-line">
<span className="k">提示词</span>
<span className="v pv-prompt">{batch.prompt || "—"}</span>
</div>
{mode === "cover" && (
<div className="pv-line">
<span className="k">平台</span>
<span className="v">
{batch.platformIds && batch.platformIds.length
? PLATFORM_OPTIONS.filter((p) => batch.platformIds!.includes(p.id))
.map((p) => p.name)
.join("、")
: "未选择"}
</span>
</div>
)}
{mode === "model" && batch.modelName && (
<div className="pv-line">
<span className="k">模特</span>
<span className="v">{batch.modelName}</span>
</div>
)}
<div className="pv-line">
<span className="k">张数</span>
<span className="v">
{batch.count} 张 · {batch.ratio} · {generating ? "生成中" : failed ? "失败" : "已完成"}
{!generating && !failed && batch.results.some((a) => a.in_library)
? ` · ${batch.results.filter((a) => a.in_library).length} 张已入库`
: ""}
</span>
</div>
</div>
{/* YYX#18:结果卡头部精简 —— 去容器/引号/平台/小标题,只留可折叠提示词 + 灰色张数文案 */}
<BatchHeader
prompt={batch.prompt}
count={batch.count}
statusText={generating ? "生成中" : failed ? "失败" : "已完成"}
adoptedCount={!generating && !failed ? batch.results.filter((a) => a.in_library).length : 0}
/>
<div className="gen-card gen-batch-card">
{renderBatchGrid(batch)}
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
@@ -1538,6 +1517,8 @@ export function ImageWorkbenchPage({
</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">
@@ -1623,13 +1604,17 @@ export function ImageWorkbenchPage({
return (
<div className="image-workbench iw-prod">
<div className="iw-layout">
{/* 最左 · 商品空间 */}
<aside className="iw-prod-space">
{/* 最左 · 商品空间(YYX#17:可折叠 → 收起后只剩商品图) */}
<aside className={`iw-prod-space${sideCollapsed ? " collapsed" : ""}`}>
<div className="iw-side-top">
<button className="back-pill" type="button" onClick={onBack}>
<ArrowLeft size={14} />
返回
</button>
{/* YYX#17:折叠/展开左侧商品栏 */}
<button className="iw-side-collapse" type="button" title={sideCollapsed ? "展开商品栏" : "收起商品栏"} aria-label={sideCollapsed ? "展开商品栏" : "收起商品栏"} onClick={() => setSideCollapsed((v) => !v)}>
{sideCollapsed ? <PanelLeftOpen size={15} /> : <PanelLeftClose size={15} />}
</button>
</div>
<div className="iw-ps-search">
<Search size={13} />
@@ -1676,7 +1661,8 @@ export function ImageWorkbenchPage({
)}
<div className="body">
<div className="nm">{item.title}</div>
<div className="sub">// {item.category || "未分类"}</div>
{/* YYX#15:商品分类去掉前缀「//」 */}
<div className="sub">{item.category || "未分类"}</div>
</div>
</button>
);
@@ -1703,12 +1689,50 @@ export function ImageWorkbenchPage({
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索图片提示词"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
<div className="iw-search-box">
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索图片提示词"} 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={mode === "model" ? "搜索批次/模特" : "搜索"} 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]))}
>
<span className={`p-logo p-logo-${p.id}`}>{p.logo}</span>
<span className="nm">{p.name}</span>
<Check className="ck" size={12} />
</button>
))}
</div>
</div>
</div>
)}
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
@@ -1955,6 +1979,8 @@ export function ImageWorkbenchPage({
<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>
@@ -2023,6 +2049,30 @@ export function ImageWorkbenchPage({
);
}
/* YYX#18:结果卡精简头部 —— 提示词单行折叠(超出给「展开/收起」)+ 灰色张数文案 */
function BatchHeader({ prompt, count, statusText, adoptedCount }: { prompt: string; count: number; statusText: string; adoptedCount: number }) {
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}{adoptedCount > 0 ? ` · ${adoptedCount} 张已入库` : ""}
</div>
</div>
);
}
/* 底部 chat 输入栏 · 参数胶囊(基线 image-optimize .param + 下拉气泡) */
function Pill({
label,