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
+77
View File
@@ -632,6 +632,83 @@
/* 胶囊在框底 → 下拉菜单向上弹,避免被框/CTA 遮住 */
.image-workbench .iw-model-pill .chip-menu { top: auto; bottom: calc(100% + 4px); }
/* ── YYX#17:左侧商品栏折叠 ── */
.image-workbench .iw-side-top .iw-side-collapse {
margin-left: auto;
width: 28px; height: 28px;
display: grid; place-items: center;
border: 1px solid var(--border-faint); border-radius: var(--r-md);
background: var(--surface); color: var(--black-alpha-48); cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.image-workbench .iw-side-top .iw-side-collapse:hover { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
/* 收起后整列变窄,只剩商品图 */
.image-workbench .iw-layout:has(.iw-prod-space.collapsed) { grid-template-columns: 76px minmax(0, 1fr); }
.image-workbench .iw-prod-space.collapsed .back-pill,
.image-workbench .iw-prod-space.collapsed .iw-ps-search,
.image-workbench .iw-prod-space.collapsed .iw-list-h,
.image-workbench .iw-prod-space.collapsed .iw-prod-item .body { display: none; }
.image-workbench .iw-prod-space.collapsed .iw-side-top { justify-content: center; padding: 12px 0 10px; }
.image-workbench .iw-prod-space.collapsed .iw-side-top .iw-side-collapse { margin-left: 0; }
.image-workbench .iw-prod-space.collapsed .iw-ps-list { padding-left: 0; padding-right: 0; align-items: center; }
.image-workbench .iw-prod-space.collapsed .iw-prod-item { justify-content: center; padding: 4px; }
/* ── YYX#12:结果搜索框 × 清空 + 平台筛选弹窗 ── */
.image-workbench .iw-search-box { position: relative; display: inline-flex; align-items: center; margin-right: 6px; }
.image-workbench .iw-search-box .input { height: 30px; width: 150px; padding-right: 26px; }
.image-workbench .iw-search-clear {
position: absolute; right: 6px;
width: 16px; height: 16px; display: grid; place-items: center;
border: 0; border-radius: 50%; background: var(--black-alpha-12); color: var(--accent-black);
cursor: pointer;
}
.image-workbench .iw-search-clear:hover { background: var(--black-alpha-24); }
.image-workbench .iw-filter-wrap { position: relative; }
.image-workbench .iw-filter-wrap .search-btn { position: relative; }
.image-workbench .iw-filter-count {
position: absolute; top: -4px; right: -4px;
min-width: 14px; height: 14px; padding: 0 3px;
display: grid; place-items: center;
background: var(--heat); color: var(--accent-white);
font-family: var(--font-mono); font-size: 10px; line-height: 1; border-radius: 999px;
}
.image-workbench .iw-filter-menu { width: 244px; padding: 10px; }
.image-workbench .iw-filter-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.image-workbench .iw-filter-h .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
.image-workbench .iw-filter-clear {
border: 0; background: none; cursor: pointer;
font-size: 12px; color: var(--heat);
}
.image-workbench .iw-filter-clear:disabled { color: var(--black-alpha-24); cursor: default; }
.image-workbench .iw-filter-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.image-workbench .iw-filter-opt {
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-md);
background: var(--background-lighter); cursor: pointer; font-family: inherit;
}
.image-workbench .iw-filter-opt:hover { background: var(--surface); }
.image-workbench .iw-filter-opt.on { border-color: var(--heat); background: var(--heat-12); }
.image-workbench .iw-filter-opt .p-logo { width: 20px; height: 20px; border-radius: 5px; font-size: 11px; }
.image-workbench .iw-filter-opt .nm { font-size: 12px; color: var(--accent-black); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.image-workbench .iw-filter-opt .ck { color: var(--heat); opacity: 0; flex-shrink: 0; }
.image-workbench .iw-filter-opt.on .ck { opacity: 1; }
/* ── YYX#18:结果卡精简头部(提示词折叠 + 灰色张数,无容器/引号/标题)── */
.image-workbench .iw-batch-head { padding: 2px 2px 0; margin-bottom: 8px; }
.image-workbench .iw-batch-head .ibh-prompt {
font-size: 13px; color: var(--accent-black); line-height: 1.6;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.image-workbench .iw-batch-head .ibh-prompt.expanded { white-space: normal; }
.image-workbench .iw-batch-head .ibh-toggle {
border: 0; background: none; padding: 2px 0; cursor: pointer;
font-size: 12px; color: var(--heat);
}
.image-workbench .iw-batch-head .ibh-count {
margin-top: 2px;
font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48);
}
/* 左栏底部 · 立即生成(主 CTA · 通栏) */
.image-workbench .iw-cta { margin-top: auto; padding-top: 14px; }
.image-workbench .iw-cta .btn {
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, DragEvent } from "react";
import { createPortal } from "react-dom";
import { api } from "../api";
import { useBodyScrollLock } from "./overlays";
import { useBodyScrollLock, ConfirmModal } from "./overlays";
import type { Product } from "../types";
import "../product-create-page.css";
@@ -51,6 +51,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
const [saving, setSaving] = useState(false);
// 抽屉内就地 toast(组件无全局 Shell.toast,这里自给一条,镜像 V1 反馈文案)
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
// YYX#24:填了内容中途退出 → 二次确认
const [confirmExit, setConfirmExit] = useState(false);
const imgInputRef = useRef<HTMLInputElement | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
@@ -80,6 +82,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
useEffect(() => { if (open) resetForm(); }, [open]);
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
function isDirty() {
return Boolean(title.trim() || target.trim() || bulletDraft.trim() || bullets.length || images.length);
}
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
function guardedClose() {
if (isDirty()) setConfirmExit(true);
else close();
}
// upload-first:任一图处于「uploading」且未在途 → 立刻传进桶(/assets/upload/),拿到 assetId 标 done
async function uploadOne(entry: PfImage) {
try {
@@ -119,6 +131,13 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
return list.filter((item) => item.id !== id);
});
}
// YYX#22:上传失败后「重新上传」—— 把该图置回 uploading 再发一次
function retryUpload(id: string) {
const hit = imagesRef.current.find((item) => item.id === id);
if (!hit) return;
setImages((list) => list.map((it) => (it.id === id ? { ...it, status: "uploading" } : it)));
void uploadOne({ ...hit, status: "uploading" });
}
function openPicker() {
if (images.length >= PF_MAX) { flash("已达上限", `${PF_MAX} / ${PF_MAX}`); return; }
imgInputRef.current?.click();
@@ -176,11 +195,11 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
return createPortal(
<>
<div className={`drawer-bg${open ? " show" : ""}`} onClick={close} />
<div className={`drawer-bg${open ? " show" : ""}`} onClick={guardedClose} />
<aside className={`drawer pc-drawer${open ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!open}>
<div className="drawer-h">
<h3></h3>
<button className="x" type="button" onClick={close} aria-label="关闭">
<button className="x" type="button" onClick={guardedClose} aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
@@ -242,7 +261,19 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<img src={item.url} alt="商品主图预览" />
{idx === 0 && <span className="pf-cover"></span>}
{item.status === "uploading" && <span className="pf-state"><span className="spinner" aria-hidden="true" /></span>}
{item.status === "error" && <span className="pf-state pf-state-err"></span>}
{/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */}
{item.status === "error" && (
<div className="pf-fail-mask">
<span className="pf-fail-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>
</span>
<span className="pf-fail-t"></span>
<button className="pf-retry" type="button" onClick={() => retryUpload(item.id)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" /></svg>
</button>
</div>
)}
<button className="pf-x" type="button" title="删除" aria-label="删除该图" onClick={() => removeImage(item.id)}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
@@ -285,7 +316,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
使
</button>
<button className="btn" type="button" onClick={close}></button>
<button className="btn" type="button" onClick={guardedClose}></button>
<button className="btn btn-primary" type="button" disabled={saving || images.some((im) => im.status === "uploading")} onClick={submit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
{saving ? "创建中…" : images.some((im) => im.status === "uploading") ? "图片上传中…" : "创建商品"}
@@ -299,6 +330,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
</div>
)}
</aside>
{/* YYX#24:填了内容中途退出 → 二次确认(复用通用 ConfirmModal) */}
<ConfirmModal
open={confirmExit}
title="商品未保存"
subtitle="// UNSAVED"
detail="当前商品信息尚未保存,确定要退出编辑吗?"
confirmText="退出编辑"
onCancel={() => setConfirmExit(false)}
onConfirm={() => { setConfirmExit(false); close(); }}
/>
</>,
document.body,
);
+17
View File
@@ -469,6 +469,23 @@
}
.pc-drawer .form-card .pf-thumb .pf-state-err { background: var(--crimson-bg); color: var(--accent-crimson); font-weight: 600; }
.pc-drawer .form-card .pf-thumb.pf-error { border-color: var(--crimson-bd); }
/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */
.pc-drawer .form-card .pf-thumb .pf-fail-mask {
position: absolute; inset: 0; z-index: 2;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
background: rgba(0, 0, 0, .62); color: var(--accent-white); text-align: center; padding: 4px;
}
.pc-drawer .form-card .pf-thumb .pf-fail-ic svg { width: 20px; height: 20px; }
.pc-drawer .form-card .pf-thumb .pf-fail-t { font-size: 11px; font-weight: 600; }
.pc-drawer .form-card .pf-thumb .pf-retry {
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 8px;
border: 0; border-radius: 999px;
background: var(--accent-white); color: var(--accent-black);
font-size: 11px; cursor: pointer;
}
.pc-drawer .form-card .pf-thumb .pf-retry:hover { background: var(--heat); color: var(--accent-white); }
.pc-drawer .form-card .pf-thumb .pf-retry svg { width: 11px; height: 11px; }
.pc-drawer .form-card .pf-thumb .pf-cover {
position: absolute; left: 4px; bottom: 4px; z-index: 1;
background: var(--heat); color: var(--accent-white);
+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,
+6
View File
@@ -28,6 +28,12 @@ type ProductPayload = {
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
};
// 复刻 mock-media productFor:按商品名关键词映射商品图 → 完整 mock 图 URL(无匹配返回 "")
// 导出供平台套图工作台复用(YYX#14:左侧商品列表无真封面时也回退到 mock,与商品库一致显图)
export function productMockCoverUrl(name: string): string {
const file = productCover(name);
return file ? `/exact/assets/mock/${file}` : "";
}
// 复刻 mock-media productFor:按商品名关键词映射商品图
function productCover(name: string): string {
const t = name.replace(/\s+/g, "");