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 { 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 { TrashView } from "~/components/files/trash-view.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 { SegmentedControl } from "~/components/ui/segmented.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.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 = { 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 } | { 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 = { 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> = { 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, trashOpen, onTrash, }: { roots: FileRoot[]; current: string; onSelect: (loc: Loc) => void; onExpand: () => void; trashCount: string; trashOpen: boolean; onTrash: () => void; }) { return ( ); } 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"; // 回收站是右侧的一个视图(?view=trash),不再单独一页 const trashView = params.get("view") === "trash"; 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, 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 || trashView || !rootList.length) return; const loc = initialLocation(rootList, readLastLoc()); if (loc) patch({ root: loc.root, path: loc.path || null }, true); }, [rootId, trashView, 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(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(null); const [menu, setMenu] = useState<{ at: MenuAnchor; items: MenuItem[] } | null>(null); const [treeSheet, setTreeSheet] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const dragRefs = useRef([]); const gridRef = useRef(null); const searchRef = useRef(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, view: 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 showTrash = () => { setTreeSheet(false); patch({ view: "trash", sel: null, 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 = (
{roots.isPending ? : null} {roots.error ? void roots.refetch()} /> : null} {rootList.length ? ( ) : null} {roots.data && !rootList.length ? ( ) : null}
); // ── 列表区主体 ── const offline = list.error && isRootOffline(list.error); const filtered = !!(q || filter); let body: React.ReactNode; if (!root) { body = roots.isPending ? : null; } else if (root.health === "offline" || offline) { body = (

{root.name} 离线

{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间文件状态不变。

上次检查:{relTime(root.checkedAt)}

); } else if (list.isPending) { body = ; } else if (list.error && !entries.length) { body = ( void list.refetch()} /> ); } else if (!entries.length) { body = filtered ? ( 清除筛选 } /> ) : ( setDialog({ kind: "mkdir" })}> 新建目录 ) : undefined } /> ); } else { body = ( 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[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 ? (
已选 {selCount} {totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} {!mobile ? ( ) : null}
) : null; // ── 工具栏 ── const chips = (
patch({ f: v === "all" ? null : v })} options={FILTER_CHIPS.map((c) => ({ value: c.value || "all", label: c.label }))} />
); const searchBox = (
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" />
); const sortControls = (
); 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: showTrash, }, ]; return ( <> {/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}

文件管理

{/* 桌面左栏:与主面板同款的圆角卡片(同背景、边框、圆角),上下与主面板对齐;可折叠、可拖宽,宽度记在本机 */} {!mobile ? ( ) : null} {trashView ? (
setTreeSheet(true)}> ) : null } />
) : (
{/* 工具栏 */} {mobile ? (
{searchOpen ? (
{searchBox}
) : root ? ( go({ root: root.id, path: p })} className="min-w-0 flex-1" /> ) : ( )}
{chips}
) : (
{root ? ( go({ root: root.id, path: p })} onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)} className="min-w-0 flex-1" /> ) : ( )} {root ? : null}
{searchBox} {chips}
{sortControls}
)} {first?.truncated ? (
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
) : null}
{body}
{!list.isPending && root ? : null} {/* 状态栏 */} {root && !offline && root.health !== "offline" && !mobile ? (
{first ? `${entries.length.toLocaleString("zh-CN")}/${first.total.toLocaleString("zh-CN")} 项` : "—"} {selCount ? ( 已选 {selCount} {totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} ) : null} {/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */} {root.usage ? 剩余 {formatBytes(root.usage.freeBytes, 1)} : null} {!root.writable && root.kind === "library" ? 只读 : null}
) : null} {!mobile ? actionBar : null}
)}
{mobile && !trashView ? actionBar : null} {/* 手机:登记目录与目录树收进左侧抽屉 */} 目录
{tree}
setMenu(null)} /> {detail && root && !trashView ? ( openWizard([detail], preset)} onClose={() => patch({ sel: null, idf: null })} actions={ <> {opApplies("organize", detail) ? ( ) : null} {detail.asset ? ( ) : null} {opApplies("writeNfo", detail) ? ( ) : null} } /> ) : null} {dialog?.kind === "rename" && root ? ( e.name)} onClose={() => setDialog(null)} /> ) : null} {dialog?.kind === "transfer" ? ( setDialog(null)} /> ) : null} {dialog?.kind === "delete" ? ( setDialog(null)} /> ) : null} {root ? ( !o && setDialog(null)} /> ) : null} {dialog?.kind === "copyPath" && root ? ( setDialog(null)} /> ) : null} {dialog?.kind === "refresh" ? ( setDialog(null)} /> ) : null} {dialog?.kind === "scrape" && root ? ( setDialog(null)} /> ) : null} {dialog?.kind === "wizard" ? setDialog(null)} /> : null} !o && setDialog(null)} /> ); }