- 资产库:批次弹窗降层(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>
226 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|