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
This commit is contained in:
1 parent
dd0f6e2c09
commit
21c55dfb3a
60 files changed
+2768
-2461
No files matched your search
@@ -39,6 +39,7 @@ import {
|
||||
} from "~/api/files.ts";
|
||||
import { useSettings } from "~/api/settings.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { RecordLink, recordPath } from "~/components/console/record-sheet.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { ScrapeRecordView } from "~/components/library/scrape-panel.tsx";
|
||||
import { EntityPicker } from "~/components/organize-wizard/entity-picker.tsx";
|
||||
@@ -355,7 +356,7 @@ export function RenameDialog({
|
||||
? {
|
||||
action: {
|
||||
label: "整理记录",
|
||||
onClick: () => window.location.assign(`/console/organize/${r.recordId}`),
|
||||
onClick: () => window.location.assign(recordPath("organize", r.recordId as string)),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
@@ -577,7 +578,7 @@ export function TransferDialog({
|
||||
.map((f) => `${baseName(f.path)}:${f.message}`)
|
||||
.join("\n"),
|
||||
action: recordId
|
||||
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
|
||||
? { label: "整理记录", onClick: () => window.location.assign(recordPath("organize", recordId)) }
|
||||
: undefined,
|
||||
});
|
||||
onClose();
|
||||
@@ -802,7 +803,7 @@ export function DeleteDialog({
|
||||
description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined,
|
||||
action:
|
||||
mode === "trash"
|
||||
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
|
||||
? { label: "回收站", onClick: () => window.location.assign("/console/files?view=trash") }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
@@ -865,9 +866,9 @@ export function DeleteDialog({
|
||||
{c.downloads.map((d) => (
|
||||
<p key={d.taskId}>
|
||||
下载任务{" "}
|
||||
<Link className="underline hover:text-foreground" to={`/console/downloads/${d.taskId}`}>
|
||||
<RecordLink className="underline hover:text-foreground" page="downloads" id={d.taskId}>
|
||||
{d.taskId.slice(0, 8)}
|
||||
</Link>{" "}
|
||||
</RecordLink>{" "}
|
||||
· {d.status} · H&R{" "}
|
||||
{d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { RecordLink } from "~/components/console/record-sheet.tsx";
|
||||
import { Timeline } from "~/components/console/timeline.tsx";
|
||||
import { SLOT_STATUS } from "~/components/library/labels.ts";
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
@@ -55,9 +56,9 @@ function RecordsSection({ entityId }: { entityId: string }) {
|
||||
at: r.finishedAt ?? r.startedAt ?? r.createdAt,
|
||||
tone: RECORD_STATUS_TONE[r.status],
|
||||
title: (
|
||||
<Link className="hover:underline" to={`/console/organize/${r.id}`}>
|
||||
<RecordLink className="hover:underline" page="organize" id={r.id}>
|
||||
{ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]}
|
||||
</Link>
|
||||
</RecordLink>
|
||||
),
|
||||
description: r.target.path ? <span className="font-mono text-meta break-all">{r.target.path}</span> : null,
|
||||
meta: `${r.counts.succeeded}/${r.counts.total}`,
|
||||
@@ -185,9 +186,9 @@ export function FileDetail({
|
||||
{
|
||||
label: "任务",
|
||||
value: (
|
||||
<Link className="underline underline-offset-2" to={`/console/downloads/${e.download.taskId}`}>
|
||||
<RecordLink className="underline underline-offset-2" page="downloads" id={e.download.taskId}>
|
||||
{downloadLabel(e.download.status).label}
|
||||
</Link>
|
||||
</RecordLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { FileEntry } from "~/api/files.ts";
|
||||
import { RecordLink } from "~/components/console/record-sheet.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
@@ -112,14 +113,15 @@ export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: bo
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5" title={v.hint}>
|
||||
{!compact && e.download ? (
|
||||
<Link
|
||||
to={`/console/downloads/${e.download.taskId}`}
|
||||
<RecordLink
|
||||
page="downloads"
|
||||
id={e.download.taskId}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="min-w-0 truncate hover:underline"
|
||||
title={`${v.hint ?? v.label} · 打开下载任务`}
|
||||
>
|
||||
{badge}
|
||||
</Link>
|
||||
</RecordLink>
|
||||
) : (
|
||||
badge
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LoaderCircle, Sparkles, Wand2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { type FileEntry, type FileRoot, filesErrorText } from "~/api/files.ts";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { RecordLink } from "~/components/console/record-sheet.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { candidateEntity, LEVEL, reasonsText } from "~/components/organize-wizard/entity-picker.tsx";
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
@@ -91,9 +91,9 @@ export function IdentifyPanel({
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{it.records.lastRecordId ? (
|
||||
<Link className="underline underline-offset-2" to={`/console/organize/${it.records.lastRecordId}`}>
|
||||
<RecordLink className="underline underline-offset-2" page="organize" id={it.records.lastRecordId}>
|
||||
查看
|
||||
</Link>
|
||||
</RecordLink>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user