Files
UHD_Media_Tool/apps/web/app/components/files/trash-view.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

440 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Clock, File as FileIcon, Folder, LoaderCircle, RotateCcw, Trash, Trash2 } from "lucide-react";
import { type ReactNode, 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 { consolePath } from "~/components/console/nav.ts";
import { recordPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { DirPicker } from "~/components/files/dir-picker.tsx";
import { Checkbox } from "~/components/files/file-list.tsx";
import { baseName, newIdempotencyKey, parentRel } from "~/components/files/model.ts";
import { Button } 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 { actorText } from "~/lib/labels.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";
}
// 回收站:文件管理右侧的一个视图(?view=trash,console-ia.md §9),左边目录树里点「回收站」切过来。
export function TrashView({ leading }: { leading?: ReactNode }) {
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("");
/** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */
const [failedIds, setFailedIds] = useState<Map<string, { code: string; message: string }>>(new Map());
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);
setFailedIds((prev) => {
const next = new Map(prev);
for (const x of r.restored) next.delete(x.id);
for (const f of r.failed) next.set(f.id, { code: f.code, message: f.message });
return next;
});
if (r.restored.length)
toast.success(
`已还原 ${r.restored.length} 项`,
r.recordId
? {
action: {
label: "整理记录",
onClick: () => window.location.assign(recordPath("organize", r.recordId as string)),
},
}
: undefined,
);
if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`);
},
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 fail = failedIds.get(r.id);
return (
<div className="flex min-w-0 max-w-md 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.trashPath}>
{baseName(r.originalPath)}
</p>
{fail ? (
<p className="text-meta text-destructive">
{fail.code === "exists" || fail.code === "conflict"
? "原位置已被占用"
: fail.code === "too_large"
? "跨卷还原超过大小上限"
: fail.message}
</p>
) : null}
</div>
</div>
);
},
},
{
key: "origin",
header: "原位置",
hideBelow: "md",
cell: (r) => {
const root = rootName.get(r.rootId);
const dir = parentRel(r.originalPath);
return root ? (
<Link
to={`/console/files?root=${encodeURIComponent(r.rootId)}${dir ? `&path=${encodeURIComponent(dir)}` : ""}`}
className="block max-w-xs truncate font-mono text-meta text-muted-foreground hover:text-foreground hover:underline"
title={`${root.hostPath}/${r.originalPath}`}
>
{dir ? `${root.name}/${dir}` : root.name}
</Link>
) : (
<span className="block max-w-xs truncate font-mono text-meta text-muted-foreground">{r.rootId}</span>
);
},
},
{
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">{actorText(r.actor)}</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: "asset",
header: "媒体库记录",
hideBelow: "lg",
cell: (r) => {
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
return (
<span className="block max-w-48 text-meta" title="还原后不会回到媒体库,需要时重新整理">
{snap.mediaEntityId ? (
<Link className="underline underline-offset-2" to={consolePath("library", snap.mediaEntityId)}>
{label}
</Link>
) : (
label
)}
<span className="block text-muted-foreground">已移出媒体库</span>
</span>
);
},
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<div className="flex justify-end gap-1">
{failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? (
<Button size="xs" variant="outline" onClick={() => setRestoreTo([r.id])}>
<RotateCcw />
还原到…
</Button>
) : (
<>
<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 (
<>
<div className="mb-3 flex min-w-0 items-start gap-2">
{leading}
<div className="min-w-0 flex-1">
<h2 className="flex items-center gap-2 text-body font-semibold text-foreground">
回收站
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
</h2>
<p className="text-caption text-muted-foreground">
到期后自动清除,到期前可以还原。还原不会恢复媒体库记录,需要时重新整理。
</p>
</div>
</div>
<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 ? `还原 ${picked.size} 项` : "还原选中"}
</Button>
{isAdmin ? (
<>
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
<Trash2 />
{picked.size ? `清除 ${picked.size} 项` : "清除选中"}
</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: "在文件管理里删除的项会出现在这里。" }}
groupBy={
new Set(rows?.map((r) => r.rootId)).size > 1
? {
key: (r) => r.rootId,
label: (k) => rootName.get(k)?.name ?? k,
storageKey: "files-trash-by-root",
}
: undefined
}
/>
<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="清空"
confirmDisabled={confirmText.trim() !== "清空"}
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>
<DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
<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>
);
}