feat(web): W5-B file manager page /console/files
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]>
This commit is contained in:
1 parent
2e2d56184c
commit
89ea68465e
17 files changed
+6269
-2
No files matched your search
@@ -0,0 +1,913 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import {
|
||||
ArrowDownAZ,
|
||||
ArrowUpAZ,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileStack,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Library,
|
||||
PenLine,
|
||||
RotateCw,
|
||||
ScanSearch,
|
||||
Search,
|
||||
SearchX,
|
||||
ServerCrash,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileRef,
|
||||
filesErrorText,
|
||||
filesKeys,
|
||||
filesMockEnabled,
|
||||
isRootOffline,
|
||||
type ListQuery,
|
||||
refKey,
|
||||
useFileList,
|
||||
useFileRoots,
|
||||
useProbe,
|
||||
} from "~/api/files.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||||
import {
|
||||
CopyPathDialog,
|
||||
DeleteDialog,
|
||||
MkdirDialog,
|
||||
RenameDialog,
|
||||
ScrapeDialog,
|
||||
TransferDialog,
|
||||
} from "~/components/files/dialogs.tsx";
|
||||
import { DirTree } from "~/components/files/dir-tree.tsx";
|
||||
import { FileDetail } from "~/components/files/file-detail.tsx";
|
||||
import { DRAG_MIME, FileList } from "~/components/files/file-list.tsx";
|
||||
import {
|
||||
emptySelection,
|
||||
FILTER_CHIPS,
|
||||
opDisabledReason,
|
||||
parentRel,
|
||||
pruneSelection,
|
||||
type Selection,
|
||||
SORT_OPTIONS,
|
||||
totalSize,
|
||||
} from "~/components/files/model.ts";
|
||||
import { OrganizeWizard } from "~/components/files/organize-wizard.tsx";
|
||||
import { PathBreadcrumb } from "~/components/files/path-breadcrumb.tsx";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const HEALTH_REASON: Record<string, string> = {
|
||||
root_missing: "挂载点不存在",
|
||||
sentinel_missing: "根目录里找不到哨兵文件,可能挂载未生效",
|
||||
timeout: "探测超时",
|
||||
permission_denied: "没有读取权限",
|
||||
};
|
||||
const healthReasonText = (r: string | null) => (r ? (HEALTH_REASON[r] ?? r) : "挂载点没有响应");
|
||||
|
||||
type Dialog =
|
||||
| { kind: "rename"; entry: FileEntry }
|
||||
| { kind: "transfer"; items: FileRef[]; mode: "move" | "copy"; target?: FileRef | null }
|
||||
| { kind: "delete"; items: FileRef[] }
|
||||
| { kind: "mkdir" }
|
||||
| { kind: "copyPath"; entry: FileEntry }
|
||||
| { kind: "scrape"; entry: FileEntry }
|
||||
| { kind: "wizard"; entries: FileEntry[] };
|
||||
|
||||
const TREE_W_KEY = "umt-files-tree-w";
|
||||
const FILES_CHANGED = "files.changed";
|
||||
|
||||
function useIsMobile() {
|
||||
const [m, setM] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
const on = () => setM(mq.matches);
|
||||
on();
|
||||
mq.addEventListener("change", on);
|
||||
return () => mq.removeEventListener("change", on);
|
||||
}, []);
|
||||
return m;
|
||||
}
|
||||
|
||||
function useTreeWidth() {
|
||||
const [w, setW] = useState(260);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const v = Number(localStorage.getItem(TREE_W_KEY));
|
||||
if (v >= 200 && v <= 440) setW(v);
|
||||
} catch {
|
||||
// 存储不可用时用默认宽度
|
||||
}
|
||||
}, []);
|
||||
const save = (v: number) => {
|
||||
const c = Math.max(200, Math.min(440, Math.round(v)));
|
||||
setW(c);
|
||||
try {
|
||||
localStorage.setItem(TREE_W_KEY, String(c));
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
};
|
||||
return [w, save] as const;
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.actor?.type === "admin";
|
||||
const mobile = useIsMobile();
|
||||
const [treeW, setTreeW] = useTreeWidth();
|
||||
const roots = useFileRoots();
|
||||
const rootList = roots.data ?? [];
|
||||
|
||||
const rootId = params.get("root") ?? "";
|
||||
const path = params.get("path") ?? "";
|
||||
const sort = (params.get("sort") ?? "name") as ListQuery["sort"];
|
||||
const order = (params.get("order") ?? "asc") as "asc" | "desc";
|
||||
const filter = params.get("f") ?? "";
|
||||
const hidden = params.get("hidden") === "1";
|
||||
const selParam = params.get("sel");
|
||||
const [qInput, setQInput] = useState(params.get("q") ?? "");
|
||||
const q = params.get("q") ?? "";
|
||||
const root = rootList.find((r) => r.id === rootId);
|
||||
|
||||
const patch = useCallback(
|
||||
(p: Record<string, string | null>, replace = false) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [k, v] of Object.entries(p)) {
|
||||
if (v === null || v === "") next.delete(k);
|
||||
else next.set(k, v);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace },
|
||||
),
|
||||
[setParams],
|
||||
);
|
||||
|
||||
// 默认进第一个在线的库根
|
||||
useEffect(() => {
|
||||
if (rootId || !rootList.length) return;
|
||||
const first = rootList.find((r) => r.health !== "offline") ?? rootList[0];
|
||||
if (first) patch({ root: first.id, path: null }, true);
|
||||
}, [rootId, rootList, patch]);
|
||||
|
||||
// 搜索 300 ms 防抖写回 URL
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (qInput.trim() !== q) patch({ q: qInput.trim() || null }, true);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [qInput, q, patch]);
|
||||
|
||||
const listQ: ListQuery | null =
|
||||
root && root.health !== "offline" ? { root: root.id, path, sort, order, q, filter, hidden } : null;
|
||||
const list = useFileList(listQ);
|
||||
const pages = useMemo(() => list.data?.pages ?? [], [list.data]);
|
||||
const entries = useMemo(() => pages.flatMap((p) => p.entries), [pages]);
|
||||
const first = pages[0];
|
||||
const keyOf = useCallback((e: FileEntry) => refKey({ root: rootId, path: e.path }), [rootId]);
|
||||
|
||||
// 目录快照变了(cursor stale)→ 从头重载
|
||||
const refetchList = list.refetch;
|
||||
useEffect(() => {
|
||||
if (pages.some((p) => p.stale)) void refetchList();
|
||||
}, [pages, refetchList]);
|
||||
|
||||
const [sel, setSel] = useState<Selection>(emptySelection);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换目录清空选择
|
||||
useEffect(() => setSel(emptySelection()), [rootId, path]);
|
||||
useEffect(() => {
|
||||
setSel((s) => {
|
||||
const n = pruneSelection(s, entries.map(keyOf));
|
||||
return n.keys.size === s.keys.size && n.anchor === s.anchor && n.focus === s.focus ? s : n;
|
||||
});
|
||||
}, [entries, keyOf]);
|
||||
|
||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||
const [menu, setMenu] = useState<{ at: MenuAnchor; entry: FileEntry } | null>(null);
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
const dragRefs = useRef<FileRef[]>([]);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const probe = useProbe();
|
||||
|
||||
const selected = useMemo(() => entries.filter((e) => sel.keys.has(keyOf(e))), [entries, sel.keys, keyOf]);
|
||||
const detail = selParam != null ? (entries.find((e) => e.path === selParam) ?? null) : null;
|
||||
|
||||
// SSE:files.changed 与整理事件 → 刷新受影响目录
|
||||
useSse([FILES_CHANGED, EventType.organizationChanged], (ev) => {
|
||||
const p = ev.payload as { rootId?: string; dirs?: string[] } | undefined;
|
||||
if (ev.type === FILES_CHANGED && p?.rootId) {
|
||||
for (const d of p.dirs ?? [""]) {
|
||||
void qc.invalidateQueries({ queryKey: ["files", "list", p.rootId, d] });
|
||||
void qc.invalidateQueries({ queryKey: filesKeys.tree(p.rootId, d) });
|
||||
}
|
||||
} else void qc.invalidateQueries({ queryKey: ["files", "list"] });
|
||||
});
|
||||
|
||||
// "/" 聚焦搜索
|
||||
useEffect(() => {
|
||||
const on = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.ctrlKey || e.metaKey) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest("input,textarea,select,[contenteditable=true],[role=dialog]")) return;
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", on);
|
||||
return () => window.removeEventListener("keydown", on);
|
||||
}, []);
|
||||
|
||||
const go = (loc: { root: string; path: string }) => {
|
||||
setTreeOpen(false);
|
||||
patch({ root: loc.root, path: loc.path || null, sel: null, q: null });
|
||||
setQInput("");
|
||||
};
|
||||
const open = (e: FileEntry) => {
|
||||
if (e.escape) return toast.error("链接指向登记根之外,已禁用");
|
||||
if (e.type === "dir") go({ root: rootId, path: e.path });
|
||||
else patch({ sel: e.path });
|
||||
};
|
||||
const up = () => {
|
||||
if (path) go({ root: rootId, path: parentRel(path) });
|
||||
};
|
||||
|
||||
const refs = (list: FileEntry[]) => list.map((e) => ({ root: rootId, path: e.path }));
|
||||
const targets = (e?: FileEntry) => (e && !sel.keys.has(keyOf(e)) ? [e] : selected.length ? selected : e ? [e] : []);
|
||||
|
||||
const doProbe = (list: FileEntry[]) => {
|
||||
const vids = list.filter((e) => e.type === "file" && e.fileKind === "video").slice(0, 20);
|
||||
if (!vids.length) return toast.info("选中项里没有视频文件");
|
||||
probe.mutate(
|
||||
{ items: refs(vids) },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const ok = r.items.filter((i) => i.status === "ok").length;
|
||||
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
|
||||
description: r.items.some((i) => i.savedToAsset)
|
||||
? "已登记资产已写回媒体信息"
|
||||
: "未登记文件的结果缓存 24 小时",
|
||||
});
|
||||
},
|
||||
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openDelete = (list: FileEntry[]) => {
|
||||
if (!root || !list.length) return;
|
||||
const blocked = list.map((e) => opDisabledReason("delete", e, root)).find(Boolean);
|
||||
if (blocked && list.length === 1) return toast.error(blocked);
|
||||
setDialog({ kind: "delete", items: refs(list) });
|
||||
};
|
||||
const openRename = (e: FileEntry) => {
|
||||
if (!root) return;
|
||||
const why = opDisabledReason("rename", e, root);
|
||||
if (why) return toast.error(why);
|
||||
setDialog({ kind: "rename", entry: e });
|
||||
};
|
||||
|
||||
const menuItems = (e: FileEntry): MenuItem[] => {
|
||||
if (!root) return [];
|
||||
const list = targets(e);
|
||||
const multi = list.length > 1;
|
||||
const why = (op: Parameters<typeof opDisabledReason>[0]) =>
|
||||
list.map((x) => opDisabledReason(op, x, root)).find(Boolean) ?? null;
|
||||
const items: MenuItem[] = [];
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "open",
|
||||
label: e.type === "dir" ? "打开" : "详情",
|
||||
icon: FolderOpen,
|
||||
hint: "Enter",
|
||||
onSelect: () => open(e),
|
||||
});
|
||||
if (multi)
|
||||
items.push({
|
||||
key: "wizard",
|
||||
label: `整理向导(${list.length} 项)`,
|
||||
icon: Wand2,
|
||||
disabled: why("organize"),
|
||||
onSelect: () => setDialog({ kind: "wizard", entries: list }),
|
||||
});
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "rename",
|
||||
label: "重命名",
|
||||
icon: PenLine,
|
||||
hint: "F2",
|
||||
disabled: why("rename"),
|
||||
onSelect: () => openRename(e),
|
||||
});
|
||||
items.push(
|
||||
{
|
||||
key: "move",
|
||||
label: "移动到…",
|
||||
icon: FolderInput,
|
||||
disabled: why("move"),
|
||||
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "move" }),
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
label: "复制到…",
|
||||
icon: Copy,
|
||||
disabled: e.escape ? "链接指向登记根之外,已禁用" : null,
|
||||
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "copy" }),
|
||||
},
|
||||
);
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "organize",
|
||||
label: "整理到…",
|
||||
icon: Wand2,
|
||||
disabled: why("organize"),
|
||||
onSelect: () => setDialog({ kind: "wizard", entries: list }),
|
||||
});
|
||||
items.push({
|
||||
key: "identify",
|
||||
label: "识别",
|
||||
icon: Sparkles,
|
||||
disabled: why("identify"),
|
||||
onSelect: () => setDialog({ kind: "wizard", entries: list }),
|
||||
});
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "scrape",
|
||||
label: "刮削",
|
||||
icon: Library,
|
||||
disabled: why("scrape"),
|
||||
onSelect: () => setDialog({ kind: "scrape", entry: e }),
|
||||
});
|
||||
items.push({
|
||||
key: "probe",
|
||||
label: "探测",
|
||||
icon: ScanSearch,
|
||||
disabled: list.some((x) => x.type === "file" && x.fileKind === "video") ? null : "只能探测视频文件",
|
||||
onSelect: () => doProbe(list),
|
||||
});
|
||||
items.push({ key: "sep1", separator: true });
|
||||
if (!multi) {
|
||||
items.push({
|
||||
key: "copyPath",
|
||||
label: "复制路径…",
|
||||
icon: ClipboardCopy,
|
||||
onSelect: () => setDialog({ kind: "copyPath", entry: e }),
|
||||
});
|
||||
items.push({
|
||||
key: "openMedia",
|
||||
label: "打开作品",
|
||||
icon: Library,
|
||||
disabled: why("openMedia"),
|
||||
onSelect: () => e.asset && navigate(`/console/library/${e.asset.mediaEntityId}`),
|
||||
});
|
||||
items.push({
|
||||
key: "records",
|
||||
label: "在整理记录中查看",
|
||||
icon: FileStack,
|
||||
disabled: why("records"),
|
||||
onSelect: () => e.asset && navigate(`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`),
|
||||
});
|
||||
items.push({ key: "sep2", separator: true });
|
||||
}
|
||||
items.push({
|
||||
key: "delete",
|
||||
label: multi ? `删除 ${list.length} 项` : "删除",
|
||||
icon: Trash2,
|
||||
hint: "Del",
|
||||
destructive: true,
|
||||
disabled: why("delete"),
|
||||
onSelect: () => openDelete(list),
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
// 拖拽:列表项 → 树节点 / 面包屑 / 列表里的目录 = 移动到(Alt 复制),一律先出 dryRun 预览
|
||||
const onDragRows = (e: FileEntry, ev: React.DragEvent) => {
|
||||
const k = keyOf(e);
|
||||
const list = sel.keys.has(k) ? selected : [e];
|
||||
if (!sel.keys.has(k)) setSel({ keys: new Set([k]), anchor: k, focus: k });
|
||||
dragRefs.current = refs(list);
|
||||
ev.dataTransfer.effectAllowed = "copyMove";
|
||||
ev.dataTransfer.setData(DRAG_MIME, JSON.stringify(dragRefs.current));
|
||||
ev.dataTransfer.setData("text/plain", list.map((x) => x.name).join("\n"));
|
||||
};
|
||||
const onDropTo = (target: FileRef, ev: React.DragEvent) => {
|
||||
const items = dragRefs.current;
|
||||
dragRefs.current = [];
|
||||
if (!items.length) return;
|
||||
if (items.some((i) => i.root === target.root && (i.path === target.path || parentRel(i.path) === target.path))) {
|
||||
if (items.every((i) => i.root === target.root && parentRel(i.path) === target.path)) return;
|
||||
}
|
||||
const tRoot = rootList.find((r) => r.id === target.root);
|
||||
if (!tRoot?.writable) return toast.error("只能拖到可写的媒体库根");
|
||||
setDialog({ kind: "transfer", items, mode: ev.altKey ? "copy" : "move", target });
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setQInput("");
|
||||
patch({ q: null, f: null });
|
||||
};
|
||||
|
||||
// ── 左栏 ──
|
||||
const tree = (
|
||||
<div className="space-y-3">
|
||||
{roots.isPending ? <LoadingState rows={4} /> : null}
|
||||
{roots.error ? <ErrorState plain error={roots.error} onRetry={() => void roots.refetch()} /> : null}
|
||||
{rootList.length ? (
|
||||
<DirTree roots={rootList} current={root ? { root: root.id, path } : null} onSelect={go} onDropTo={onDropTo} />
|
||||
) : null}
|
||||
{roots.data && !rootList.length ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={FolderTree}
|
||||
title="还没有可浏览的根"
|
||||
description="在系统设置里登记媒体库根或下载器路径映射后出现在这里。"
|
||||
className="px-2 py-8"
|
||||
/>
|
||||
) : null}
|
||||
<Link
|
||||
to="/console/files/trash"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-caption text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
回收站
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 列表区主体 ──
|
||||
const offline = list.error && isRootOffline(list.error);
|
||||
const filtered = !!(q || filter);
|
||||
let body: React.ReactNode;
|
||||
if (!root) {
|
||||
body = roots.isPending ? <LoadingState rows={8} className="p-4" /> : null;
|
||||
} else if (root.health === "offline" || offline) {
|
||||
body = (
|
||||
<div role="alert" className="flex flex-col items-center px-6 py-14 text-center">
|
||||
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ServerCrash className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-lead font-medium text-foreground">{root.name} 离线</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
|
||||
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}
|
||||
。不会把离线当成空目录,也不会改变任何资产状态。
|
||||
</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
void roots.refetch();
|
||||
void list.refetch();
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else if (list.isPending) {
|
||||
body = <LoadingState rows={10} className="p-4" />;
|
||||
} else if (list.error && !entries.length) {
|
||||
body = (
|
||||
<ErrorState
|
||||
plain
|
||||
error={new Error(filesErrorText(list.error, "读取目录失败"))}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
);
|
||||
} else if (!entries.length) {
|
||||
body = filtered ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={SearchX}
|
||||
title="没有符合筛选的项"
|
||||
description={q ? `搜索“${q}”没有结果;支持子串与 glob(如 *.mkv)。` : undefined}
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<X />
|
||||
清除筛选
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={FolderOpen}
|
||||
title="空目录"
|
||||
description={hidden ? undefined : "隐藏文件未显示,可在工具栏打开。"}
|
||||
action={
|
||||
root.writable ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setDialog({ kind: "mkdir" })}>
|
||||
<FolderPlus />
|
||||
新建目录
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<FileList
|
||||
gridRef={gridRef}
|
||||
entries={entries}
|
||||
keyOf={keyOf}
|
||||
selection={sel}
|
||||
onSelection={setSel}
|
||||
onOpen={open}
|
||||
onUp={up}
|
||||
onMenu={(entry, at) => setMenu({ entry, at })}
|
||||
onRename={openRename}
|
||||
onDelete={openDelete}
|
||||
onDragRows={onDragRows}
|
||||
onDropToDir={(dir, ev) => onDropTo({ root: rootId, path: dir.path }, ev)}
|
||||
hasMore={!!list.hasNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
mobile={mobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selCount = selected.length;
|
||||
const actionBar =
|
||||
selCount > 0 && root ? (
|
||||
<div
|
||||
className={cn(
|
||||
"pg-float z-30 flex items-center gap-1.5 overflow-x-auto rounded-full px-2 py-1.5 scroll-bleed",
|
||||
mobile
|
||||
? "fixed right-3 bottom-3 left-3"
|
||||
: "absolute bottom-12 left-1/2 max-w-[calc(100%-2rem)] -translate-x-1/2",
|
||||
)}
|
||||
role="toolbar"
|
||||
aria-label="批量操作"
|
||||
>
|
||||
<span className="shrink-0 px-2 text-caption whitespace-nowrap">
|
||||
已选 {selCount} 项{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setDialog({ kind: "wizard", entries: selected })}
|
||||
disabled={!!selected.map((e) => opDisabledReason("organize", e, root)).find(Boolean)}
|
||||
>
|
||||
<Wand2 />
|
||||
整理
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDialog({ kind: "transfer", items: refs(selected), mode: "move" })}
|
||||
disabled={!!selected.map((e) => opDisabledReason("move", e, root)).find(Boolean)}
|
||||
>
|
||||
<FolderInput />
|
||||
移动
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDialog({ kind: "transfer", items: refs(selected), mode: "copy" })}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => doProbe(selected)} disabled={probe.isPending}>
|
||||
<ScanSearch />
|
||||
探测
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => openDelete(selected)}
|
||||
disabled={!!selected.map((e) => opDisabledReason("delete", e, root)).find(Boolean)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="清除选择" onClick={() => setSel(emptySelection())}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="文件"
|
||||
description="只浏览已登记的库根与下载目录;每行带业务状态,删除进回收站,改名同步资产与路径绑定。"
|
||||
actions={
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<Trash2 />
|
||||
回收站
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{/* 桌面左栏:根 + 目录树(可拖宽) */}
|
||||
<aside
|
||||
className="pg-chrome relative hidden shrink-0 self-start overflow-hidden rounded-2xl md:block"
|
||||
style={{ width: treeW }}
|
||||
aria-label="根与目录树"
|
||||
>
|
||||
<div className="max-h-[calc(100dvh-11rem)] overflow-y-auto p-2.5">{tree}</div>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整目录树宽度"
|
||||
aria-valuenow={treeW}
|
||||
aria-valuemin={200}
|
||||
aria-valuemax={440}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
const startX = e.clientX;
|
||||
const startW = treeW;
|
||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||
const upH = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", upH);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", upH);
|
||||
}}
|
||||
className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="pg-slab relative flex h-[calc(100dvh-11rem)] min-h-[28rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
|
||||
aria-label="目录内容"
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<div className="space-y-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="md:hidden"
|
||||
aria-label="根与目录"
|
||||
onClick={() => setTreeOpen(true)}
|
||||
>
|
||||
<FolderTree />
|
||||
</Button>
|
||||
{root ? (
|
||||
<PathBreadcrumb
|
||||
rootName={root.name}
|
||||
path={path}
|
||||
onNavigate={(p) => go({ root: root.id, path: p })}
|
||||
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => {
|
||||
void roots.refetch();
|
||||
void list.refetch();
|
||||
}}
|
||||
>
|
||||
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!root?.writable}
|
||||
title={root?.writable ? undefined : "只读根不能新建目录"}
|
||||
onClick={() => setDialog({ kind: "mkdir" })}
|
||||
>
|
||||
<FolderPlus />
|
||||
<span className="hidden sm:inline">新建目录</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-full sm:w-60">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setQInput("");
|
||||
gridRef.current?.focus();
|
||||
}
|
||||
if (e.key === "ArrowDown") gridRef.current?.focus();
|
||||
}}
|
||||
placeholder="名称或 glob,如 *.mkv ( / )"
|
||||
aria-label="搜索当前目录"
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto scroll-bleed">
|
||||
{FILTER_CHIPS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
aria-pressed={filter === c.value}
|
||||
onClick={() => patch({ f: c.value || null })}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2.5 py-1 text-meta whitespace-nowrap transition-colors duration-micro",
|
||||
filter === c.value
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<select
|
||||
aria-label="排序"
|
||||
value={sort}
|
||||
onChange={(e) => patch({ sort: e.target.value === "name" ? null : e.target.value })}
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-caption"
|
||||
>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
按{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={order === "asc" ? "升序,点此改为降序" : "降序,点此改为升序"}
|
||||
title={order === "asc" ? "升序" : "降序"}
|
||||
onClick={() => patch({ order: order === "asc" ? "desc" : null })}
|
||||
>
|
||||
{order === "asc" ? <ArrowDownAZ /> : <ArrowUpAZ />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-pressed={hidden}
|
||||
aria-label={hidden ? "隐藏点文件" : "显示隐藏文件"}
|
||||
title={hidden ? "正在显示隐藏文件" : "显示隐藏文件"}
|
||||
onClick={() => patch({ hidden: hidden ? null : "1" })}
|
||||
className={cn(hidden && "bg-foreground/[0.08] text-foreground")}
|
||||
>
|
||||
{hidden ? <Eye /> : <EyeOff />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{first?.truncated ? (
|
||||
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
|
||||
目录过大,只读取了前 {first.total.toLocaleString("zh-CN")} 项。请用搜索或进入子目录缩小范围。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
{root && !offline && root.health !== "offline" ? (
|
||||
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
|
||||
<span>
|
||||
{first ? `${entries.length.toLocaleString("zh-CN")} / ${first.total.toLocaleString("zh-CN")} 项` : "—"}
|
||||
</span>
|
||||
{selCount ? (
|
||||
<span>
|
||||
已选 {selCount}
|
||||
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{root.usage ? <span className="ml-auto">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
|
||||
{!root.writable ? <StatusBadge>只读</StatusBadge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!mobile ? actionBar : null}
|
||||
</section>
|
||||
</div>
|
||||
{mobile ? actionBar : null}
|
||||
|
||||
{/* 手机:根与目录树收进抽屉 */}
|
||||
<Sheet open={treeOpen} onOpenChange={setTreeOpen}>
|
||||
<SheetContent side="left" className="w-[85vw] gap-0 p-0 sm:max-w-sm">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-4 py-3">
|
||||
<SheetTitle>根与目录</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">{tree}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ContextMenu anchor={menu?.at ?? null} items={menu ? menuItems(menu.entry) : []} onClose={() => setMenu(null)} />
|
||||
|
||||
{detail && root ? (
|
||||
<FileDetail
|
||||
root={root}
|
||||
entry={detail}
|
||||
onClose={() => patch({ sel: null })}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openRename(detail)}
|
||||
disabled={!!opDisabledReason("rename", detail, root)}
|
||||
>
|
||||
<PenLine />
|
||||
重命名
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialog({ kind: "transfer", items: refs([detail]), mode: "move" })}
|
||||
disabled={!!opDisabledReason("move", detail, root)}
|
||||
>
|
||||
<FolderInput />
|
||||
移动
|
||||
</Button>
|
||||
{detail.asset ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setDialog({ kind: "wizard", entries: [detail] })}
|
||||
disabled={!!opDisabledReason("organize", detail, root)}
|
||||
>
|
||||
<Wand2 />
|
||||
整理
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => openDelete([detail])}
|
||||
disabled={!!opDisabledReason("delete", detail, root)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "rename" && root ? (
|
||||
<RenameDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
{dialog?.kind === "transfer" ? (
|
||||
<TransferDialog
|
||||
roots={rootList}
|
||||
items={dialog.items}
|
||||
initialMode={dialog.mode}
|
||||
initialTarget={dialog.target}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
{dialog?.kind === "delete" ? (
|
||||
<DeleteDialog roots={rootList} items={dialog.items} isAdmin={isAdmin} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
{root ? (
|
||||
<MkdirDialog
|
||||
root={root}
|
||||
path={path}
|
||||
open={dialog?.kind === "mkdir"}
|
||||
onOpenChange={(o) => !o && setDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
{dialog?.kind === "copyPath" && root ? (
|
||||
<CopyPathDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
{dialog?.kind === "scrape" ? <ScrapeDialog entry={dialog.entry} onClose={() => setDialog(null)} /> : null}
|
||||
{dialog?.kind === "wizard" && root ? (
|
||||
<OrganizeWizard roots={rootList} entries={dialog.entries} rootId={root.id} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user