import { ArrowLeft, Clock, File as FileIcon, Folder, LoaderCircle, RotateCcw, Trash, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { useSession } from "~/api/auth.ts"; import { type FileRef, filesErrorText, filesMockEnabled, type TrashItem, useFileRoots, usePurge, useRestore, useTrash, } from "~/api/files.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { DirTree } from "~/components/files/dir-tree.tsx"; import { Checkbox } from "~/components/files/file-list.tsx"; import { baseName, newIdempotencyKey } from "~/components/files/model.ts"; import { Button, buttonVariants } from "~/components/ui/button.tsx"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog.tsx"; import { Input } from "~/components/ui/input.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; const DAY = 86_400_000; function expiryTone(iso: string): "destructive" | "warning" | "neutral" { const left = Date.parse(iso) - Date.now(); if (left <= 0) return "destructive"; if (left < DAY) return "warning"; return "neutral"; } export default function FilesTrashPage() { const trash = useTrash(); const roots = useFileRoots(); const session = useSession(); const isAdmin = session.data?.actor?.type === "admin"; const restore = useRestore(); const purge = usePurge(); const [picked, setPicked] = useState>(new Set()); const [restoreTo, setRestoreTo] = useState(null); const [purgeAll, setPurgeAll] = useState(false); const [purgeIds, setPurgeIds] = useState(null); const [confirmText, setConfirmText] = useState(""); const rows = trash.data?.pages.flatMap((p) => p.items); const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]); const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? []; const toggle = (id: string) => setPicked((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else n.add(id); return n; }); const doRestore = (ids: string[], target?: FileRef) => restore.mutate( { idempotencyKey: newIdempotencyKey(), ids, target }, { onSuccess: (r) => { setPicked(new Set()); setRestoreTo(null); if (r.restored.length) toast.success(`已还原 ${r.restored.length} 项`); for (const f of r.failed) toast.error( f.code === "exists" || f.code === "conflict" ? "原位置已被占用,可用“还原到…”换个目录" : f.message, { description: rows?.find((x) => x.id === f.id)?.originalPath }, ); }, onError: (e) => toast.error(filesErrorText(e, "还原失败")), }, ); const doPurge = (ids?: string[]) => purge .mutateAsync({ idempotencyKey: newIdempotencyKey(), ids }) .then((r) => { setPicked(new Set()); toast.success(`已清除 ${r.purged.length} 项${r.failed.length ? `,${r.failed.length} 项失败` : ""}`); }) .catch((e) => { toast.error(filesErrorText(e, "清除失败")); throw e; }); const columns: DataColumn[] = [ { key: "pick", header: ( 0 && picked.size < (rows?.length ?? 0)} label="全选" onToggle={() => setPicked(picked.size === rows?.length ? new Set() : new Set(rows?.map((r) => r.id)))} /> ), className: "w-10", cell: (r) => ( toggle(r.id)} /> ), }, { key: "name", header: "原位置", cell: (r) => { const root = rootName.get(r.rootId); const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null } | null; return (
{r.isDir ? ( ) : ( )}

{baseName(r.originalPath)}

{root?.name ?? r.rootId} / {r.originalPath}

{snap?.title ? (

删除时关联:{snap.title} {snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原不会自动恢复资产)

) : null}
); }, }, { key: "size", header: "大小", hideBelow: "sm", align: "right", cell: (r) => {formatBytes(r.size, 1)}, }, { key: "deleted", header: "删除", hideBelow: "md", cell: (r) => ( {relTime(r.deletedAt)} {r.actor ? ( {r.actor.replace(/^admin_session:/, "管理员 ")} ) : null} ), }, { key: "expires", header: "到期清除", cell: (r) => ( {Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)} ), }, { key: "act", header: 操作, align: "right", cell: (r) => (
{isAdmin ? ( ) : null}
), }, ]; return ( <> {filesMockEnabled() ? 演示数据 : null} 文件 } />
{isAdmin ? ( <> 清除已到期 {expired.length || ""} } title="清除已到期的项?" description={`${expired.length} 项已过保留期,清除后不可恢复。`} confirmLabel="清除" destructive onConfirm={() => doPurge(undefined)} /> ) : ( 立即清除仅管理员会话可用。 )}
r.id} loading={trash.isPending} error={trash.error} onRetry={() => void trash.refetch()} hasMore={trash.hasNextPage} onLoadMore={() => void trash.fetchNextPage()} loadingMore={trash.isFetchingNextPage} empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会先移到这里。" }} /> !o && setPurgeIds(null)} title={`立即清除 ${purgeIds?.length ?? 0} 项?`} description="清除后不可恢复。" confirmLabel="清除" destructive onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))} /> {/* 清空全部:二次确认,需输入“清空回收站” */} { setPurgeAll(o); if (!o) setConfirmText(""); }} title="清空回收站?" description={`将立即清除全部 ${rows?.length ?? 0} 项(含未到期的),不可恢复。`} confirmLabel="清空" destructive onConfirm={() => { if (confirmText.trim() !== "清空回收站") { toast.error("请输入“清空回收站”确认"); return Promise.reject(new Error("unconfirmed")); } return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText("")); }} > setConfirmText(e.target.value)} placeholder="输入 清空回收站" aria-label="输入 清空回收站 以确认" /> {restoreTo ? ( setRestoreTo(null)} onPick={(t) => doRestore(restoreTo, t)} pending={restore.isPending} /> ) : null} ); } function RestoreToDialog({ ids, onClose, onPick, pending, }: { ids: string[]; onClose: () => void; onPick: (t: FileRef) => void; pending: boolean; }) { const roots = useFileRoots(); const [target, setTarget] = useState(null); return ( !o && onClose()}> 还原到… {ids.length} 项 · 选一个可写的目录;同名已存在的项会失败,不会覆盖。
); }