Files
yingqing/core/frontend/src/routes/trash.tsx
T
zycandClaude Fable 5 5ec69ad91f 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>
2026-07-02 18:03:26 +08:00

226 lines
11 KiB
TypeScript

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";
const coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.preview_url)?.preview_url || "";
// 资产缩略图:主文件直链优先,任一带 preview_url 的文件兜底
const assetCoverOf = (a: Asset): string =>
a.files?.find((f) => f.is_primary && f.preview_url)?.preview_url || a.files?.find((f) => f.preview_url)?.preview_url || "";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// mono 时间戳(设计规范 // 05.22 式):软删走 update updated_at → updated_at ≈ 删除时间
const dateOf = (iso?: string): string => {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
};
const ASSET_TYPE_LABEL: Record<string, string> = { image: "图片", video: "视频", audio: "音频" };
// 垃圾桶:已软删商品(status=archived)+ 已软删资产(is_deleted,R108)· 均可恢复 / 可彻底删除(不可恢复)
export function TrashPage({ onRestore, onPurge }: {
navigate: NavigateFn;
onRestore: (id: string) => Promise<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
}) {
const [items, setItems] = useState<Product[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
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;
void Promise.allSettled([api.productsTrash(), api.assetsTrash()]).then(([p, a]) => {
if (!alive) return;
setItems(p.status === "fulfilled" ? p.value.results : []);
setAssets(a.status === "fulfilled" ? a.value.results : []);
setLoading(false);
});
return () => { alive = false; };
}, []);
async function restore(id: string) {
setBusyId(id);
try { await onRestore(id); setItems((list) => list.filter((p) => p.id !== id)); }
finally { setBusyId(null); }
}
async function purge(id: string) {
setBusyId(id);
try { await onPurge(id); setItems((list) => list.filter((p) => p.id !== id)); setConfirmId(null); }
finally { setBusyId(null); }
}
// 资产的恢复/彻底删除直接走 api(商品那对经 App 的 action 包装,只发商品口)。失败不移行、如实亮错误。
async function restoreAsset(id: string) {
setBusyId(id);
setErrText("");
try { await api.restoreAsset(id); setAssets((list) => list.filter((a) => a.id !== id)); }
catch (error) { setErrText(error instanceof Error ? error.message : "恢复失败"); }
finally { setBusyId(null); }
}
async function purgeAsset(id: string) {
setBusyId(id);
setErrText("");
try { await api.purgeAsset(id); setAssets((list) => list.filter((a) => a.id !== id)); setConfirmId(null); }
catch (error) { setErrText(error instanceof Error ? error.message : "彻底删除失败"); }
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">
<div>
<h1>垃圾桶</h1>
<div className="sub">
<span className="mono">// 已删除商品与资产</span>
<span>·</span>
<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 ? (
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 && assets.length === 0 ? (
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
) : (
<>
{errText && <div className="trash-err mono">// {errText}</div>}
{items.length > 0 && (
<div className="trash-section">
<div className="trash-section-title mono">// 商品 · {items.length}</div>
<div className="trash-list">
{items.map((p) => {
const cover = coverOf(p);
const busy = busyId === p.id;
return (
<div className="trash-row" key={p.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame">无图</span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={p.title}>{p.title}</div>
<div className="trash-sub mono">// {p.category || "未分类"}</div>
</div>
<div className="trash-actions">
{confirmId === p.id ? (
<>
<span className="trash-confirm-txt mono">彻底删除?不可恢复</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purge(p.id)}>确认删除</button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}>取消</button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(p.id)}>恢复</button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(p.id)}>彻底删除</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{assets.length > 0 && (
<div className="trash-section">
<div className="trash-section-title mono">// 资产 · {assets.length}</div>
<div className="trash-list">
{assets.map((a) => {
const cover = assetCoverOf(a);
const busy = busyId === a.id;
const deletedAt = dateOf(a.updated_at);
return (
<div className="trash-row" key={a.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame">无图</span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={a.name}>{a.name}</div>
<div className="trash-sub mono">// {ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}{deletedAt ? ` · 删除于 ${deletedAt}` : ""}</div>
</div>
<div className="trash-actions">
{confirmId === a.id ? (
<>
<span className="trash-confirm-txt mono">彻底删除?不可恢复</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purgeAsset(a.id)}>确认删除</button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}>取消</button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restoreAsset(a.id)}>恢复</button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(a.id)}>彻底删除</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
<ConfirmModal
open={purgeAllOpen}
title="清空垃圾桶"
subtitle="// PURGE ALL"
detail="清空垃圾桶数据无法恢复,是否需要清空?"
confirmText="清空"
onCancel={() => setPurgeAllOpen(false)}
onConfirm={purgeAll}
/>
</div>
);
}