fix: YYX测试清单16条收口——资产库弹窗层级/批次多选删/商品筛选/模特卡单卡化/垃圾桶清空等

- 资产库:批次弹窗降层(under-confirm 990<999)修单图删除确认弹窗被压;编辑态批次卡多选批删;facets 新增 products 关联商品筛选(前后端,+2 测试);视频成品 tab 补搜索/排序/分页
- 平台套图工作台:「更多」气泡与 icon 左对齐+鼠标移出自动收起;模特卡改单卡(空态「+」占位,点卡进模特库单选回填)
- 工作台:删余额条,冻结/共计移至小标题行右侧(.dash-funds-mono)
- 模特库:点模特卡弹详情(形象图/标签/三视图+灯箱放大)
- 垃圾桶:新增「全部恢复」/「清空垃圾桶」(清空二次确认)
- 视频项目页:字重/阴影等轻量对齐设计规范
- api.assetFacets 返回体逐键兜底:旧后端缺 products 键会致资产库整页 undefined.find 白屏(前后端部署时序防护,浏览器已复现验证)

验证:tsc --noEmit / vite build / apps.assets 测试全过;暂存树已独立编译验证,不依赖工作区其余在途改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 18:03:26 +08:00
co-authored by Claude Fable 5
parent a048c32527
commit 5ec69ad91f
14 changed files with 496 additions and 176 deletions
+58 -107
View File
@@ -547,14 +547,6 @@ const IMAGE_SUGGESTIONS = [
"电影感都市夜景,街道湿漉漉反射霓虹,4K 高清"
];
/* 模特上身图 · 真人模特默认占位卡(基线 model-photo Ava/Luna/Mia/Zoe) */
const FALLBACK_MODELS = [
{ id: "m1", name: "Ava", tag: "亚洲·25岁·清新" },
{ id: "m2", name: "Luna", tag: "亚洲·22岁·学生" },
{ id: "m3", name: "Mia", tag: "混血·28岁·OL" },
{ id: "m4", name: "Zoe", tag: "亚洲·30岁·健身" }
];
/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg;
logo 留作图加载失败时的兜底字符,img 为真 logo) */
const PLATFORM_OPTIONS = [
@@ -705,18 +697,15 @@ export function ImageWorkbenchPage({
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 [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/无过滤=死框,输入无效);按名称/分类过滤
const [prodQuery, setProdQuery] = useState("");
const visibleProducts = products.filter((item) => {
@@ -1336,7 +1325,10 @@ export function ImageWorkbenchPage({
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
<div className="gen-img-more">
{/* 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>
@@ -1783,22 +1775,25 @@ export function ImageWorkbenchPage({
商品库
</button> */}
<span className="spacer" />
<div className="tb-search-wrap">
{searchOpen && (
<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>
{/* 搜索仅平台套图用(按提示词筛结果图);模特模式改单卡「+」选择,无网格可搜,不再显示 */}
{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">
@@ -1829,26 +1824,7 @@ export function ImageWorkbenchPage({
</div>
</div>
)}
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
<button className={`tb-chip${gridSort === "name" ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "time" ? "" : "time"))}><span className="lbl">{gridSort === "name" ? "按名称" : "时间"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${gridSort === "recent" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("recent"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最近添加</div>
<div className={`mi${gridSort === "name" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("name"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>按名称</div>
</div>
</div>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "model" ? " open" : ""}`} data-filter="model">
<button className={`tb-chip${gridQuery ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "model" ? "" : "model"))}><span className="lbl">{gridQuery || "模特"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${!gridQuery ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridQuery(""); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部模特</div>
{(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => (
<div className={`mi${gridQuery === nm ? " selected" : ""}`} key={nm} role="button" tabIndex={0} onClick={() => { setGridQuery(nm); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{nm}</div>
))}
</div>
</div>
</>
)}
{/* row40:模特模式已改单卡「+」选择,原「时间排序 / 全部模特」下拉随网格一并移除 */}
</div>
<div className="iw-main-body">
@@ -1859,65 +1835,40 @@ export function ImageWorkbenchPage({
<div className="iw-step-h">
<span className="num">1</span>
<span className="title">选择模特</span>
{/* P0①:接入现成模特库全屏弹窗(原「全部模特→」仅内联展开;现可打开完整演员库选用) */}
<button className="iw-actor-lib-btn" type="button" onClick={() => setActorLibOpen(true)}>
<Users size={12} />
模特库
</button>
{personAssets.length > 6 && (
<span className="right" role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setShowAllModels((v) => !v)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setShowAllModels((v) => !v); } }}>{showAllModels ? "收起 ↑" : `全部 (${personAssets.length})`}</span>
)}
</div>
<div className="model-grid">
{personAssets.length > 0
? personAssets
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || "")))
.slice(0, showAllModels ? undefined : 6)
.map((item) => {
const url = item.files?.[0]?.preview_url;
return (
<button
type="button"
key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id, item.name)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="m-thumb">
{url ? (
<img className="m-thumb-img" src={url} alt={item.name} loading="lazy" />
) : (
<div className="placeholder">
<span className="ph-frame">{item.name.slice(0, 4)}</span>
</div>
)}
<div className="m-name">{item.name}</div>
</div>
</button>
);
})
: FALLBACK_MODELS
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0))
.map((item) => (
<button
type="button"
key={item.id}
className={`model-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
onClick={() => togglePick(item.id, item.name)}
>
<span className="m-check">
<Check size={11} />
</span>
<div className="placeholder m-thumb">
<span className="ph-frame">{item.name}</span>
<div className="m-name">{item.name}</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>
) : (
@@ -1950,7 +1901,7 @@ export function ImageWorkbenchPage({
<span className="title">生成设置</span>
</div>
<div className="iw-sub">
<div className="iw-sub-h">// 生成数量{mode === "model" ? " (每模特)" : ""}</div>
<div className="iw-sub-h">// 生成数量</div>
<div className="pill-row">
{countOptions.map((value) => (
<button