389 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|