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
+75 -4
View File
@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { User, X } from "lucide-react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
@@ -13,6 +16,62 @@ const TABS: { k: Tab; label: string }[] = [
{ k: "mine", label: "我的模特" }
];
type Preview = { src: string; kind: "image"; name: string };
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null; close: () => void; onZoom: (p: Preview) => void }) {
const open = !!model;
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted || !model) return null;
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><User size={16} /></div>
<div className="ti">
{model.name}
<span>{model.is_official ? "官方模板" : model.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b model-detail-b">
<div
className={`placeholder model-detail-portrait${model.portrait ? " has-mock-media" : ""}`}
style={model.portrait ? mediaStyle(model.portrait) : undefined}
role={model.portrait ? "button" : undefined}
tabIndex={model.portrait ? 0 : undefined}
onClick={model.portrait ? () => onZoom({ src: model.portrait, kind: "image", name: model.name }) : undefined}
onKeyDown={model.portrait ? zoomKey({ src: model.portrait, kind: "image", name: model.name }) : undefined}
>
{!model.portrait && <span className="ph-frame">无图</span>}
</div>
<div className="model-detail-tags mono">
{model.is_official && <span className="pill info">官方模板</span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
{model.description && <p className="model-detail-desc">{model.description}</p>}
<div className="model-detail-label mono">// 三视图</div>
<div
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
style={model.triview ? mediaStyle(model.triview) : undefined}
role={model.triview ? "button" : undefined}
tabIndex={model.triview ? 0 : undefined}
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
>
{!model.triview && <span className="ph-frame mono">// 无三视图</span>}
</div>
</div>
</div>
</div>,
document.body,
);
}
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage() {
@@ -21,6 +80,8 @@ export function ModelsPage() {
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [detail, setDetail] = useState<ModelEntity | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
@@ -96,7 +157,14 @@ export function ModelsPage() {
) : (
<div className="models-grid">
{items.map((m) => (
<div className="model-card" key={m.id}>
<div
className="model-card"
key={m.id}
role="button"
tabIndex={0}
onClick={() => setDetail(m)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
@@ -105,12 +173,12 @@ export function ModelsPage() {
{m.is_official && <span className="pill info model-official">官方模板</span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm">
<div className="model-confirm" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}>删除</button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}>取消</button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={() => setConfirmId(m.id)}>×</button>
<button className="model-del-btn" type="button" title="删除模特" onClick={(e) => { e.stopPropagation(); setConfirmId(m.id); }}>×</button>
))}
</div>
<div className="model-body">
@@ -131,6 +199,9 @@ export function ModelsPage() {
))}
</div>
)}
<ModelDetailModal model={detail} close={() => setDetail(null)} onZoom={setPreview} />
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
}