Files
yingqing/core/frontend/src/routes/trash.tsx
T
2026-08-20 12:52:05 +08:00

389 lines
15 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { api } from "../api";
import { SystemLoading } from "../components/loading";
import { ConfirmModal } from "../components/overlays";
import type { NavigateFn } from "./route-config";
import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
import "../trash-page.css";
type TrashKind = "product" | "asset" | "model" | "image" | "imageExceptionBatch" | "freeVideo" | "project";
type TrashRow = {
id: string;
kind: TrashKind;
title: string;
subtitle: string;
cover?: string;
};
type TrashSection = {
key: TrashKind;
title: string;
rows: TrashRow[];
};
type ProductBatchResult = {
succeededIds: string[];
failedIds: string[];
};
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
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: "音频" };
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: ImageConversationTrash[]): 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)}` : ""}`,
cover: c.cover_preview_url || ""
}));
const IMAGE_MODE_LABEL: Record<ImageExceptionBatchTrash["mode"], string> = {
image: "图片创作",
model: "模特上身图",
cover: "平台套图"
};
const rowsFromImageExceptionBatches = (items: ImageExceptionBatchTrash[]): TrashRow[] =>
items.map((batch) => ({
id: batch.id,
kind: "imageExceptionBatch",
title: batch.prompt || batch.product_title || "未命名图片生成",
subtitle: `${IMAGE_MODE_LABEL[batch.mode]} · ${batch.count}${dateOf(batch.updated_at) ? ` · 删除于 ${dateOf(batch.updated_at)}` : ""}`,
cover: batch.cover_preview_url || ""
}));
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, onRestoreProducts, onPurgeProducts, onChanged }: {
navigate: NavigateFn;
onRestore: (id: string) => Promise<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
onRestoreProducts: (ids: string[]) => Promise<ProductBatchResult>;
onPurgeProducts: (ids: string[]) => Promise<ProductBatchResult>;
onChanged?: () => void;
}) {
const [sections, setSections] = useState<TrashSection[]>([]);
const [loading, setLoading] = useState(true);
const [confirmId, setConfirmId] = 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 allRows = useMemo(() => sections.flatMap((section) => section.rows), [sections]);
const isEmpty = allRows.length === 0;
useEffect(() => {
let alive = true;
void Promise.allSettled([
api.productsTrash(),
api.assetsTrash(),
api.modelsTrash(),
api.conversationsTrash("image"),
api.imageExceptionBatchesTrash(),
api.freeVideoTrash(0, 100),
api.projectsTrash()
]).then(([products, assets, models, conversations, imageExceptionBatches, freeVideos, projects]) => {
if (!alive) return;
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: "imageExceptionBatch",
title: "图片异常批次",
rows: imageExceptionBatches.status === "fulfilled" ? rowsFromImageExceptionBatches(imageExceptionBatches.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; };
}, []);
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)
);
}
function rowKey(row: TrashRow): string {
return `${row.kind}:${row.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 === "imageExceptionBatch") return api.restoreWorkbenchImageBatch(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 === "imageExceptionBatch") return api.purgeWorkbenchImageBatch(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 restoreRow(row);
removeRow(row);
onChanged?.();
} catch (error) {
setErrText(error instanceof Error ? error.message : "恢复失败");
} finally {
setBusyKey(null);
}
}
async function purge(row: TrashRow) {
setBusyKey(rowKey(row));
setErrText("");
try {
await purgeRow(row);
removeRow(row);
setConfirmId(null);
onChanged?.();
} catch (error) {
setErrText(error instanceof Error ? error.message : "彻底删除失败");
} finally {
setBusyKey(null);
}
}
async function restoreAll() {
setBulkBusy("restore");
setErrText("");
const rows = [...allRows];
const productRows = rows.filter((row) => row.kind === "product");
const otherRows = rows.filter((row) => row.kind !== "product");
const [productResult, otherResults] = await Promise.all([
productRows.length
? onRestoreProducts(productRows.map((row) => row.id))
: Promise.resolve<ProductBatchResult>({ succeededIds: productRows.map((row) => row.id), failedIds: [] }),
Promise.allSettled(otherRows.map((row) => restoreRow(row)))
]);
const failed = new Set([
...productResult.failedIds.map((id) => `product:${id}`),
...otherRows.filter((_, index) => otherResults[index].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 = otherResults.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败");
if (productResult.succeededIds.length || otherResults.some((r) => r.status === "fulfilled")) onChanged?.();
setBulkBusy(null);
}
async function purgeAll() {
setPurgeAllOpen(false);
setBulkBusy("purge");
setErrText("");
const rows = [...allRows];
const productRows = rows.filter((row) => row.kind === "product");
const otherRows = rows.filter((row) => row.kind !== "product");
const [productResult, otherResults] = await Promise.all([
productRows.length
? onPurgeProducts(productRows.map((row) => row.id))
: Promise.resolve<ProductBatchResult>({ succeededIds: productRows.map((row) => row.id), failedIds: [] }),
Promise.allSettled(otherRows.map((row) => purgeRow(row)))
]);
const failed = new Set([
...productResult.failedIds.map((id) => `product:${id}`),
...otherRows.filter((_, index) => otherResults[index].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 = otherResults.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败");
if (productResult.succeededIds.length || otherResults.some((r) => r.status === "fulfilled")) onChanged?.();
setBulkBusy(null);
}
return (
<div className="trash-page">
<div className="trash-inner">
<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 ? (
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="trash" />
) : isEmpty ? (
<div className="placeholder trash-empty"><span className="ph-frame">垃圾桶是空的</span></div>
) : (
<>
{errText && <div className="trash-err mono">{errText}</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">
{section.rows.map((row) => {
const key = rowKey(row);
const busy = busyKey === key;
return (
<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={row.title}>{row.title}</div>
<div className="trash-sub mono">{row.subtitle}</div>
</div>
<div className="trash-actions">
{confirmId === key ? (
<>
<span className="trash-confirm-txt mono">彻底删除?不可恢复</span>
<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(row)}>恢复</button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(key)}>彻底删除</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</>
)}
</div>
<ConfirmModal
open={purgeAllOpen}
title="清空垃圾桶"
detail="清空垃圾桶数据无法恢复,是否需要清空?"
confirmText="清空"
onCancel={() => setPurgeAllOpen(false)}
onConfirm={purgeAll}
/>
</div>
);
}