Files
UHD_Media_Tool/apps/web/app/routes/console/files.tsx
T
rootandClaude Opus 5.5 cf364435eb feat(web): 存储设置与文件管理的 Alist 存储切换
- 设置 → 媒体库与目录 → 存储:登记 Alist / OpenList(令牌 / 账号密码 / 访客、路径密码、限速与监控选项),
  测试连接显示版本、用户与容量;媒体库目录可选存储
- 文件管理左栏多一组「Alist 存储」,路径显示「存储名:/路径」;移动 / 复制目标可选 Alist 存储;
  上传按钮;Alist 根点刷新才让 Alist 刷新目录缓存
- 运行状态的挂载根列出存储实例(令牌失效单独提示)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 23:57:33 +08:00

1264 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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)} />
</>
);
}