fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程

This commit is contained in:
hh
2026-07-10 15:06:07 +08:00
parent 3e52942547
commit 564215dc53
28 changed files with 1086 additions and 190 deletions
+223 -114
View File
@@ -1,111 +1,257 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, 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 type { Asset, FreeVideoTask, ImageConversation, ModelEntity, Product, Project } 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 || "";
type TrashKind = "product" | "asset" | "model" | "image" | "freeVideo" | "project";
type TrashRow = {
id: string;
kind: TrashKind;
title: string;
subtitle: string;
cover?: string;
};
type TrashSection = {
key: TrashKind;
title: string;
rows: TrashRow[];
};
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 => {
const dateOf = (iso?: string | null): 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 coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.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 ASSET_TYPE_LABEL: Record<string, string> = { image: "图片", video: "视频", audio: "音频" };
// 垃圾桶:已软删商品(status=archived)+ 已软删资产(is_deleted,R108)· 均可恢复 / 可彻底删除(不可恢复)
export function TrashPage({ onRestore, onPurge }: {
const rowsFromProducts = (items: Product[]): TrashRow[] =>
items.map((p) => ({
id: p.id,
kind: "product",
title: p.title,
subtitle: p.category || "未分类",
cover: coverOf(p)
}));
const rowsFromAssets = (items: Asset[]): TrashRow[] =>
items.map((a) => {
const deletedAt = dateOf(a.updated_at);
return {
id: a.id,
kind: "asset",
title: a.name,
subtitle: `${ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}${deletedAt ? ` · 删除于 ${deletedAt}` : ""}`,
cover: assetCoverOf(a)
};
});
const rowsFromModels = (items: ModelEntity[]): TrashRow[] =>
items.map((m) => ({
id: m.id,
kind: "model",
title: m.name,
subtitle: m.source === "upload" ? "上传模特" : "AI 模特",
cover: m.portrait || m.triview || ""
}));
const rowsFromConversations = (items: ImageConversation[]): TrashRow[] =>
items.map((c) => ({
id: c.id,
kind: "image",
title: c.title || "未命名图片创作",
subtitle: `${c.task_count || 0} 个生成任务${dateOf(c.updated_at) ? ` · 删除于 ${dateOf(c.updated_at)}` : ""}`
}));
const rowsFromFreeVideos = (items: FreeVideoTask[]): TrashRow[] =>
items.map((task) => ({
id: task.id,
kind: "freeVideo",
title: task.prompt || "未命名视频创作",
subtitle: `${task.status}${task.duration ? ` · ${task.duration}s` : ""}${task.aspect_ratio ? ` · ${task.aspect_ratio}` : ""}`,
cover: task.thumbnail_url || ""
}));
const rowsFromProjects = (items: Project[]): TrashRow[] =>
items.map((p) => ({
id: p.id,
kind: "project",
title: p.name,
subtitle: p.product_title || p.current_stage || p.status,
cover: p.cover_preview_url || ""
}));
export function TrashPage({ onRestore, onPurge, onChanged }: {
navigate: NavigateFn;
onRestore: (id: string) => Promise<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
onChanged?: () => void;
}) {
const [items, setItems] = useState<Product[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [sections, setSections] = useState<TrashSection[]>([]);
const [loading, setLoading] = useState(true);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [busyKey, setBusyKey] = 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;
const allRows = useMemo(() => sections.flatMap((section) => section.rows), [sections]);
const isEmpty = allRows.length === 0;
useEffect(() => {
let alive = true;
void Promise.allSettled([api.productsTrash(), api.assetsTrash()]).then(([p, a]) => {
void Promise.allSettled([
api.productsTrash(),
api.assetsTrash(),
api.modelsTrash(),
api.conversationsTrash("image"),
api.freeVideoTrash(0, 100),
api.projectsTrash()
]).then(([products, assets, models, conversations, freeVideos, projects]) => {
if (!alive) return;
setItems(p.status === "fulfilled" ? p.value.results : []);
setAssets(a.status === "fulfilled" ? a.value.results : []);
const rawSections: TrashSection[] = [
{
key: "product",
title: "商品",
rows: products.status === "fulfilled" ? rowsFromProducts(products.value.results) : []
},
{
key: "asset",
title: "资产",
rows: assets.status === "fulfilled" ? rowsFromAssets(assets.value.results) : []
},
{
key: "model",
title: "模特",
rows: models.status === "fulfilled" ? rowsFromModels(models.value.results) : []
},
{
key: "image",
title: "自由创作图片",
rows: conversations.status === "fulfilled" ? rowsFromConversations(conversations.value.results) : []
},
{
key: "freeVideo",
title: "自由创作视频",
rows: freeVideos.status === "fulfilled" ? rowsFromFreeVideos(freeVideos.value.results) : []
},
{
key: "project",
title: "视频项目",
rows: projects.status === "fulfilled" ? rowsFromProjects(projects.value.results) : []
}
];
const nextSections = rawSections.filter((section) => section.rows.length > 0);
setSections(nextSections);
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); }
function removeRow(row: TrashRow) {
setSections((list) =>
list
.map((section) => section.key === row.kind ? { ...section, rows: section.rows.filter((item) => item.id !== row.id) } : section)
.filter((section) => section.rows.length > 0)
);
}
async function purge(id: string) {
setBusyId(id);
try { await onPurge(id); setItems((list) => list.filter((p) => p.id !== id)); setConfirmId(null); }
finally { setBusyId(null); }
function rowKey(row: TrashRow): string {
return `${row.kind}:${row.id}`;
}
// 资产的恢复/彻底删除直接走 api(商品那对经 App 的 action 包装,只发商品口)。失败不移行、如实亮错误。
async function restoreAsset(id: string) {
setBusyId(id);
function restoreRow(row: TrashRow): Promise<unknown> {
if (row.kind === "product") return Promise.resolve(onRestore(row.id));
if (row.kind === "asset") return api.restoreAsset(row.id);
if (row.kind === "model") return api.restoreModel(row.id);
if (row.kind === "image") return api.restoreConversation(row.id);
if (row.kind === "freeVideo") return api.restoreFreeVideo(row.id);
return api.restoreProject(row.id);
}
function purgeRow(row: TrashRow): Promise<unknown> {
if (row.kind === "product") return Promise.resolve(onPurge(row.id));
if (row.kind === "asset") return api.purgeAsset(row.id);
if (row.kind === "model") return api.purgeModel(row.id);
if (row.kind === "image") return api.purgeConversation(row.id);
if (row.kind === "freeVideo") return api.purgeFreeVideo(row.id);
return api.purgeProject(row.id);
}
async function restore(row: TrashRow) {
setBusyKey(rowKey(row));
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); }
try {
await restoreRow(row);
removeRow(row);
onChanged?.();
} catch (error) {
setErrText(error instanceof Error ? error.message : "恢复失败");
} finally {
setBusyKey(null);
}
}
async function purgeAsset(id: string) {
setBusyId(id);
async function purge(row: TrashRow) {
setBusyKey(rowKey(row));
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); }
try {
await purgeRow(row);
removeRow(row);
setConfirmId(null);
onChanged?.();
} catch (error) {
setErrText(error instanceof Error ? error.message : "彻底删除失败");
} finally {
setBusyKey(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");
const rows = [...allRows];
const results = await Promise.allSettled(rows.map((row) => restoreRow(row)));
const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey));
setSections((list) =>
list
.map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) }))
.filter((section) => section.rows.length > 0)
);
const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败");
if (results.some((r) => r.status === "fulfilled")) onChanged?.();
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");
const rows = [...allRows];
const results = await Promise.allSettled(rows.map((row) => purgeRow(row)));
const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey));
setSections((list) =>
list
.map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) }))
.filter((section) => section.rows.length > 0)
);
const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败");
if (results.some((r) => r.status === "fulfilled")) onChanged?.();
setBulkBusy(null);
}
@@ -115,7 +261,7 @@ export function TrashPage({ onRestore, onPurge }: {
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 已删除商品与资产</span>
<span className="mono">// 已删除内容</span>
<span>·</span>
<span>,()</span>
</div>
@@ -123,45 +269,45 @@ export function TrashPage({ onRestore, onPurge }: {
<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" ? "恢复中" : "全部恢复"}
{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" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中...</span></div>
) : isEmpty ? (
<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>
{sections.map((section) => (
<div className="trash-section" key={section.key}>
<div className="trash-section-title mono">// {section.title} · {section.rows.length}</div>
<div className="trash-list">
{items.map((p) => {
const cover = coverOf(p);
const busy = busyId === p.id;
{section.rows.map((row) => {
const key = rowKey(row);
const busy = busyKey === key;
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 className="trash-row" key={key}>
<div className={`placeholder trash-thumb${row.cover ? " has-mock-media" : ""}`} style={row.cover ? mediaStyle(row.cover) : undefined}>
{!row.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 className="trash-name" title={row.title}>{row.title}</div>
<div className="trash-sub mono">// {row.subtitle}</div>
</div>
<div className="trash-actions">
{confirmId === p.id ? (
{confirmId === key ? (
<>
<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 trash-del" type="button" disabled={busy} onClick={() => void purge(row)}></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>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(row)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(key)}></button>
</>
)}
</div>
@@ -170,44 +316,7 @@ export function TrashPage({ onRestore, onPurge }: {
})}
</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>
)}
))}
</>
)}