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:
@@ -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
|
||||
|
||||
@@ -72,17 +72,22 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
|
||||
const allProjectsLoaded = projects.length >= projCount;
|
||||
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
|
||||
|
||||
// 余额 mini progress:真实冻结占比 = 冻结 /(可用 + 冻结);无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」,改显真实「冻结 ¥X / 共 ¥Y」。
|
||||
// 冻结信息:真实「冻结 ¥X / 共 ¥Y」,无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」。
|
||||
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
|
||||
const balanceNum = Number(billing?.account.balance ?? 0);
|
||||
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
|
||||
const fundsTotal = balanceNum + reservedNum;
|
||||
const reservedPct = fundsTotal > 0 ? Math.min(100, Math.round((reservedNum / fundsTotal) * 100)) : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h1>欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}</h1>
|
||||
<div className="sub"><span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div>
|
||||
<div className="sub">
|
||||
<span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span>
|
||||
<span className="dash-funds-mono" style={{ marginLeft: "auto" }} onClick={() => navigate("account")}>
|
||||
余额 {money(balanceNum)} · 冻结 {money(reservedNum)} / 共 {money(fundsTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" onClick={() => (onCreateProduct ? setCreateDrawer(true) : navigate("products"))}>
|
||||
@@ -95,7 +100,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub">冻结 {money(reservedNum)} / 共 {money(fundsTotal)}</div></button></div>
|
||||
<div className="stats with-corners" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /></div>
|
||||
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] →</button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count} 镜` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="商品库" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="资产库" desc={`${assetCount ?? "…"} 资产`} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="所有项目" desc={`${projCount} 个`} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2>提示</h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong>扣费规则</strong>生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span className="mono">[ 确认通过 ]</span> 时按 token 实际结算。</div></div></div></div>
|
||||
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
|
||||
{onCreateProduct && (
|
||||
|
||||
@@ -415,10 +415,14 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
const [openChip, setOpenChip] = useState("");
|
||||
const [srcFilter, setSrcFilter] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("");
|
||||
// 关联商品筛选(仅图片成品 tab):存商品 id,选中后把 product=<id> 传进列表/批次请求;选项走 facets.products
|
||||
const [productFilter, setProductFilter] = useState("");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
// 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds)
|
||||
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
|
||||
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
|
||||
// 删除确认:单删传 [id],批删传选中数组
|
||||
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
|
||||
@@ -436,7 +440,14 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
||||
// 批次多选:整卡点选 = 选中/取消该批次(与「管理项目」一致)
|
||||
const toggleBatch = (batchId: string) => setSelectedBatches((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(batchId)) next.delete(batchId); else next.add(batchId);
|
||||
return next;
|
||||
});
|
||||
const clearSelection = () => { setSelected(new Set()); setSelectedBatches(new Set()); };
|
||||
const exitEdit = () => { setEditMode(false); clearSelection(); };
|
||||
|
||||
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
|
||||
const [items, setItems] = useState<Asset[]>([]);
|
||||
@@ -448,7 +459,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
|
||||
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
|
||||
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
|
||||
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
|
||||
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]>; products: { id: string; title: string }[] }>({ sources: [], kinds: [], metadata: {}, products: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -457,12 +468,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
useEffect(() => { const id = setTimeout(() => setDebouncedQuery(query), 400); return () => clearTimeout(id); }, [query]);
|
||||
|
||||
// 切 tab / 改筛选 / 改排序回到第 1 页
|
||||
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
|
||||
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删)
|
||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); setSelected(new Set()); }, [tab]);
|
||||
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc]);
|
||||
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删/误筛)
|
||||
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setProductFilter(""); setMetaFilter({}); setSelected(new Set()); setSelectedBatches(new Set()); }, [tab]);
|
||||
|
||||
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
|
||||
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
|
||||
// 当前 tab 的 metadata 筛选键(来源/类型/关联商品走独立字段与 facets,不算 metadata)
|
||||
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind" && c.key !== "product").map((c) => c.key);
|
||||
|
||||
// 图片成品三类按批次成组展示;其余 tab 平铺
|
||||
const isBatchTab = BATCH_TABS.includes(tab);
|
||||
@@ -477,6 +488,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
tab,
|
||||
q: debouncedQuery || undefined,
|
||||
source: srcFilter || undefined,
|
||||
product: productFilter || undefined, // 后端 assetsPage/batches 两条通道都按 ?product= 三路过滤
|
||||
meta: metaFilter,
|
||||
ordering: sortDesc ? "-created_at" : "created_at",
|
||||
page,
|
||||
@@ -495,7 +507,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
}
|
||||
return () => { alive = false; };
|
||||
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
|
||||
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc, page, reloadFlag]);
|
||||
|
||||
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
|
||||
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
|
||||
@@ -515,12 +527,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
const srcOptions = facets.sources;
|
||||
const kindOptions = facets.kinds;
|
||||
const metaOptions = (key: string) => facets.metadata[key] || [];
|
||||
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
|
||||
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || productFilter || Object.values(metaFilter).some(Boolean));
|
||||
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
|
||||
const curPage = Math.min(page, totalPages);
|
||||
// 乐观隐藏后真正渲染的列表
|
||||
const shown = items.filter((a) => !deletingIds.has(a.id));
|
||||
|
||||
// 视频成品:前端全量数组按搜索(项目名)过滤 + 排序(按片段数)+ 分页切片(10/页,共享 Pager)
|
||||
const filteredPacks = useMemo(() => {
|
||||
const q = debouncedQuery.trim().toLowerCase();
|
||||
const arr = q ? packs.filter((p) => (p.project_name || "").toLowerCase().includes(q)) : packs.slice();
|
||||
return arr.sort((a, b) => (sortDesc ? b.clips.length - a.clips.length : a.clips.length - b.clips.length));
|
||||
}, [packs, debouncedQuery, sortDesc]);
|
||||
const packTotalPages = Math.max(1, Math.ceil(filteredPacks.length / LIB_PAGE_SIZE));
|
||||
const packCurPage = Math.min(page, packTotalPages);
|
||||
const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE);
|
||||
|
||||
// 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id)
|
||||
const selCount = isBatchTab ? selectedBatches.size : selected.size;
|
||||
const bulkDeleteIds = () => (isBatchTab
|
||||
? batches.filter((b) => selectedBatches.has(b.batch_id)).flatMap((b) => b.items.map((a) => a.id))
|
||||
: Array.from(selected));
|
||||
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
@@ -548,6 +576,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
if (!ids.length || !onDelete) return;
|
||||
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
|
||||
setSelected(new Set());
|
||||
setSelectedBatches(new Set());
|
||||
// R108:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
|
||||
setOpenBatch((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -587,11 +616,35 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
</div>
|
||||
|
||||
{tab === "videopacks" ? (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="search-inline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<input className="input" placeholder="搜索项目名" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
<span className="spacer"></span>
|
||||
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
|
||||
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
|
||||
<span className="chip-label">{sortDesc ? "片段最多" : "片段最少"}</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 align-right">
|
||||
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); 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>
|
||||
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="result-meta">// 显示 <span className="count">{shownPacks.length}</span> / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}</div>
|
||||
|
||||
<div className="packs-grid">
|
||||
{packs.length === 0 ? (
|
||||
<div className="empty-filter">// 还没有视频成品 · 去视频项目生成片段</div>
|
||||
{filteredPacks.length === 0 ? (
|
||||
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
|
||||
) : (
|
||||
packs.map((pack) => {
|
||||
shownPacks.map((pack) => {
|
||||
const first = pack.clips[0];
|
||||
// 视频成品的所有片段 id:批量删除整个素材包
|
||||
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
|
||||
@@ -619,6 +672,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
@@ -668,6 +723,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (chip.key === "product") {
|
||||
// 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product=<id> 进列表/批次请求
|
||||
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
|
||||
return (
|
||||
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
|
||||
<button className={`chip${productFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span className="chip-label">{productFilter ? (curTitle || "关联商品") : "关联商品"}</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${!productFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setProductFilter(""); 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>
|
||||
{facets.products.length > 0 && <div className="mi-sep" />}
|
||||
{facets.products.map((p) => (
|
||||
<div className={`mi${productFilter === p.id ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => { setProductFilter(p.id); 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>{p.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
|
||||
const opts = metaOptions(chip.key);
|
||||
const cur = metaFilter[chip.key] || "";
|
||||
@@ -711,20 +788,21 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
{isBatchTab ? (
|
||||
batches.length ? (
|
||||
<div className="packs-grid" id="batch-grid">
|
||||
{batches.map((batch) => (
|
||||
<article className="pack-card" key={batch.batch_id} onClick={() => !editMode && setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenBatch(batch); } }}>
|
||||
{editMode && onDelete && (
|
||||
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
<span className="pack-count mono">{batch.count} 张</span>
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
|
||||
</article>
|
||||
))}
|
||||
{batches.map((batch) => {
|
||||
const isSel = selectedBatches.has(batch.batch_id);
|
||||
// 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗
|
||||
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
|
||||
return (
|
||||
<article className={`pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
|
||||
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
|
||||
<div className="placeholder asset-thumb pack-thumb">
|
||||
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
|
||||
<span className="pack-count mono">{batch.count} 张</span>
|
||||
</div>
|
||||
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<SkeletonGrid count={8} />
|
||||
@@ -826,9 +904,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图) */}
|
||||
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图)。
|
||||
under-confirm:批次弹窗内点单图删除会弹 ConfirmModal(.modal-bg),故批次层必须降到 .modal-bg 之下,否则二次确认被压住看不见(R109 修复) */}
|
||||
{openBatch && createPortal(
|
||||
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
|
||||
<div className="pack-modal-bg under-confirm" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
|
||||
<div className="pack-modal">
|
||||
<div className="pack-modal-h">
|
||||
<div>
|
||||
@@ -870,11 +949,11 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
|
||||
)}
|
||||
|
||||
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
||||
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<span className="ct">已选 <b>{selected.size}</b> 项</span>
|
||||
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}>清空</button>
|
||||
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
|
||||
<span className="ct">已选 <b>{selCount}</b> {isBatchTab ? "批" : "项"}</span>
|
||||
<button className="clear-sel" type="button" onClick={clearSelection}>清空</button>
|
||||
<span className="sep" />
|
||||
<button className="danger" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
|
||||
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
|
||||
删除选中
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -665,7 +665,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<td>
|
||||
<div className="hstack">
|
||||
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
|
||||
<span className="muted-2 mono" style={{ fontSize: "12px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { api } from "../api";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
import type { Asset, Product } from "../types";
|
||||
import "../trash-page.css";
|
||||
@@ -32,6 +33,9 @@ export function TrashPage({ onRestore, onPurge }: {
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [errText, setErrText] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState<"restore" | "purge" | null>(null);
|
||||
const [purgeAllOpen, setPurgeAllOpen] = useState(false);
|
||||
const isEmpty = items.length === 0 && assets.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -70,6 +74,41 @@ export function TrashPage({ onRestore, onPurge }: {
|
||||
finally { setBusyId(null); }
|
||||
}
|
||||
|
||||
// 全部恢复:商品走 onRestore、资产走 api.restoreAsset,各自独立通道并发跑;失败的留在列表、错误如实亮出
|
||||
// 按 id 而非下标比对结果:防止执行期间单条操作改动了 items/assets 导致下标错位
|
||||
async function restoreAll() {
|
||||
setBulkBusy("restore");
|
||||
setErrText("");
|
||||
const itemIds = items.map((p) => p.id);
|
||||
const assetIds = assets.map((a) => a.id);
|
||||
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onRestore(id))));
|
||||
const assetResults = await Promise.allSettled(assetIds.map((id) => api.restoreAsset(id)));
|
||||
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
|
||||
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
|
||||
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
|
||||
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
|
||||
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败");
|
||||
setBulkBusy(null);
|
||||
}
|
||||
// 清空垃圾桶:同上,走各自的彻底删除通道
|
||||
async function purgeAll() {
|
||||
setPurgeAllOpen(false);
|
||||
setBulkBusy("purge");
|
||||
setErrText("");
|
||||
const itemIds = items.map((p) => p.id);
|
||||
const assetIds = assets.map((a) => a.id);
|
||||
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onPurge(id))));
|
||||
const assetResults = await Promise.allSettled(assetIds.map((id) => api.purgeAsset(id)));
|
||||
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
|
||||
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
|
||||
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
|
||||
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
|
||||
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败");
|
||||
setBulkBusy(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="trash-page">
|
||||
<div className="page-head">
|
||||
@@ -81,6 +120,12 @@ export function TrashPage({ onRestore, onPurge }: {
|
||||
<span>可恢复,或彻底删除(不可恢复)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => setPurgeAllOpen(true)}>清空垃圾桶</button>
|
||||
<button className="btn btn-primary" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => void restoreAll()}>
|
||||
{bulkBusy === "restore" ? "恢复中…" : "全部恢复"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -165,6 +210,16 @@ export function TrashPage({ onRestore, onPurge }: {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={purgeAllOpen}
|
||||
title="清空垃圾桶"
|
||||
subtitle="// PURGE ALL"
|
||||
detail="清空垃圾桶数据无法恢复,是否需要清空?"
|
||||
confirmText="清空"
|
||||
onCancel={() => setPurgeAllOpen(false)}
|
||||
onConfirm={purgeAll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user