Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
1264 lines
46 KiB
TypeScript
1264 lines
46 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import { EventType } from "@umt/contracts";
|
||
import {
|
||
ArrowDownAZ,
|
||
ArrowUp,
|
||
ArrowUpAZ,
|
||
ClipboardCopy,
|
||
Cloud,
|
||
Copy,
|
||
Download,
|
||
Eye,
|
||
EyeOff,
|
||
FileDown,
|
||
FileStack,
|
||
FolderInput,
|
||
FolderOpen,
|
||
FolderPlus,
|
||
FolderTree,
|
||
HardDrive,
|
||
Keyboard,
|
||
Library,
|
||
MoreHorizontal,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
PenLine,
|
||
RefreshCw,
|
||
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,
|
||
type FileRoot,
|
||
filesErrorText,
|
||
filesKeys,
|
||
filesMockEnabled,
|
||
isRootOffline,
|
||
type ListQuery,
|
||
refKey,
|
||
refreshRemoteDir,
|
||
useFileList,
|
||
useFileRoots,
|
||
useProbe,
|
||
useTrash,
|
||
} from "~/api/files.ts";
|
||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||
import {
|
||
CopyPathDialog,
|
||
DeleteDialog,
|
||
MkdirDialog,
|
||
RefreshMetadataDialog,
|
||
RenameDialog,
|
||
ScrapeDialog,
|
||
TransferDialog,
|
||
UploadButton,
|
||
} 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 {
|
||
displayPath,
|
||
emptySelection,
|
||
FILTER_CHIPS,
|
||
groupRoots,
|
||
initialLocation,
|
||
type Loc,
|
||
type MenuKey,
|
||
menuModel,
|
||
OP_LABEL,
|
||
opApplies,
|
||
opDisabledReason,
|
||
parentRel,
|
||
pruneSelection,
|
||
type Selection,
|
||
SORT_OPTIONS,
|
||
totalSize,
|
||
} from "~/components/files/model.ts";
|
||
import { PathBreadcrumb } from "~/components/files/path-breadcrumb.tsx";
|
||
import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx";
|
||
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Input } from "~/components/ui/input.tsx";
|
||
import { Pagination } from "~/components/ui/pagination.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: "refresh"; items: FileEntry[] }
|
||
| { kind: "scrape"; items: FileEntry[] }
|
||
| { kind: "wizard"; source: OrganizeSource }
|
||
| { kind: "wizardSource"; source: Extract<OrganizeSource, { kind: "files" }> }
|
||
| { kind: "shortcuts" };
|
||
|
||
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
|
||
export const handle = { layout: "wide" };
|
||
|
||
const TREE_W_KEY = "umt-files-tree-w";
|
||
const TREE_W_DEFAULT = 280;
|
||
const TREE_W_MIN = 200;
|
||
const TREE_W_MAX = 520;
|
||
const TREE_OPEN_KEY = "umt-files-tree-open";
|
||
const LAST_LOC_KEY = "umt-files-last";
|
||
const FILES_CHANGED = "files.changed";
|
||
|
||
const MENU_ICON: Record<MenuKey, typeof FolderOpen> = {
|
||
open: FolderOpen,
|
||
organize: Wand2,
|
||
identify: Sparkles,
|
||
rename: PenLine,
|
||
move: FolderInput,
|
||
copy: Copy,
|
||
scrape: RefreshCw,
|
||
writeNfo: FileDown,
|
||
probe: ScanSearch,
|
||
copyPath: ClipboardCopy,
|
||
openMedia: Library,
|
||
records: FileStack,
|
||
delete: Trash2,
|
||
};
|
||
const MENU_HINT: Partial<Record<MenuKey, string>> = { open: "Enter", rename: "F2", delete: "Del" };
|
||
|
||
function readStore(key: string): string | null {
|
||
try {
|
||
return localStorage.getItem(key);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
function writeStore(key: string, v: string) {
|
||
try {
|
||
localStorage.setItem(key, v);
|
||
} catch {
|
||
// 存储不可用时不记
|
||
}
|
||
}
|
||
|
||
function useTreeWidth() {
|
||
const [w, setW] = useState(TREE_W_DEFAULT);
|
||
useEffect(() => {
|
||
const v = Number(readStore(TREE_W_KEY));
|
||
if (v >= TREE_W_MIN && v <= TREE_W_MAX) setW(v);
|
||
}, []);
|
||
const save = (v: number) => {
|
||
const c = Math.max(TREE_W_MIN, Math.min(TREE_W_MAX, Math.round(v)));
|
||
setW(c);
|
||
writeStore(TREE_W_KEY, String(c));
|
||
};
|
||
return [w, save] as const;
|
||
}
|
||
|
||
function useTreeOpen() {
|
||
const [open, setOpen] = useState(true);
|
||
useEffect(() => {
|
||
if (readStore(TREE_OPEN_KEY) === "0") setOpen(false);
|
||
}, []);
|
||
const save = (v: boolean) => {
|
||
setOpen(v);
|
||
writeStore(TREE_OPEN_KEY, v ? "1" : "0");
|
||
};
|
||
return [open, save] as const;
|
||
}
|
||
|
||
function readLastLoc(): Loc | null {
|
||
try {
|
||
const v = JSON.parse(readStore(LAST_LOC_KEY) ?? "null") as Loc | null;
|
||
return v && typeof v.root === "string" && typeof v.path === "string" ? v : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const hnrPathsOf = (list: FileEntry[]) =>
|
||
list.filter((e) => e.download && (e.download.hnr !== "satisfied" || e.status === "downloading")).map((e) => e.path);
|
||
|
||
/** 折叠后的左栏:52px 圆角卡片里的图标条(登记目录 + 回收站),展开钮与主面板工具栏第一行对齐 */
|
||
function TreeRail({
|
||
roots,
|
||
current,
|
||
onSelect,
|
||
onExpand,
|
||
trashCount,
|
||
}: {
|
||
roots: FileRoot[];
|
||
current: string;
|
||
onSelect: (loc: Loc) => void;
|
||
onExpand: () => void;
|
||
trashCount: string;
|
||
}) {
|
||
return (
|
||
<nav aria-label="登记目录" className="flex flex-col items-center gap-1 py-2.5">
|
||
<Button variant="ghost" size="icon-sm" aria-label="展开目录树" title="展开目录树" onClick={onExpand}>
|
||
<PanelLeftOpen />
|
||
</Button>
|
||
<span aria-hidden className="my-1 h-px w-6 bg-border" />
|
||
{groupRoots(roots).flatMap((g) =>
|
||
g.items.map((r) => {
|
||
const Icon = r.kind === "download" ? Download : r.kind === "storage" ? Cloud : HardDrive;
|
||
return (
|
||
<button
|
||
key={r.id}
|
||
type="button"
|
||
title={`${r.name}${r.health === "offline" ? " · 离线" : ""}`}
|
||
aria-label={r.name}
|
||
aria-current={current === r.id ? "location" : undefined}
|
||
onClick={() => onSelect({ root: r.id, path: "" })}
|
||
className={cn(
|
||
"relative grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||
current === r.id && "pill-selected text-foreground",
|
||
)}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<span
|
||
aria-hidden
|
||
className={cn(
|
||
"absolute right-1 bottom-1 h-1.5 w-1.5 rounded-full",
|
||
r.health === "healthy" ? "bg-success" : r.health === "offline" ? "bg-destructive" : "bg-warning",
|
||
)}
|
||
/>
|
||
</button>
|
||
);
|
||
}),
|
||
)}
|
||
<span aria-hidden className="my-1 h-px w-6 bg-border" />
|
||
<Link
|
||
to="/console/files/trash"
|
||
title={`回收站 ${trashCount}`}
|
||
aria-label={`回收站 ${trashCount}`}
|
||
className="grid h-8 w-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Link>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
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 [treeOpen, setTreeOpenStored] = useTreeOpen();
|
||
const roots = useFileRoots();
|
||
const rootList = roots.data ?? [];
|
||
const trash = useTrash();
|
||
const trashFirst = trash.data?.pages[0];
|
||
const trashCount = trashFirst ? `${trashFirst.items.length}${trashFirst.nextCursor ? "+" : ""}` : "";
|
||
|
||
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 identifyAuto = params.get("idf") === "1";
|
||
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],
|
||
);
|
||
|
||
// 初始位置:上次位置 > 第一个下载目录 > 第一个库根(§2 #2)
|
||
useEffect(() => {
|
||
if (rootId || !rootList.length) return;
|
||
const loc = initialLocation(rootList, readLastLoc());
|
||
if (loc) patch({ root: loc.root, path: loc.path || null }, true);
|
||
}, [rootId, rootList, patch]);
|
||
|
||
// 记住上次位置
|
||
useEffect(() => {
|
||
if (root) writeStore(LAST_LOC_KEY, JSON.stringify({ root: root.id, path }));
|
||
}, [root, path]);
|
||
|
||
// 搜索 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 paged = useListPagination(entries, {
|
||
hasMore: list.hasNextPage,
|
||
onLoadMore: () => list.fetchNextPage(),
|
||
loadingMore: list.isFetchingNextPage,
|
||
error: list.error,
|
||
resetKey: JSON.stringify({ rootId, path, q, sort, order, filter, hidden }),
|
||
});
|
||
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; items: MenuItem[] } | null>(null);
|
||
const [treeSheet, setTreeSheet] = useState(false);
|
||
const [searchOpen, setSearchOpen] = 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) });
|
||
}
|
||
void qc.invalidateQueries({ queryKey: filesKeys.trash });
|
||
} else void qc.invalidateQueries({ queryKey: ["files", "list"] });
|
||
});
|
||
|
||
const go = (loc: { root: string; path: string }) => {
|
||
setTreeSheet(false);
|
||
patch({ root: loc.root, path: loc.path || null, sel: null, idf: 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, idf: null });
|
||
};
|
||
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] : []);
|
||
|
||
/** 整理只取视频和目录;选中的字幕/NFO 等附属文件随主文件处理,不单独提交 */
|
||
const openWizard = (all: FileEntry[], preset?: WizardEntity | null) => {
|
||
if (!root || !all.length) return;
|
||
const list = all.filter((x) => opApplies("identify", x));
|
||
if (!list.length) return toast.error("只有视频或目录能整理");
|
||
const why = list.map((x) => opDisabledReason("organize", x, root)).find(Boolean);
|
||
if (why) return toast.error(why);
|
||
setDialog({
|
||
kind: "wizard",
|
||
source: { kind: "files", items: refs(list), hnrPaths: hnrPathsOf(list), preset: preset ?? null },
|
||
});
|
||
};
|
||
|
||
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 copyPaths = async (list: FileEntry[]) => {
|
||
if (!root) return;
|
||
const text = list.map((e) => displayPath(root, e.path)).join("\n");
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast.success(`已复制 ${list.length} 个路径`);
|
||
} catch {
|
||
toast.error("浏览器不允许写剪贴板");
|
||
}
|
||
};
|
||
|
||
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 act = (op: MenuKey, list: FileEntry[]) => {
|
||
const e = list[0];
|
||
if (!e) return;
|
||
switch (op) {
|
||
case "open":
|
||
return open(e);
|
||
case "organize":
|
||
return openWizard(list);
|
||
case "identify":
|
||
if (list.length === 1) return patch({ sel: e.path, idf: "1" });
|
||
return openWizard(list);
|
||
case "rename":
|
||
return openRename(e);
|
||
case "move":
|
||
case "copy":
|
||
return setDialog({ kind: "transfer", items: refs(list), mode: op });
|
||
case "scrape":
|
||
return setDialog({ kind: "refresh", items: list });
|
||
case "writeNfo":
|
||
return setDialog({ kind: "scrape", items: list });
|
||
case "probe":
|
||
return doProbe(list);
|
||
case "copyPath":
|
||
return list.length > 1 ? void copyPaths(list) : setDialog({ kind: "copyPath", entry: e });
|
||
case "openMedia":
|
||
return e.asset && navigate(`/console/library/${e.asset.mediaEntityId}`);
|
||
case "records":
|
||
return e.asset && navigate(`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`);
|
||
case "delete":
|
||
return openDelete(list);
|
||
}
|
||
};
|
||
|
||
const menuItems = (list: FileEntry[]): MenuItem[] => {
|
||
if (!root || !list.length) return [];
|
||
const multi = list.length > 1;
|
||
const e = list[0] as FileEntry;
|
||
return menuModel(list, root).map((m) => {
|
||
if ("separator" in m) return m;
|
||
let label = OP_LABEL[m.key];
|
||
if (m.key === "open") label = e.type === "dir" ? "打开" : "详情";
|
||
if (multi && m.key === "organize") label = `整理 ${list.length} 项`;
|
||
if (multi && m.key === "delete") label = `删除 ${list.length} 项`;
|
||
if (m.key === "scrape") label = "刷新元数据";
|
||
return {
|
||
key: m.key,
|
||
label,
|
||
icon: MENU_ICON[m.key],
|
||
hint: multi ? undefined : MENU_HINT[m.key],
|
||
disabled: m.disabled,
|
||
destructive: m.key === "delete",
|
||
onSelect: () => act(m.key, list),
|
||
};
|
||
});
|
||
};
|
||
|
||
// 快捷键(§11):/ 搜索 · Alt+↑ 上一级 · Ctrl/⌘+Enter 整理选中项 · ? 快捷键浮层
|
||
const keyRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
||
keyRef.current = (e: KeyboardEvent) => {
|
||
const t = e.target as HTMLElement;
|
||
const typing = !!t.closest("input,textarea,select,[contenteditable=true]");
|
||
const inDialog = !!t.closest("[role=dialog],[role=alertdialog],[role=menu]");
|
||
if (inDialog || dialog) return;
|
||
if (e.altKey && e.key === "ArrowUp" && !typing) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
up();
|
||
return;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && !typing) {
|
||
const list = selected.length ? selected : [];
|
||
if (list.length) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
openWizard(list);
|
||
}
|
||
return;
|
||
}
|
||
if (typing || e.ctrlKey || e.metaKey || e.altKey) return;
|
||
if (e.key === "/") {
|
||
e.preventDefault();
|
||
setSearchOpen(true);
|
||
requestAnimationFrame(() => searchRef.current?.focus());
|
||
} else if (e.key === "?") {
|
||
e.preventDefault();
|
||
setDialog({ kind: "shortcuts" });
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
const on = (e: KeyboardEvent) => keyRef.current(e);
|
||
window.addEventListener("keydown", on, true);
|
||
return () => window.removeEventListener("keydown", on, true);
|
||
}, []);
|
||
|
||
// 拖拽:列表项 → 树节点 / 面包屑 / 列表里的目录 = 移动到(Alt 复制),一律先出预览
|
||
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.every((i) => i.root === target.root && (i.path === target.path || 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 openMenuAt = (el: HTMLElement, items: MenuItem[]) => {
|
||
const r = el.getBoundingClientRect();
|
||
setMenu({ at: { x: r.left, y: r.bottom + 6 }, items });
|
||
};
|
||
|
||
// ── 左栏 ──
|
||
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" />
|
||
回收站
|
||
{trashCount && trashCount !== "0" ? <span className="ml-auto tabular-nums">{trashCount}</span> : null}
|
||
</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="空目录"
|
||
action={
|
||
root.writable ? (
|
||
<Button variant="outline" size="sm" onClick={() => setDialog({ kind: "mkdir" })}>
|
||
<FolderPlus />
|
||
新建目录
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
);
|
||
} else {
|
||
body = (
|
||
<FileList
|
||
key={`${rootId}|${path}|${paged.pagination.page}|${paged.pagination.pageSize}`}
|
||
gridRef={gridRef}
|
||
entries={paged.items ?? []}
|
||
keyOf={keyOf}
|
||
selection={sel}
|
||
onSelection={setSel}
|
||
onOpen={open}
|
||
onUp={up}
|
||
onMenu={(entry, at) => setMenu({ at, items: menuItems(targets(entry)) })}
|
||
onRename={openRename}
|
||
onDelete={openDelete}
|
||
onAction={(e, op) => act(op, [e])}
|
||
opState={(e, op) => (root ? opDisabledReason(op, e, root) : null)}
|
||
onDragRows={onDragRows}
|
||
onDropToDir={(dir, ev) => onDropTo({ root: rootId, path: dir.path }, ev)}
|
||
hasMore={false}
|
||
onLoadMore={() => void list.fetchNextPage()}
|
||
loadingMore={list.isFetchingNextPage}
|
||
mobile={mobile}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── 批量栏 ──
|
||
const selCount = selected.length;
|
||
const blockedTip = (op: Parameters<typeof opDisabledReason>[0]) => {
|
||
if (!root) return null;
|
||
const bad = selected.map((e) => opDisabledReason(op, e, root)).filter(Boolean) as string[];
|
||
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
|
||
};
|
||
const moreItems = (): MenuItem[] => {
|
||
const items: MenuItem[] = [];
|
||
if (mobile) items.push({ key: "copy", label: "复制到…", icon: Copy, onSelect: () => act("copy", selected) });
|
||
if (selected.some((e) => opApplies("probe", e)))
|
||
items.push({ key: "probe", label: "探测", icon: ScanSearch, onSelect: () => act("probe", selected) });
|
||
items.push({
|
||
key: "scrape",
|
||
label: "刷新元数据",
|
||
icon: RefreshCw,
|
||
disabled: selected.every((e) => e.asset) ? null : "只能刷新已整理的文件",
|
||
onSelect: () => act("scrape", selected),
|
||
});
|
||
items.push({
|
||
key: "writeNfo",
|
||
label: "刮削…",
|
||
icon: FileDown,
|
||
disabled: selected.every((e) => opApplies("writeNfo", e)) ? blockedTip("writeNfo") : "只能刮削视频文件或目录",
|
||
onSelect: () => act("writeNfo", selected),
|
||
});
|
||
items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) });
|
||
return items;
|
||
};
|
||
const organizeTip = !selected.some((e) => opApplies("identify", e))
|
||
? "只有视频或目录能整理"
|
||
: (() => {
|
||
const bad = selected
|
||
.filter((e) => opApplies("identify", e))
|
||
.map((e) => (root ? opDisabledReason("organize", e, root) : null))
|
||
.filter(Boolean) as string[];
|
||
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
|
||
})();
|
||
const moveTip = blockedTip("move");
|
||
const deleteTip = blockedTip("delete");
|
||
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>
|
||
<span title={organizeTip ?? undefined}>
|
||
<Button size="sm" onClick={() => openWizard(selected)} disabled={!!organizeTip}>
|
||
<Wand2 />
|
||
整理
|
||
</Button>
|
||
</span>
|
||
<span title={moveTip ?? undefined}>
|
||
<Button size="sm" variant="outline" onClick={() => act("move", selected)} disabled={!!moveTip}>
|
||
<FolderInput />
|
||
移动
|
||
</Button>
|
||
</span>
|
||
{!mobile ? (
|
||
<Button size="sm" variant="outline" onClick={() => act("copy", selected)}>
|
||
<Copy />
|
||
复制
|
||
</Button>
|
||
) : null}
|
||
<Button size="sm" variant="outline" onClick={(ev) => openMenuAt(ev.currentTarget, moreItems())}>
|
||
<MoreHorizontal />
|
||
更多
|
||
</Button>
|
||
<span title={deleteTip ?? undefined}>
|
||
<Button size="sm" variant="destructive" onClick={() => openDelete(selected)} disabled={!!deleteTip}>
|
||
<Trash2 />
|
||
删除
|
||
</Button>
|
||
</span>
|
||
<Button size="icon-sm" variant="ghost" aria-label="清除选择" onClick={() => setSel(emptySelection())}>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
) : null;
|
||
|
||
// ── 工具栏 ──
|
||
const chips = (
|
||
<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>
|
||
);
|
||
const searchBox = (
|
||
<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("");
|
||
setSearchOpen(false);
|
||
gridRef.current?.focus();
|
||
}
|
||
if (e.key === "ArrowDown") gridRef.current?.focus();
|
||
}}
|
||
placeholder="名称或 glob,如 *.mkv"
|
||
aria-label="搜索当前目录"
|
||
className="h-8 pl-9"
|
||
/>
|
||
</div>
|
||
);
|
||
const sortControls = (
|
||
<div className="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>
|
||
);
|
||
const mobileMore = (): MenuItem[] => [
|
||
...SORT_OPTIONS.map((o) => ({
|
||
key: `sort-${o.value}`,
|
||
label: `按${o.label}${sort === o.value ? " ✓" : ""}`,
|
||
onSelect: () => patch({ sort: o.value === "name" ? null : o.value }),
|
||
})),
|
||
{
|
||
key: "order",
|
||
label: order === "asc" ? "改为降序" : "改为升序",
|
||
icon: order === "asc" ? ArrowUpAZ : ArrowDownAZ,
|
||
onSelect: () => patch({ order: order === "asc" ? "desc" : null }),
|
||
},
|
||
{
|
||
key: "hidden",
|
||
label: hidden ? "不显示隐藏文件" : "显示隐藏文件",
|
||
icon: hidden ? EyeOff : Eye,
|
||
onSelect: () => patch({ hidden: hidden ? null : "1" }),
|
||
},
|
||
{ key: "sep", separator: true },
|
||
{
|
||
key: "mkdir",
|
||
label: "新建目录",
|
||
icon: FolderPlus,
|
||
disabled: root?.writable ? null : root?.kind === "download" ? "下载目录里不能新建目录" : "只读目录,不能新建",
|
||
onSelect: () => setDialog({ kind: "mkdir" }),
|
||
},
|
||
{
|
||
key: "trash",
|
||
label: `回收站${trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}`,
|
||
icon: Trash2,
|
||
onSelect: () => navigate("/console/files/trash"),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
{/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}
|
||
<h1 className="sr-only">文件管理</h1>
|
||
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0 gap-3 p-3", mobile && "flex-col")}>
|
||
{/* 桌面左栏:与主面板同款的圆角卡片(同背景、边框、圆角),上下与主面板对齐;可折叠、可拖宽,宽度记在本机 */}
|
||
{!mobile ? (
|
||
<aside
|
||
className="relative flex shrink-0"
|
||
style={{ width: treeOpen ? treeW : 52 }}
|
||
aria-label="登记目录与目录树"
|
||
>
|
||
<div className="pg-slab pg-slab-solid flex min-h-0 w-full flex-col overflow-hidden rounded-2xl">
|
||
{treeOpen ? (
|
||
<>
|
||
{/* 头部一行与主面板工具栏第一行等高(同 px-3 py-2.5 + 按钮高度) */}
|
||
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2.5">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="折叠目录树"
|
||
title="折叠目录树"
|
||
onClick={() => setTreeOpenStored(false)}
|
||
>
|
||
<PanelLeftClose />
|
||
</Button>
|
||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">目录</span>
|
||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">{tree}</div>
|
||
</>
|
||
) : (
|
||
<TreeRail
|
||
roots={rootList}
|
||
current={rootId}
|
||
onSelect={go}
|
||
onExpand={() => setTreeOpenStored(true)}
|
||
trashCount={trashCount}
|
||
/>
|
||
)}
|
||
</div>
|
||
{treeOpen ? (
|
||
// biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦
|
||
<div
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="调整目录树宽度"
|
||
title="拖动调整宽度,双击恢复默认"
|
||
aria-valuenow={treeW}
|
||
aria-valuemin={TREE_W_MIN}
|
||
aria-valuemax={TREE_W_MAX}
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||
}}
|
||
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
|
||
onPointerDown={(e) => {
|
||
e.preventDefault();
|
||
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-3 -right-2.5 z-10 w-2 cursor-col-resize rounded-full hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||
/>
|
||
) : null}
|
||
</aside>
|
||
) : null}
|
||
|
||
<section
|
||
className="pg-slab pg-slab-solid relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
|
||
aria-label="目录内容"
|
||
>
|
||
{/* 工具栏 */}
|
||
{mobile ? (
|
||
<div className="space-y-2 border-b border-border px-3 py-2">
|
||
<div className="flex min-w-0 items-center gap-1.5">
|
||
<Button variant="outline" size="icon-sm" aria-label="目录" onClick={() => setTreeSheet(true)}>
|
||
<FolderTree />
|
||
</Button>
|
||
{searchOpen ? (
|
||
<div className="min-w-0 flex-1">{searchBox}</div>
|
||
) : root ? (
|
||
<PathBreadcrumb
|
||
root={root}
|
||
path={path}
|
||
onNavigate={(p) => go({ root: root.id, path: p })}
|
||
className="min-w-0 flex-1"
|
||
/>
|
||
) : (
|
||
<span className="flex-1" />
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={searchOpen ? "收起搜索" : "搜索"}
|
||
onClick={() => {
|
||
setSearchOpen((v) => !v);
|
||
if (!searchOpen) requestAnimationFrame(() => searchRef.current?.focus());
|
||
}}
|
||
>
|
||
{searchOpen ? <X /> : <Search />}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="更多"
|
||
onClick={(ev) => openMenuAt(ev.currentTarget, mobileMore())}
|
||
>
|
||
<MoreHorizontal />
|
||
</Button>
|
||
</div>
|
||
{chips}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2 border-b border-border px-3 py-2.5">
|
||
<div className="flex min-w-0 items-center gap-1.5">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="上一级"
|
||
title="上一级(Alt+↑)"
|
||
disabled={!path}
|
||
onClick={up}
|
||
>
|
||
<ArrowUp />
|
||
</Button>
|
||
{root ? (
|
||
<PathBreadcrumb
|
||
root={root}
|
||
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={async () => {
|
||
// Alist:手动刷新才让 Alist 刷新目录缓存(穿透到网盘接口)
|
||
if (root?.storage) await refreshRemoteDir(root.id, path).catch(() => undefined);
|
||
void roots.refetch();
|
||
void list.refetch();
|
||
}}
|
||
>
|
||
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="快捷键"
|
||
title="快捷键(?)"
|
||
onClick={() => setDialog({ kind: "shortcuts" })}
|
||
>
|
||
<Keyboard />
|
||
</Button>
|
||
<span
|
||
title={
|
||
root?.writable
|
||
? undefined
|
||
: root?.kind === "download"
|
||
? "下载目录里不能新建目录"
|
||
: "只读目录,不能新建"
|
||
}
|
||
>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!root?.writable}
|
||
onClick={() => setDialog({ kind: "mkdir" })}
|
||
>
|
||
<FolderPlus />
|
||
新建目录
|
||
</Button>
|
||
</span>
|
||
{root ? <UploadButton root={root} path={path} /> : null}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{searchBox}
|
||
{chips}
|
||
<div className="ml-auto">{sortControls}</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>
|
||
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
|
||
|
||
{/* 状态栏 */}
|
||
{root && !offline && root.health !== "offline" && !mobile ? (
|
||
<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}
|
||
{/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */}
|
||
{root.usage ? <span>剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
|
||
{!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null}
|
||
</div>
|
||
) : null}
|
||
{!mobile ? actionBar : null}
|
||
</section>
|
||
</div>
|
||
{mobile ? actionBar : null}
|
||
|
||
{/* 手机:登记目录与目录树收进左侧抽屉 */}
|
||
<Sheet open={treeSheet} onOpenChange={setTreeSheet}>
|
||
<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?.items ?? []} onClose={() => setMenu(null)} />
|
||
|
||
{detail && root ? (
|
||
<FileDetail
|
||
root={root}
|
||
entry={detail}
|
||
identifyAuto={identifyAuto}
|
||
onOrganize={(preset) => openWizard([detail], preset)}
|
||
onClose={() => patch({ sel: null, idf: null })}
|
||
actions={
|
||
<>
|
||
{opApplies("organize", detail) ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => openWizard([detail])}
|
||
disabled={!!opDisabledReason("organize", detail, root)}
|
||
>
|
||
<Wand2 />
|
||
整理
|
||
</Button>
|
||
) : null}
|
||
{detail.asset ? (
|
||
<Button size="sm" variant="outline" onClick={() => act("scrape", [detail])}>
|
||
<RefreshCw />
|
||
刷新元数据
|
||
</Button>
|
||
) : null}
|
||
{opApplies("writeNfo", detail) ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => act("writeNfo", [detail])}
|
||
disabled={!!opDisabledReason("writeNfo", detail, root)}
|
||
title={opDisabledReason("writeNfo", detail, root) ?? undefined}
|
||
>
|
||
<FileDown />
|
||
刮削
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => openRename(detail)}
|
||
disabled={!!opDisabledReason("rename", detail, root)}
|
||
title={opDisabledReason("rename", detail, root) ?? undefined}
|
||
>
|
||
<PenLine />
|
||
重命名
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => act("move", [detail])}
|
||
disabled={!!opDisabledReason("move", detail, root)}
|
||
title={opDisabledReason("move", detail, root) ?? undefined}
|
||
>
|
||
<FolderInput />
|
||
移动
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
onClick={() => openDelete([detail])}
|
||
disabled={!!opDisabledReason("delete", detail, root)}
|
||
title={opDisabledReason("delete", detail, root) ?? undefined}
|
||
>
|
||
<Trash2 />
|
||
删除
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
{dialog?.kind === "rename" && root ? (
|
||
<RenameDialog
|
||
root={root}
|
||
entry={dialog.entry}
|
||
siblings={entries.map((e) => e.name)}
|
||
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 === "refresh" ? (
|
||
<RefreshMetadataDialog items={dialog.items} onClose={() => setDialog(null)} />
|
||
) : null}
|
||
{dialog?.kind === "scrape" && root ? (
|
||
<ScrapeDialog items={dialog.items} root={root} onClose={() => setDialog(null)} />
|
||
) : null}
|
||
{dialog?.kind === "wizard" ? <OrganizeWizard source={dialog.source} onClose={() => setDialog(null)} /> : null}
|
||
<ShortcutsDialog open={dialog?.kind === "shortcuts"} onOpenChange={(o) => !o && setDialog(null)} />
|
||
</>
|
||
);
|
||
}
|