Files
UHD_Media_Tool/apps/web/app/routes/console/files.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

1257 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,
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,
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,
} from "~/components/files/dialogs.tsx";
import { DirTree } from "~/components/files/dir-tree.tsx";
import { FileDetail } from "~/components/files/file-detail.tsx";
import { DRAG_MIME, FileList } from "~/components/files/file-list.tsx";
import {
emptySelection,
FILTER_CHIPS,
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 : 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) => `${root.hostPath}${e.path ? `/${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
rootName={root.name}
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
rootName={root.name}
path={path}
onNavigate={(p) => go({ root: root.id, path: p })}
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
className="min-w-0 flex-1"
/>
) : (
<span className="flex-1" />
)}
<Button
variant="ghost"
size="icon-sm"
aria-label="刷新"
title="刷新"
onClick={() => {
void roots.refetch();
void list.refetch();
}}
>
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
</Button>
<Button
variant="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>
</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)} />
</>
);
}