Files
UHD_Media_Tool/apps/web/app/routes/console/files.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

1212 lines
43 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,
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 { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
import {
CopyPathDialog,
DeleteDialog,
MkdirDialog,
RefreshMetadataDialog,
RenameDialog,
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, buttonVariants } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { useSse } from "~/lib/sse.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
const HEALTH_REASON: Record<string, string> = {
root_missing: "挂载点不存在",
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
timeout: "探测超时",
permission_denied: "没有读取权限",
};
const healthReasonText = (r: string | null) => (r ? (HEALTH_REASON[r] ?? r) : "挂载点没有响应");
type Dialog =
| { kind: "rename"; entry: FileEntry }
| { kind: "transfer"; items: FileRef[]; mode: "move" | "copy"; target?: FileRef | null }
| { kind: "delete"; items: FileRef[] }
| { kind: "mkdir" }
| { kind: "copyPath"; entry: FileEntry }
| { kind: "refresh"; items: FileEntry[] }
| { kind: "wizard"; source: OrganizeSource }
| { kind: "shortcuts" };
const TREE_W_KEY = "umt-files-tree-w";
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,
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(260);
useEffect(() => {
const v = Number(readStore(TREE_W_KEY));
if (v >= 200 && v <= 440) setW(v);
}, []);
const save = (v: number) => {
const c = Math.max(200, Math.min(440, 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);
/** 折叠后的左栏:44px 图标条(登记目录 + 回收站) */
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">
<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 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 "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
gridRef={gridRef}
entries={entries}
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={!!list.hasNextPage}
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: "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 (
<>
<PageHeader
title="文件管理"
actions={
mobile ? undefined : (
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
<Trash2 />
回收站{trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}
</Link>
</>
)
}
/>
<div className="flex min-w-0 gap-4">
{/* 桌面左栏:登记目录 + 目录树(可折叠、可拖宽) */}
{!mobile ? (
<aside
className="pg-chrome relative shrink-0 self-start overflow-hidden rounded-2xl"
style={{ width: treeOpen ? treeW : 44 }}
aria-label="登记目录与目录树"
>
{treeOpen ? (
<>
<div className="max-h-[calc(100dvh-11rem)] overflow-y-auto p-2.5">{tree}</div>
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
<div
role="separator"
aria-orientation="vertical"
aria-label="调整目录树宽度"
aria-valuenow={treeW}
aria-valuemin={200}
aria-valuemax={440}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
if (e.key === "ArrowRight") setTreeW(treeW + 16);
}}
onPointerDown={(e) => {
const startX = e.clientX;
const startW = treeW;
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
const upH = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", upH);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", upH);
}}
className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
/>
</>
) : (
<TreeRail
roots={rootList}
current={rootId}
onSelect={go}
onExpand={() => setTreeOpenStored(true)}
trashCount={trashCount}
/>
)}
</aside>
) : null}
<section
className={cn(
"pg-slab relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
mobile ? "h-[calc(100dvh-9.5rem)] min-h-[24rem]" : "h-[calc(100dvh-11rem)] min-h-[28rem]",
)}
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={treeOpen ? "折叠目录树" : "展开目录树"}
title={treeOpen ? "折叠目录树" : "展开目录树"}
onClick={() => setTreeOpenStored(!treeOpen)}
>
{treeOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</Button>
<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>
{/* 状态栏 */}
{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}
<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 === "wizard" ? <OrganizeWizard source={dialog.source} onClose={() => setDialog(null)} /> : null}
<ShortcutsDialog open={dialog?.kind === "shortcuts"} onOpenChange={(o) => !o && setDialog(null)} />
</>
);
}