Roots + lazy dir tree, virtualized grid with status badges and asset/slot/ download links, breadcrumb, sort, glob search, hidden toggle, filter chips, paging and truncation notice; multi-select (Shift/Ctrl/all), action bar, context menu, drag-to-move with dryRun preview, keyboard navigation, offline (503) and empty/error states, mobile layout with tree drawer. Rename/move/copy/delete dialogs with dryRun previews and reference checks, 4-step organize wizard, trash view (restore/restore-to/purge, expiry). DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
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<Set<string>>(new Set());
|
|
const [restoreTo, setRestoreTo] = useState<string[] | null>(null);
|
|
const [purgeAll, setPurgeAll] = useState(false);
|
|
const [purgeIds, setPurgeIds] = useState<string[] | null>(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<TrashItem>[] = [
|
|
{
|
|
key: "pick",
|
|
header: (
|
|
<Checkbox
|
|
checked={!!rows?.length && picked.size === rows.length}
|
|
partial={picked.size > 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) => (
|
|
<Checkbox checked={picked.has(r.id)} label={`选择 ${baseName(r.originalPath)}`} onToggle={() => 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 (
|
|
<div className="flex min-w-0 max-w-lg items-start gap-2">
|
|
{r.isDir ? (
|
|
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="truncate text-foreground" title={r.originalPath}>
|
|
{baseName(r.originalPath)}
|
|
</p>
|
|
<p className="truncate font-mono text-meta text-muted-foreground" title={r.trashPath}>
|
|
{root?.name ?? r.rootId} / {r.originalPath}
|
|
</p>
|
|
{snap?.title ? (
|
|
<p className="text-meta text-muted-foreground">
|
|
删除时关联:{snap.title}
|
|
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原不会自动恢复资产)
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "size",
|
|
header: "大小",
|
|
hideBelow: "sm",
|
|
align: "right",
|
|
cell: (r) => <span className="whitespace-nowrap">{formatBytes(r.size, 1)}</span>,
|
|
},
|
|
{
|
|
key: "deleted",
|
|
header: "删除",
|
|
hideBelow: "md",
|
|
cell: (r) => (
|
|
<span
|
|
className="block max-w-40 text-muted-foreground"
|
|
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
|
|
>
|
|
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
|
|
{r.actor ? (
|
|
<span className="block truncate text-meta">{r.actor.replace(/^admin_session:/, "管理员 ")}</span>
|
|
) : null}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "expires",
|
|
header: "到期清除",
|
|
cell: (r) => (
|
|
<span title={formatDateTime(r.expiresAt)}>
|
|
<StatusBadge tone={expiryTone(r.expiresAt)}>
|
|
{Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)}
|
|
</StatusBadge>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "act",
|
|
header: <span className="sr-only">操作</span>,
|
|
align: "right",
|
|
cell: (r) => (
|
|
<div className="flex justify-end gap-1">
|
|
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
|
|
<RotateCcw />
|
|
还原
|
|
</Button>
|
|
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
|
|
还原到…
|
|
</Button>
|
|
{isAdmin ? (
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
aria-label="立即清除"
|
|
title="立即清除"
|
|
onClick={() => setPurgeIds([r.id])}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="回收站"
|
|
description="删除的文件先移到这里,保留 7 天后由后台任务清除;期间可还原到原位置或其他目录。"
|
|
actions={
|
|
<>
|
|
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
|
<Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
|
<ArrowLeft />
|
|
文件
|
|
</Link>
|
|
</>
|
|
}
|
|
/>
|
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!picked.size || restore.isPending}
|
|
onClick={() => doRestore([...picked])}
|
|
>
|
|
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
|
|
还原选中 {picked.size || ""}
|
|
</Button>
|
|
{isAdmin ? (
|
|
<>
|
|
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
|
|
<Trash2 />
|
|
清除选中
|
|
</Button>
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button size="sm" variant="outline" disabled={!expired.length}>
|
|
<Clock />
|
|
清除已到期 {expired.length || ""}
|
|
</Button>
|
|
}
|
|
title="清除已到期的项?"
|
|
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
|
|
confirmLabel="清除"
|
|
destructive
|
|
onConfirm={() => doPurge(undefined)}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
className="ml-auto"
|
|
disabled={!rows?.length}
|
|
onClick={() => setPurgeAll(true)}
|
|
>
|
|
<Trash />
|
|
清空回收站
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<span className="ml-auto text-meta text-muted-foreground">立即清除仅管理员会话可用。</span>
|
|
)}
|
|
</div>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(r) => 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: "在文件页删除的项会先移到这里。" }}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!purgeIds}
|
|
onOpenChange={(o) => !o && setPurgeIds(null)}
|
|
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
|
|
description="清除后不可恢复。"
|
|
confirmLabel="清除"
|
|
destructive
|
|
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
|
|
/>
|
|
|
|
{/* 清空全部:二次确认,需输入“清空回收站” */}
|
|
<ConfirmDialog
|
|
open={purgeAll}
|
|
onOpenChange={(o) => {
|
|
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(""));
|
|
}}
|
|
>
|
|
<Input
|
|
value={confirmText}
|
|
onChange={(e) => setConfirmText(e.target.value)}
|
|
placeholder="输入 清空回收站"
|
|
aria-label="输入 清空回收站 以确认"
|
|
/>
|
|
</ConfirmDialog>
|
|
|
|
{restoreTo ? (
|
|
<RestoreToDialog
|
|
ids={restoreTo}
|
|
onClose={() => 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<FileRef | null>(null);
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>还原到…</DialogTitle>
|
|
<DialogDescription>{ids.length} 项 · 选一个可写的目录;同名已存在的项会失败,不会覆盖。</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-80 overflow-y-auto rounded-xl border border-border p-1.5">
|
|
<DirTree roots={roots.data ?? []} current={target} onSelect={setTarget} pickOnly />
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button disabled={!target || pending} onClick={() => target && onPick(target)}>
|
|
{pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
|
|
还原到这里
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|