feat(web): W5-B file manager page /console/files

Roots + lazy dir tree, virtualized grid with status badges and asset/slot/
download links, breadcrumb, sort, glob search, hidden toggle, filter chips,
paging and truncation notice; multi-select (Shift/Ctrl/all), action bar,
context menu, drag-to-move with dryRun preview, keyboard navigation,
offline (503) and empty/error states, mobile layout with tree drawer.
Rename/move/copy/delete dialogs with dryRun previews and reference checks,
4-step organize wizard, trash view (restore/restore-to/purge, expiry).
DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:16:35 +08:00
1 parent 2e2d56184c
commit 89ea68465e
17 files changed
+6269 -2

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type FileEntry,
type FileRef,
FileRootList,
FileStat,
type FilesDeleteRequest,
FilesDeleteResult,
type FilesIdentifyRequest,
FilesIdentifyResult,
FilesListResponse,
type FilesMkdirRequest,
FilesMkdirResult,
type FilesMoveRequest,
type FilesOrganizeRequest,
FilesOrganizeResult,
type FilesProbeRequest,
FilesProbeResult,
type FilesRenameRequest,
FilesRenameResult,
type FilesScrapeRequest,
FilesScrapeResult,
FilesTransferResult,
FilesTrashList,
type FilesTrashPurgeRequest,
FilesTrashPurgeResult,
type FilesTrashRestoreRequest,
FilesTrashRestoreResult,
} from "@umt/contracts";
import type { z } from "zod";
import type { FetcherArg } from "~/lib/api.ts";
import { ApiRequestError, apiParsed } from "./parse.ts";
// 文件管理(W5-A:/files*;规格 docs/plan/06-file-manager-spec.md §2,形状取 contracts/files.ts)。
// ?mock=1 只在开发构建生效:不连后端,用 components/files/mock.ts 的内存文件树演示交互。
export type {
FileDeleteCheck,
FileEntry,
FileIdentifyCandidate,
FileIdentifyItem,
FileRef,
FileRoot,
FileStatus,
FilesOrganizeGroup,
FilesTransferResult,
TrashItem,
} from "@umt/contracts";
// ─── mock 开关 ───────────────────────────────────────────────────
const MOCK_KEY = "umt-files-mock";
/** 纯函数:开发构建才允许;URL ?mock=1 打开并记在会话里,?mock=0 关闭 */
export function resolveFilesMock(o: {
dev: boolean;
search: string;
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem"> | null;
}) {
if (!o.dev) return false;
const p = new URLSearchParams(o.search);
try {
if (!o.storage) return p.get("mock") === "1";
if (p.get("mock") === "1") o.storage.setItem(MOCK_KEY, "1");
if (p.get("mock") === "0") o.storage.removeItem(MOCK_KEY);
return o.storage.getItem(MOCK_KEY) === "1";
} catch {
return p.get("mock") === "1";
}
}
export function filesMockEnabled(): boolean {
if (typeof window === "undefined") return false;
let storage: Storage | null = null;
try {
storage = window.sessionStorage;
} catch {
storage = null;
}
return resolveFilesMock({ dev: import.meta.env.DEV, search: window.location.search, storage });
}
let mockModule: Promise<typeof import("~/components/files/mock.ts")> | null = null;
const loadMock = () => {
mockModule ??= import("~/components/files/mock.ts");
return mockModule;
};
/** 统一出口:mock 时走内存实现,否则走后端并按合同校验;失败抛 ApiRequestError */
async function call<S extends z.ZodType>(schema: S, path: string, arg: FetcherArg = {}): Promise<z.infer<S>> {
if (filesMockEnabled()) {
const m = await loadMock();
return (await m.mockFiles(path, arg)) as z.infer<S>;
}
return apiParsed(schema, `/api/v1${path}`, arg);
}
const post = <S extends z.ZodType>(schema: S, path: string, body: unknown) =>
call(schema, path, { method: "POST", body });
// ─── 错误 ────────────────────────────────────────────────────────
/** 文件专有原因放在 message 前缀(如 "root_unavailable: …");取出来分流文案 */
export function filesErrorReason(err: unknown): string | null {
if (!(err instanceof ApiRequestError)) return null;
const m = /^([a-z_]+):/.exec(err.message);
return m ? (m[1] as string) : null;
}
/** 根离线 → 显式离线态(不当作空目录) */
export const isRootOffline = (err: unknown) =>
filesErrorReason(err) === "root_unavailable" ||
(err instanceof ApiRequestError && err.code === "upstream_unavailable");
/** 去掉原因前缀,给人看的部分 */
export function filesErrorText(err: unknown, fallback = "操作失败"): string {
if (!(err instanceof Error) || !err.message) return fallback;
return err.message.replace(/^[a-z_]+:\s*/, "");
}
// ─── 查询 ────────────────────────────────────────────────────────
export const filesKeys = {
all: ["files"] as const,
roots: ["files", "roots"] as const,
list: (q: ListQuery) => ["files", "list", q.root, q.path, q] as const,
tree: (root: string, path: string) => ["files", "tree", root, path] as const,
stat: (root: string, path: string) => ["files", "stat", root, path] as const,
trash: ["files", "trash"] as const,
};
export function useFileRoots() {
return useQuery({
queryKey: filesKeys.roots,
queryFn: ({ signal }) => call(FileRootList, "/files/roots", { signal }).then((r) => r.items),
staleTime: 30_000,
});
}
export type ListQuery = {
root: string;
path: string;
sort?: "name" | "mtime" | "size" | "status";
order?: "asc" | "desc";
q?: string;
filter?: string;
hidden?: boolean;
};
export const PAGE_SIZE = 200;
export function useFileList(q: ListQuery | null) {
return useInfiniteQuery({
queryKey: filesKeys.list(q ?? { root: "", path: "" }),
enabled: !!q?.root,
initialPageParam: undefined as string | undefined,
placeholderData: (prev, prevQuery) =>
// 同一目录内换排序/过滤保留旧数据防闪;换目录不保留(避免把上一个目录的内容当成当前目录)
prevQuery && prevQuery.queryKey[2] === q?.root && prevQuery.queryKey[3] === q?.path
? keepPreviousData(prev)
: undefined,
retry: (n, err) => !(err instanceof ApiRequestError) && n < 2,
queryFn: ({ pageParam, signal }) =>
call(FilesListResponse, "/files", {
params: {
root: q?.root,
path: q?.path ?? "",
sort: q?.sort,
order: q?.order,
q: q?.q || undefined,
filter: q?.filter || undefined,
hidden: q?.hidden ? "true" : undefined,
cursor: pageParam,
limit: PAGE_SIZE,
},
signal,
}),
getNextPageParam: (last: FilesListResponse) => last.nextCursor ?? undefined,
});
}
/** 目录树懒加载:只取子目录(按名称),取前 1000 项 */
export function useDirChildren(root: string, path: string, enabled: boolean) {
return useQuery({
queryKey: filesKeys.tree(root, path),
enabled: enabled && !!root,
staleTime: 30_000,
retry: false,
queryFn: ({ signal }) =>
call(FilesListResponse, "/files", {
params: { root, path, sort: "name", order: "asc", limit: 1000 },
signal,
}).then((r) => ({ dirs: r.entries.filter((e) => e.type === "dir"), truncated: r.truncated || !!r.nextCursor })),
});
}
export function useFileStat(root: string | null, path: string | null) {
return useQuery({
queryKey: filesKeys.stat(root ?? "", path ?? ""),
enabled: !!root && path != null,
retry: false,
queryFn: ({ signal }) => call(FileStat, "/files/stat", { params: { root, path }, signal }),
});
}
export function useTrash(enabled = true) {
return useInfiniteQuery({
queryKey: filesKeys.trash,
enabled,
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
call(FilesTrashList, "/files/trash", { params: { cursor: pageParam, limit: 100 }, signal }),
getNextPageParam: (last: FilesTrashList) => last.nextCursor ?? undefined,
});
}
/** 非媒体小文件(NFO/字幕/图片 ≤ 20 MB)下载与预览地址 */
export function fileDownloadUrl(root: string, path: string): string {
const qs = new URLSearchParams({ root, path });
return `/api/v1/files/download?${qs}`;
}
export function useTextPreview(root: string | null, path: string | null, enabled: boolean) {
return useQuery({
queryKey: ["files", "preview", root, path],
enabled: enabled && !!root && !!path,
retry: false,
queryFn: async ({ signal }) => {
if (filesMockEnabled()) return (await loadMock()).mockPreview(root ?? "", path ?? "");
const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal });
if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`);
const text = await res.text();
return text.length > 64_000 ? `${text.slice(0, 64_000)}\n…(已截断)` : text;
},
});
}
// ─── 变更 ────────────────────────────────────────────────────────
function useFilesMutation<B, T>(fn: (b: B) => Promise<T>, opts: { invalidate?: (b: B) => boolean } = {}) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: (_d, b) => {
if (opts.invalidate?.(b) ?? true) void qc.invalidateQueries({ queryKey: filesKeys.all });
},
});
}
const notDry = (b: { dryRun?: boolean }) => b.dryRun === false;
export const useMkdir = () => useFilesMutation((b: FilesMkdirRequest) => post(FilesMkdirResult, "/files/mkdir", b));
export const useRename = () =>
useFilesMutation((b: FilesRenameRequest) => post(FilesRenameResult, "/files/rename", b), { invalidate: notDry });
export const useMove = () =>
useFilesMutation((b: FilesMoveRequest) => post(FilesTransferResult, "/files/move", b), { invalidate: notDry });
export const useDelete = () =>
useFilesMutation((b: FilesDeleteRequest) => post(FilesDeleteResult, "/files/delete", b), { invalidate: notDry });
export const useRestore = () =>
useFilesMutation((b: FilesTrashRestoreRequest) => post(FilesTrashRestoreResult, "/files/trash/restore", b));
export const usePurge = () =>
useFilesMutation((b: FilesTrashPurgeRequest) => post(FilesTrashPurgeResult, "/files/trash/purge", b));
export const useIdentify = () =>
useMutation({ mutationFn: (b: FilesIdentifyRequest) => post(FilesIdentifyResult, "/files/identify", b) });
export const useOrganizeFiles = () =>
useFilesMutation((b: FilesOrganizeRequest) => post(FilesOrganizeResult, "/files/organize", b), {
invalidate: notDry,
});
export const useScrape = () =>
useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) });
export const useProbe = () => useFilesMutation((b: FilesProbeRequest) => post(FilesProbeResult, "/files/probe", b));
/** 行的唯一键:根 + 相对路径 */
export const refKey = (r: FileRef) => `${r.root}\u0000${r.path}`;
export const entryRef = (root: string, e: Pick<FileEntry, "path">): FileRef => ({ root, path: e.path });
+2 -2
View File
@@ -3,8 +3,8 @@ import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts";
describe("控制台导航", () => { describe("控制台导航", () => {
it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => { it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => {
expect(NAV_ITEMS).toHaveLength(14); expect(NAV_ITEMS).toHaveLength(15);
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(14); expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(15);
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
}); });
+7
View File
@@ -5,6 +5,7 @@ import {
Download, Download,
FileStack, FileStack,
Fingerprint, Fingerprint,
FolderTree,
Globe, Globe,
House, House,
Inbox, Inbox,
@@ -63,6 +64,12 @@ export const NAV_GROUPS: NavGroup[] = [
icon: FileStack, icon: FileStack,
description: "Copy/Move 入库前后路径与逐文件结果", description: "Copy/Move 入库前后路径与逐文件结果",
}, },
{
to: "/console/files",
label: "文件",
icon: FolderTree,
description: "浏览已登记的库根与下载目录,改名、移动、删除与批量整理",
},
], ],
}, },
{ {
@@ -0,0 +1,157 @@
import type { LucideIcon } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "~/lib/utils.ts";
export type MenuItem =
| {
key: string;
label: string;
icon?: LucideIcon;
/** 快捷键提示 */
hint?: string;
/** 禁用原因(显示为 title,并在行内小字说明) */
disabled?: string | null;
destructive?: boolean;
onSelect: () => void;
}
| { key: string; separator: true };
export type MenuAnchor = { x: number; y: number };
/**
* 右键菜单 / 行 ⋯ 菜单共用的浮层(pg-float)。按坐标定位,越界自动翻转;
* 点外面、Esc、滚动、失焦都会关闭;↑↓ 在可用项间移动,Enter 执行。
*/
export function ContextMenu({
anchor,
items,
onClose,
label = "操作",
}: {
anchor: MenuAnchor | null;
items: MenuItem[];
onClose: () => void;
label?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const [active, setActive] = useState(-1);
useLayoutEffect(() => {
if (!anchor || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const left = anchor.x + r.width > vw - 8 ? Math.max(8, anchor.x - r.width) : anchor.x;
const top = anchor.y + r.height > vh - 8 ? Math.max(8, anchor.y - r.height) : anchor.y;
setPos({ left, top });
ref.current.focus();
}, [anchor]);
useEffect(() => {
if (!anchor) return;
const close = (e: Event) => {
if (e.type === "mousedown" && ref.current?.contains(e.target as Node)) return;
onClose();
};
window.addEventListener("mousedown", close);
window.addEventListener("resize", close);
window.addEventListener("blur", close);
document.addEventListener("scroll", close, true);
return () => {
window.removeEventListener("mousedown", close);
window.removeEventListener("resize", close);
window.removeEventListener("blur", close);
document.removeEventListener("scroll", close, true);
};
}, [anchor, onClose]);
useEffect(() => {
if (!anchor) {
setPos(null);
setActive(-1);
}
}, [anchor]);
if (!anchor || typeof document === "undefined") return null;
const actionable = items
.map((it, i) => ({ it, i }))
.filter(({ it }) => !("separator" in it) && !it.disabled)
.map(({ i }) => i);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!actionable.length) return;
const cur = actionable.indexOf(active);
const next =
e.key === "ArrowDown"
? actionable[(cur + 1) % actionable.length]
: actionable[(cur - 1 + actionable.length) % actionable.length];
setActive(next ?? -1);
} else if (e.key === "Enter" && active >= 0) {
e.preventDefault();
const it = items[active];
if (it && !("separator" in it) && !it.disabled) {
onClose();
it.onSelect();
}
}
};
return createPortal(
<div
ref={ref}
role="menu"
aria-label={label}
tabIndex={-1}
onKeyDown={onKeyDown}
onContextMenu={(e) => e.preventDefault()}
style={{ left: pos?.left ?? anchor.x, top: pos?.top ?? anchor.y, visibility: pos ? "visible" : "hidden" }}
className="pg-float fixed z-50 min-w-52 max-w-72 animate-pg-pop rounded-2xl p-1.5 outline-none"
>
{items.map((it, i) =>
"separator" in it ? (
<hr key={it.key} className="my-1 border-0 border-t border-border" />
) : (
<button
key={it.key}
type="button"
role="menuitem"
aria-disabled={!!it.disabled}
title={it.disabled ?? undefined}
onMouseEnter={() => setActive(i)}
onClick={() => {
if (it.disabled) return;
onClose();
it.onSelect();
}}
className={cn(
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-body transition-colors duration-micro",
it.disabled
? "cursor-not-allowed opacity-50"
: it.destructive
? "text-foreground hover:bg-destructive/10 hover:text-destructive"
: "text-foreground hover:bg-foreground/[0.06]",
active === i &&
!it.disabled &&
(it.destructive ? "bg-destructive/10 text-destructive" : "bg-foreground/[0.06]"),
)}
>
{it.icon ? <it.icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> : <span className="w-4" />}
<span className="min-w-0 flex-1">
<span className="block truncate">{it.label}</span>
{it.disabled ? <span className="block text-meta text-muted-foreground">{it.disabled}</span> : null}
</span>
{it.hint ? <kbd className="mt-0.5 font-mono text-meta text-muted-foreground">{it.hint}</kbd> : null}
</button>
),
)}
</div>,
document.body,
);
}
+935
View File
@@ -0,0 +1,935 @@
import {
ArrowRight,
Copy,
FolderInput,
FolderPlus,
LoaderCircle,
PenLine,
Sparkles,
Trash2,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileRef,
type FileRoot,
type FilesTransferResult,
filesErrorReason,
filesErrorText,
useDelete,
useFileStat,
useIdentify,
useMkdir,
useMove,
useRename,
useScrape,
} from "~/api/files.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { DirTree } from "./dir-tree.tsx";
import {
baseName,
deleteBlockText,
nameProblem,
newIdempotencyKey,
splitCommon,
stemRange,
suggestName,
} from "./model.ts";
// ─── 共用 ───────────────────────────────────────────────────────
/** 前后路径对照:公共前缀淡显,差异部分高亮 */
export function PathPair({ from, to, tone }: { from: string; to: string | null; tone?: "conflict" | "skip" }) {
if (!to) return <p className="font-mono text-meta break-all text-muted-foreground">{from}</p>;
const s = splitCommon(from, to);
return (
<div className="min-w-0 font-mono text-meta leading-relaxed">
<p className="break-all text-muted-foreground">
<span className="opacity-60">{s.common}</span>
{s.a}
</p>
<p className={cn("flex gap-1 break-all", tone === "conflict" ? "text-destructive" : "text-foreground")}>
<ArrowRight aria-hidden className="mt-0.5 h-3 w-3 shrink-0" />
<span>
<span className="opacity-60">{s.common}</span>
<mark className="rounded-tight bg-foreground/[0.08] px-0.5 text-inherit">{s.b}</mark>
</span>
</p>
</div>
);
}
function Notice({ tone, children }: { tone: "warning" | "destructive" | "neutral"; children: React.ReactNode }) {
return (
<div
role={tone === "neutral" ? undefined : "alert"}
className={cn(
"flex gap-2 rounded-xl border px-3 py-2 text-caption",
tone === "destructive" && "border-destructive/35 bg-destructive/10 text-destructive",
tone === "warning" && "border-warning/35 bg-warning/10 text-warning",
tone === "neutral" && "border-border bg-foreground/[0.03] text-muted-foreground",
)}
>
{tone !== "neutral" ? <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /> : null}
<div className="min-w-0">{children}</div>
</div>
);
}
function useDebounced<T>(v: T, ms: number): T {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
// ─── 新建目录 ───────────────────────────────────────────────────
export function MkdirDialog({
root,
path,
open,
onOpenChange,
onCreated,
}: {
root: FileRoot;
path: string;
open: boolean;
onOpenChange: (o: boolean) => void;
onCreated?: (path: string) => void;
}) {
const [name, setName] = useState("");
const [err, setErr] = useState<string | null>(null);
const mkdir = useMkdir();
const key = useMemo(() => (open ? newIdempotencyKey() : ""), [open]);
const submit = (ev: React.FormEvent) => {
ev.preventDefault();
const p = nameProblem(name);
if (p) return setErr(p);
mkdir.mutate(
{ idempotencyKey: key, root: root.id, path, name: name.trim() },
{
onSuccess: (r) => {
toast.success(`已新建目录 ${name.trim()}`);
setName("");
onOpenChange(false);
onCreated?.(r.path);
},
onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名项已存在" : filesErrorText(e)),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={submit} className="grid gap-4">
<DialogHeader>
<DialogTitle>新建目录</DialogTitle>
<DialogDescription className="font-mono break-all">
{root.hostPath}
{path ? `/${path}` : ""}/
</DialogDescription>
</DialogHeader>
<FormField label="目录名" htmlFor="mkdir-name" error={err}>
<Input
id="mkdir-name"
autoFocus
value={name}
aria-invalid={!!err}
onChange={(e) => {
setName(e.target.value);
setErr(null);
}}
/>
</FormField>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={mkdir.isPending || !name.trim()}>
{mkdir.isPending ? <LoaderCircle className="animate-spin" /> : <FolderPlus />}
新建
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ─── 重命名 ─────────────────────────────────────────────────────
export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
const isDir = entry.type === "dir";
const [name, setName] = useState(entry.name);
const [withSidecars, setWithSidecars] = useState(true);
const [err, setErr] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const key = useMemo(() => newIdempotencyKey(), []);
const preview = useRename();
const exec = useRename();
const identify = useIdentify();
const debounced = useDebounced(name.trim(), 350);
const problem = nameProblem(name);
const changed = name.trim() !== entry.name;
useEffect(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
const [a, b] = stemRange(entry.name, isDir);
el.setSelectionRange(a, b);
}, [entry.name, isDir]);
// 输入停顿后 dryRun:列出将一起改名的 sidecars 与资产/绑定同步
const previewMutate = preview.mutate;
useEffect(() => {
if (!debounced || debounced === entry.name || nameProblem(debounced)) return;
previewMutate({
idempotencyKey: `${key}-preview`,
root: root.id,
path: entry.path,
newName: debounced,
withSidecars,
dryRun: true,
});
}, [debounced, withSidecars, entry.name, entry.path, key, root.id, previewMutate]);
const suggest = () =>
identify.mutate(
{ items: [{ root: root.id, path: entry.path }] },
{
onSuccess: (r) => {
const it = r.items[0];
const c = it?.candidates[0];
if (!it || !c) return toast.info("没有可信的识别结果,无法给出建议名");
setName(suggestName(c, it.parsed, entry.name, isDir));
setErr(null);
},
onError: (e) => toast.error(filesErrorText(e, "识别失败")),
},
);
const submit = (ev: React.FormEvent) => {
ev.preventDefault();
if (problem) return setErr(problem);
if (!changed) return onClose();
exec.mutate(
{ idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false },
{
onSuccess: (r) => {
toast.success(
`已改名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
r.recordId
? {
action: {
label: "整理记录",
onClick: () => window.location.assign(`/console/organize/${r.recordId}`),
},
}
: undefined,
);
onClose();
},
onError: (e) => {
const reason = filesErrorReason(e);
setErr(
reason === "exists"
? "目标名已存在,不会覆盖"
: reason === "download_active"
? "做种中,改名会让下载器找不到文件"
: filesErrorText(e),
);
},
},
);
};
const pv = preview.data && preview.variables?.newName === debounced ? preview.data : null;
const pvErr = preview.error && preview.variables?.newName === debounced ? preview.error : null;
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-xl">
<form onSubmit={submit} className="grid min-w-0 gap-4">
<DialogHeader>
<DialogTitle>重命名</DialogTitle>
<DialogDescription className="break-all">{entry.name}</DialogDescription>
</DialogHeader>
<FormField label="新名称" htmlFor="rename-name" error={err ?? (problem && changed ? problem : null)}>
<div className="flex gap-2">
<Input
id="rename-name"
ref={inputRef}
value={name}
aria-invalid={!!err}
onChange={(e) => {
setName(e.target.value);
setErr(null);
}}
className="font-mono"
/>
{entry.fileKind === "video" || isDir ? (
<Button type="button" variant="outline" onClick={suggest} disabled={identify.isPending}>
{identify.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
建议名
</Button>
) : null}
</div>
</FormField>
{!isDir ? (
<div className="flex items-center justify-between gap-3 text-caption">
<span>
同名附属文件一起改
<span className="block text-meta text-muted-foreground">同 stem 的字幕 / NFO / 图片</span>
</span>
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" />
</div>
) : null}
{changed && !problem ? (
<div className="space-y-2">
{preview.isPending ? (
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<LoaderCircle className="h-3 w-3 animate-spin" /> 预览中
</p>
) : null}
{pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null}
{pv ? (
<>
<ul className="max-h-48 space-y-1.5 overflow-auto">
{pv.renames.map((r) => (
<li key={r.from} className="rounded-lg border border-border/60 px-2.5 py-1.5">
<div className="mb-0.5">
<StatusBadge>{r.role === "main" ? "本体" : "附属"}</StatusBadge>
</div>
<PathPair from={r.from} to={r.to} />
</li>
))}
</ul>
{pv.assetLocations > 0 ? (
<Notice tone="neutral">
将同步 {pv.assetLocations} 条资产位置,并写一条整理记录(action=rename)。
</Notice>
) : null}
{pv.bindings.length > 0 ? (
<Notice tone="warning">
这是作品/季的绑定目录:将新建绑定并把旧绑定标记为已替代,之后新集入库进入新目录。
</Notice>
) : null}
</>
) : null}
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
取消
</Button>
<Button type="submit" disabled={exec.isPending || !changed || !!problem}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : <PenLine />}
重命名
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ─── 移动 / 复制到… ─────────────────────────────────────────────
export function TransferDialog({
roots,
items,
initialMode,
initialTarget,
onClose,
}: {
roots: FileRoot[];
items: FileRef[];
initialMode: "move" | "copy";
initialTarget?: FileRef | null;
onClose: () => void;
}) {
const [mode, setMode] = useState(initialMode);
const [target, setTarget] = useState<FileRef | null>(initialTarget ?? null);
const [plan, setPlan] = useState<FilesTransferResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const key = useMemo(() => newIdempotencyKey(), []);
const dry = useMove();
const exec = useMove();
const rootById = new Map(roots.map((r) => [r.id, r]));
const targetRoot = target ? rootById.get(target.root) : undefined;
const runPreview = (t: FileRef, m: "move" | "copy") => {
setPlan(null);
setErr(null);
dry.mutate(
{ idempotencyKey: `${key}-dry-${m}-${t.root}-${t.path}`, items, target: t, mode: m, dryRun: true },
{ onSuccess: setPlan, onError: (e) => setErr(filesErrorText(e, "预览失败")) },
);
};
// 拖拽进来时目标已定:直接出预览
const dryMutate = dry.mutate;
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开时跑一次
useEffect(() => {
if (initialTarget) runPreview(initialTarget, initialMode);
}, [dryMutate]);
const conflicts = plan?.pairs.filter((p) => p.conflict) ?? [];
const hnrBlocked = mode === "move" && (plan?.hnrBlocks.length ?? 0) > 0;
const canRun = !!plan && !hnrBlocked && plan.spaceOk && conflicts.length < plan.pairs.length;
const run = () => {
if (!plan || !target) return;
exec.mutate(
{ idempotencyKey: key, items, target, mode, dryRun: false, planId: plan.planId },
{
onSuccess: (r) => {
const ok = r.executed?.succeeded.length ?? 0;
const failed = r.executed?.failed ?? [];
const recordId = r.executed?.recordId;
const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`;
(failed.length ? toast.warning : toast.success)(msg, {
description: failed
.slice(0, 3)
.map((f) => `${baseName(f.path)}:${f.message}`)
.join("\n"),
action: recordId
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
: undefined,
});
onClose();
},
onError: (e) => {
const reason = filesErrorReason(e);
if (reason === "plan_stale") {
setErr("源文件在预览后发生了变化,已重新生成预览,请再确认一次。");
runPreview(target, mode);
} else setErr(filesErrorText(e));
},
},
);
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
<DialogDescription>
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} ·
先预览前后路径、冲突与空间,确认后才执行; 统一走可恢复的整理计划,跨卷为 Copy → 核验 → 删源。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
<div className="min-h-0 space-y-3">
<div className="flex gap-1.5">
{(["move", "copy"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => {
setMode(m);
if (target) runPreview(target, m);
}}
className={cn(
"flex-1 rounded-full border px-3 py-1.5 text-caption transition-colors duration-micro",
mode === m
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{m === "move" ? "移动" : "复制"}
</button>
))}
</div>
<div className="max-h-72 overflow-y-auto rounded-xl border border-border p-1.5 md:max-h-96">
<DirTree
roots={roots}
current={target}
pickOnly
defaultOpen={roots.filter((r) => r.writable && r.health !== "offline").map((r) => r.id)}
onSelect={(loc) => {
setTarget(loc);
runPreview(loc, mode);
}}
/>
</div>
<p className="text-meta text-muted-foreground">只有可写的媒体库根能作为目标。</p>
</div>
<div className="min-w-0 space-y-3">
{target && targetRoot ? (
<div className="rounded-xl border border-border px-3 py-2">
<p className="text-meta text-muted-foreground">目标目录</p>
<p className="font-mono text-caption break-all">
{targetRoot.hostPath}
{target.path ? `/${target.path}` : ""}
</p>
</div>
) : (
<Notice tone="neutral">在左侧选择目标目录。</Notice>
)}
{dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览…
</p>
) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null}
{plan ? (
<>
<div className="flex flex-wrap gap-1.5">
<StatusBadge>
{plan.pairs.length} 项 · {formatBytes(plan.totalBytes, 1)}
</StatusBadge>
<StatusBadge tone={plan.spaceOk ? "success" : "destructive"}>
{plan.spaceOk ? "空间足够" : "空间不足"}
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
</StatusBadge>
{conflicts.length ? (
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
) : null}
{plan.pairs.some((p) => p.registered) ? <StatusBadge>含已登记资产,执行后同步位置</StatusBadge> : null}
</div>
{hnrBlocked ? (
<Notice tone="destructive">
{plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能 Move。可改用“复制”,或等 H&R 满足后再移动。
<button
type="button"
className="ml-1 underline"
onClick={() => {
if (!target) return;
setMode("copy");
runPreview(target, "copy");
}}
>
改为复制
</button>
</Notice>
) : null}
{plan.truncated ? <Notice tone="warning">源文件过多,预览只列出前一部分;执行覆盖全部。</Notice> : null}
<ul className="space-y-1.5">
{plan.pairs.map((p) => {
const sr = rootById.get(p.source.root);
const tr = rootById.get(p.target.root);
return (
<li
key={`${p.source.root}:${p.source.path}`}
className="rounded-lg border border-border/60 px-2.5 py-1.5"
>
<div className="mb-1 flex flex-wrap items-center gap-1.5">
{p.conflict ? (
<StatusBadge tone="warning">目标已存在 · 跳过</StatusBadge>
) : (
<StatusBadge tone="success">{mode === "move" ? "移动" : "复制"}</StatusBadge>
)}
<span className="text-meta text-muted-foreground">{formatBytes(p.size, 1)}</span>
</div>
<PathPair
from={`${sr?.hostPath ?? p.source.root}/${p.source.path}`}
to={`${tr?.hostPath ?? p.target.root}/${p.target.path}`}
tone={p.conflict ? "conflict" : undefined}
/>
</li>
);
})}
</ul>
</>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button onClick={run} disabled={!canRun || exec.isPending}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : mode === "move" ? <FolderInput /> : <Copy />}
确认{mode === "move" ? "移动" : "复制"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 删除 ───────────────────────────────────────────────────────
export function DeleteDialog({
roots,
items,
isAdmin,
onClose,
}: {
roots: FileRoot[];
items: FileRef[];
isAdmin: boolean;
onClose: () => void;
}) {
const [retire, setRetire] = useState(false);
const [unbind, setUnbind] = useState(false);
const [mode, setMode] = useState<"trash" | "permanent">("trash");
const [confirmText, setConfirmText] = useState("");
const [err, setErr] = useState<string | null>(null);
const key = useMemo(() => newIdempotencyKey(), []);
const check = useDelete();
const exec = useDelete();
const rootById = new Map(roots.map((r) => [r.id, r]));
const checkMutate = check.mutate;
useEffect(() => {
checkMutate(
{ idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false },
{ onError: (e) => setErr(filesErrorText(e, "引用检查失败")) },
);
}, [checkMutate, items, key]);
const checks = check.data?.checks ?? [];
const hasAssets = checks.some((c) => c.assets.length > 0);
const hasBindings = checks.some((c) => c.bindings.length > 0);
const effective = (b: string | null) =>
b === "needs_retire" && retire ? null : b === "needs_unbind" && unbind ? null : b;
const hardBlocked = checks.filter((c) => {
const b = effective(c.blocked);
return b && !deleteBlockText(b)?.soft;
});
const softPending = checks.filter((c) => {
const b = effective(c.blocked);
return b && deleteBlockText(b)?.soft;
});
const single = items.length === 1 ? baseName(items[0]?.path ?? "") : null;
const confirmPhrase = single ?? `永久删除 ${items.length} 项`;
const permanentOk = mode !== "permanent" || confirmText.trim() === confirmPhrase;
const deletable = checks.length - hardBlocked.length;
const canRun = !!check.data && hardBlocked.length === 0 && softPending.length === 0 && permanentOk;
const run = () =>
exec.mutate(
{
idempotencyKey: `${key}-${mode}`,
items,
mode,
dryRun: false,
retireAssets: retire,
unbind,
confirmName: mode === "permanent" && single ? confirmText.trim() : undefined,
planId: check.data?.planId ?? undefined,
},
{
onSuccess: (r) => {
const n = mode === "trash" ? r.trashed.length : r.deleted.length;
(r.failed.length ? toast.warning : toast.success)(
mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`,
{
description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined,
action:
mode === "trash"
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
: undefined,
},
);
onClose();
},
onError: (e) => {
const reason = filesErrorReason(e);
setErr(
reason === "permanent_disabled"
? "系统设置未开启永久删除(files.allowPermanentDelete)"
: reason === "approval_required"
? "需要审批:API Key 删除每次都要一次性审批"
: filesErrorText(e),
);
},
},
);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogDescription>
删除前已做引用检查:做种任务、已登记资产、路径绑定。删除不会动下载器里的任务。
</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
{check.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 引用检查中…
</p>
) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null}
{checks.length ? (
<ul className="space-y-1.5">
{checks.map((c) => {
const b = deleteBlockText(effective(c.blocked));
const r = rootById.get(c.root);
return (
<li key={`${c.root}:${c.path}`} className="rounded-lg border border-border/60 px-2.5 py-2">
<div className="flex flex-wrap items-center gap-1.5">
{b ? (
<StatusBadge tone={b.soft ? "warning" : "destructive"}>
{b.soft ? "需确认" : "不能删除"}
</StatusBadge>
) : (
<StatusBadge tone="success">可删除</StatusBadge>
)}
<span className="min-w-0 truncate text-caption text-foreground">
{baseName(c.path) || r?.name}
</span>
<span className="text-meta text-muted-foreground">
{c.isDir ? "目录 · " : ""}
{formatBytes(c.size, 1)}
</span>
</div>
{b ? (
<p className={cn("mt-1 text-meta", b.soft ? "text-warning" : "text-destructive")}>{b.text}</p>
) : null}
<div className="mt-1 space-y-0.5 text-meta text-muted-foreground">
{c.downloads.map((d) => (
<p key={d.taskId}>
下载任务{" "}
<Link className="underline hover:text-foreground" to={`/console/tasks/${d.taskId}`}>
{d.taskId.slice(0, 8)}
</Link>{" "}
· {d.status} · H&R{" "}
{d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}
</p>
))}
{c.assets.length ? (
<p>已登记资产 {c.assets.length} 个(删除后退役,满足的 Slot 退回待处理)</p>
) : null}
{c.bindings.map((bd) => (
<p key={bd.bindingId}>
路径绑定:{bd.title}
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
</p>
))}
{c.sharedInode ? <p>共享 inode(硬链接):只删除这一处链接,其他位置的文件不受影响</p> : null}
</div>
</li>
);
})}
</ul>
) : null}
{hasAssets ? (
<label className="flex items-start gap-2.5 text-caption">
<input type="checkbox" className="mt-1" checked={retire} onChange={(e) => setRetire(e.target.checked)} />
<span>
同时退役资产
<span className="block text-meta text-muted-foreground">
资产位置标为已移除、资产退役;满足的 Slot 退回待处理并建待处理事项。
</span>
</span>
</label>
) : null}
{hasBindings ? (
<label className="flex items-start gap-2.5 text-caption">
<input type="checkbox" className="mt-1" checked={unbind} onChange={(e) => setUnbind(e.target.checked)} />
<span>
解除路径绑定
<span className="block text-meta text-muted-foreground">
绑定标为已替代;之后该作品入库会重新选择目录。
</span>
</span>
</label>
) : null}
{checks.length && deletable > 0 ? (
<div className="space-y-2 rounded-xl border border-border p-3">
<label className="flex items-start gap-2.5 text-caption">
<input
type="radio"
name="del-mode"
className="mt-1"
checked={mode === "trash"}
onChange={() => setMode("trash")}
/>
<span>
移入回收站
<span className="block text-meta text-muted-foreground">保留 7 天,期间可在回收站还原到原位置。</span>
</span>
</label>
<label
className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")}
title={isAdmin ? undefined : "仅管理员会话可永久删除"}
>
<input
type="radio"
name="del-mode"
className="mt-1"
disabled={!isAdmin}
checked={mode === "permanent"}
onChange={() => setMode("permanent")}
/>
<span>
永久删除
<span className="block text-meta text-muted-foreground">
{isAdmin ? "不可恢复;需在系统设置开启后才可用。" : "仅管理员会话可用。"}
</span>
</span>
</label>
{mode === "permanent" ? (
<FormField
label={
<>
输入 <span className="font-mono">{confirmPhrase}</span> 以确认
</>
}
htmlFor="del-confirm"
>
<Input
id="del-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="font-mono"
autoComplete="off"
/>
</FormField>
) : null}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button variant="destructive" onClick={run} disabled={!canRun || exec.isPending}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
{mode === "trash" ? "移入回收站" : "永久删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 复制路径 ───────────────────────────────────────────────────
export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
const stat = useFileStat(root.id, entry.path);
const local = `${root.hostPath}${entry.path ? `/${entry.path}` : ""}`;
const rows = [
{ label: "本机路径", value: local },
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
{ label: "根内相对路径", value: entry.path || "(根)" },
];
const copy = async (v: string) => {
try {
await navigator.clipboard.writeText(v);
toast.success("已复制");
} catch {
toast.error("浏览器不允许写剪贴板");
}
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>复制路径</DialogTitle>
<DialogDescription className="break-all">{entry.name}</DialogDescription>
</DialogHeader>
<ul className="space-y-2">
{rows.map((r) => (
<li key={r.label} className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-meta text-muted-foreground">{r.label}</p>
<p className="font-mono text-caption break-all">{r.value ?? (stat.isPending ? "…" : "—")}</p>
</div>
{r.value ? (
<Button
variant="outline"
size="icon-sm"
aria-label={`复制${r.label}`}
onClick={() => copy(r.value as string)}
>
<Copy />
</Button>
) : null}
</li>
))}
</ul>
<p className="text-meta text-muted-foreground">
下载器与媒体服务器视角的路径按系统设置里的路径映射换算,见“系统设置 → 路径映射”。
</p>
</DialogContent>
</Dialog>
);
}
// ─── 刮削 ───────────────────────────────────────────────────────
export function ScrapeDialog({ entry, onClose }: { entry: FileEntry; onClose: () => void }) {
const [writeNfo, setWriteNfo] = useState(false);
const [images, setImages] = useState(false);
const scrape = useScrape();
const key = useMemo(() => newIdempotencyKey(), []);
const asset = entry.asset;
if (!asset) return null;
const run = () =>
scrape.mutate(
{ idempotencyKey: key, mediaEntityId: asset.mediaEntityId, writeNfo, images },
{
onSuccess: (r) => {
const extra =
r.nfo === "not_implemented" || r.images === "not_implemented"
? "NFO/图片投影尚未实现,本次只刷新了字段。"
: r.nfo === "skipped"
? "目标 NFO 不是本系统生成且字段已锁定,未覆盖。"
: "";
toast.success(`已提交元数据刷新${r.lockedFields.length ? `,${r.lockedFields.length} 个锁定字段不动` : ""}`, {
description: extra || undefined,
action: { label: "查看运行", onClick: () => window.location.assign(`/console/engine/${r.taskId}`) },
});
onClose();
},
onError: (e) => toast.error(filesErrorText(e, "刮削失败")),
},
);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>刮削 {asset.title}</DialogTitle>
<DialogDescription>
字段级刷新元数据:用户锁定的字段不动,不改路径。非本系统生成的 NFO 不会被覆盖。
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between gap-3 text-caption">
写 NFO 到绑定目录
<Switch checked={writeNfo} onCheckedChange={setWriteNfo} aria-label="写 NFO 到绑定目录" />
</div>
<div className="flex items-center justify-between gap-3 text-caption">
下载海报/背景图
<Switch checked={images} onCheckedChange={setImages} aria-label="下载海报/背景图" />
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button onClick={run} disabled={scrape.isPending}>
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
刮削
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+352
View File
@@ -0,0 +1,352 @@
import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { healthLabel } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { joinRel, segments } from "./model.ts";
type Loc = { root: string; path: string };
type DropHandler = (target: Loc, e: React.DragEvent) => void;
const nodeKey = (root: string, path: string) => `${root}\u0000${path}`;
/**
* 根列表 + 懒加载目录树。按 库 / 下载 分组,每根带健康徽标与剩余空间;
* 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。
* pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。
*/
export function DirTree({
roots,
current,
onSelect,
onDropTo,
pickOnly = false,
defaultOpen = [],
className,
}: {
roots: FileRoot[];
current: Loc | null;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly?: boolean;
/** 初始展开的根 id(目标选择器里默认展开可写根) */
defaultOpen?: string[];
className?: string;
}) {
const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen.map((id) => nodeKey(id, ""))));
// 当前路径的各级祖先自动展开
useEffect(() => {
if (!current) return;
setOpen((prev) => {
const next = new Set(prev);
const segs = segments(current.path);
next.add(nodeKey(current.root, ""));
for (let i = 1; i < segs.length; i++) next.add(nodeKey(current.root, segs.slice(0, i).join("/")));
return next.size === prev.size ? prev : next;
});
}, [current]);
const toggle = (k: string) =>
setOpen((prev) => {
const next = new Set(prev);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
const groups = [
{ title: "媒体库", items: roots.filter((r) => r.kind === "library") },
{ title: "下载目录", items: roots.filter((r) => r.kind === "download") },
].filter((g) => g.items.length > 0);
return (
<nav aria-label="根与目录" className={cn("space-y-4", className)}>
{groups.map((g) => (
<section key={g.title} aria-label={g.title}>
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
{g.title}
</p>
<div className="space-y-0.5">
{g.items.map((r) => (
<RootNode
key={r.id}
root={r}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
/>
))}
</div>
</section>
))}
</nav>
);
}
function RootNode({
root,
current,
open,
toggle,
onSelect,
onDropTo,
pickOnly,
}: {
root: FileRoot;
current: Loc | null;
open: Set<string>;
toggle: (k: string) => void;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
}) {
const k = nodeKey(root.id, "");
const offline = root.health === "offline";
const disabled = pickOnly && (!root.writable || offline);
const isOpen = open.has(k) && !offline;
const active = current?.root === root.id && current.path === "";
const h = healthLabel(root.health);
const Icon = root.kind === "download" ? Download : HardDrive;
return (
<div>
<TreeRow
depth={0}
active={active}
disabled={disabled}
expandable={!offline}
expanded={isOpen}
onToggle={() => toggle(k)}
onClick={() => {
if (disabled) return;
onSelect({ root: root.id, path: "" });
if (!open.has(k)) toggle(k);
}}
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
title={disabled ? (offline ? "根离线" : "只读根,不能作为目标") : root.hostPath}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body text-foreground">{root.name}</span>
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
{root.health !== "healthy" ? (
<span
className={cn(
"inline-flex items-center gap-1",
h.tone === "destructive" && "text-destructive",
h.tone === "warning" && "text-warning",
)}
>
{h.tone !== "neutral" ? <TriangleAlert className="h-3 w-3" /> : null}
{h.label}
</span>
) : (
<span className="inline-flex items-center gap-1 text-success">
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
正常
</span>
)}
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable ? <span>只读</span> : null}
</span>
</span>
</TreeRow>
{isOpen ? (
<Children
root={root}
path=""
depth={1}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
/>
) : null}
</div>
);
}
function Children({
root,
path,
depth,
current,
open,
toggle,
onSelect,
onDropTo,
pickOnly,
}: {
root: FileRoot;
path: string;
depth: number;
current: Loc | null;
open: Set<string>;
toggle: (k: string) => void;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
}) {
const q = useDirChildren(root.id, path, true);
if (q.isPending)
return (
<div
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
style={{ paddingLeft: depth * 14 + 28 }}
>
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中
</div>
);
if (q.error)
return (
<p className="py-1 text-meta text-destructive" style={{ paddingLeft: depth * 14 + 28 }}>
读取失败
</p>
);
const dirs = q.data?.dirs ?? [];
if (!dirs.length)
return (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
无子目录
</p>
);
return (
<div>
{dirs.map((d) => {
const p = d.path || joinRel(path, d.name);
const k = nodeKey(root.id, p);
const isOpen = open.has(k);
const active = current?.root === root.id && current.path === p;
const disabled = pickOnly && !root.writable;
return (
<div key={k}>
<TreeRow
depth={depth}
active={active}
disabled={disabled}
expandable
expanded={isOpen}
onToggle={() => toggle(k)}
onClick={() => !disabled && onSelect({ root: root.id, path: p })}
onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined}
title={d.name}
>
{isOpen ? (
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
</TreeRow>
{isOpen ? (
<Children
root={root}
path={p}
depth={depth + 1}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
/>
) : null}
</div>
);
})}
{q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
只显示前 1000 个子目录
</p>
) : null}
</div>
);
}
function TreeRow({
depth,
active,
disabled,
expandable,
expanded,
onToggle,
onClick,
onDrop,
title,
children,
}: {
depth: number;
active: boolean;
disabled?: boolean;
expandable: boolean;
expanded: boolean;
onToggle: () => void;
onClick: () => void;
onDrop?: (e: React.DragEvent) => void;
title?: string;
children: React.ReactNode;
}) {
const [over, setOver] = useState(false);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: 整行是拖放目标,交互本体是里面的两个按钮
<div
className={cn(
"group flex items-center gap-1 rounded-lg pr-2 transition-colors duration-micro",
active
? "pill-selected text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
disabled && "opacity-50",
over && "bg-foreground/[0.08] ring-1 ring-ring",
)}
style={{ paddingLeft: depth * 14 }}
onDragOver={
onDrop
? (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
setOver(true);
}
: undefined
}
onDragLeave={onDrop ? () => setOver(false) : undefined}
onDrop={
onDrop
? (e) => {
e.preventDefault();
setOver(false);
onDrop(e);
}
: undefined
}
>
<button
type="button"
tabIndex={-1}
aria-label={expanded ? "收起" : "展开"}
aria-expanded={expanded}
onClick={onToggle}
className={cn("grid h-7 w-6 shrink-0 place-items-center rounded-md", !expandable && "invisible")}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", expanded && "rotate-90")} />
</button>
<button
type="button"
onClick={onClick}
title={title}
aria-current={active ? "location" : undefined}
aria-disabled={disabled || undefined}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left outline-none focus-visible:underline",
disabled && "cursor-not-allowed",
)}
>
{children}
</button>
</div>
);
}
@@ -0,0 +1,278 @@
import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileRoot,
fileDownloadUrl,
filesErrorText,
filesMockEnabled,
useFileStat,
useProbe,
useTextPreview,
} from "~/api/files.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { downloadLabel } from "~/lib/labels.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { EntryStatus } from "./file-list.tsx";
import { baseName } from "./model.ts";
const PREVIEW_MAX = 20 * 1024 * 1024;
/** 选中单项的详情抽屉:文件信息、作品/Slot/下载链接、sidecars、探测摘要、路径绑定与小文件预览 */
export function FileDetail({
root,
entry,
onClose,
actions,
}: {
root: FileRoot;
entry: FileEntry;
onClose: () => void;
actions?: ReactNode;
}) {
const stat = useFileStat(root.id, entry.path);
const probe = useProbe();
const e = stat.data?.entry ?? entry;
const previewable =
e.type === "file" &&
e.size <= PREVIEW_MAX &&
(e.fileKind === "nfo" || e.fileKind === "subtitle" || e.fileKind === "image");
const text = useTextPreview(root.id, e.path, previewable && e.fileKind !== "image");
const media = probe.data?.items[0]?.summary ?? e.media;
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
title={e.name}
description={<EntryStatus e={e} />}
footer={actions}
>
<DetailSection title="文件">
<KvList
items={[
{ label: "类型", value: e.type === "dir" ? "目录" : e.type === "symlink" ? "符号链接" : (e.ext ?? "文件") },
{
label: "大小",
value:
e.type === "dir"
? e.dirSummary
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已登记`
: "—"
: formatBytes(e.size, 2),
},
{ label: "修改时间", value: formatDateTime(e.mtime) },
{
label: "链接数",
value:
stat.data?.nlink != null
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,禁止原地改写)" : ""}`
: null,
},
{ label: "本机路径", value: `${root.hostPath}${e.path ? `/${e.path}` : ""}`, mono: true, wide: true },
...(stat.data && stat.data.realPath !== `${root.hostPath}/${e.path}`
? [{ label: "真实路径", value: stat.data.realPath, mono: true, wide: true }]
: []),
]}
/>
{stat.isPending ? <LoadingState rows={1} /> : null}
{stat.error ? (
<p className="mt-2 text-meta text-destructive">{filesErrorText(stat.error, "读取详情失败")}</p>
) : null}
</DetailSection>
{e.asset ? (
<DetailSection title="已登记资产">
<KvList
items={[
{
label: "作品",
value: (
<Link
className="underline underline-offset-2 hover:text-foreground"
to={`/console/library/${e.asset.mediaEntityId}`}
>
{e.asset.title}
{e.asset.year ? ` (${e.asset.year})` : ""}
</Link>
),
},
{ label: "内容单元", value: e.asset.contentUnit ?? "整部" },
{
label: "Slot",
value: e.asset.slotId ? (
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
{e.asset.slotStatus ?? "查看"}
</Link>
) : (
"未满足任何 Slot"
),
},
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
]}
/>
<Link
className="mt-2 inline-block text-meta text-muted-foreground underline"
to={`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`}
>
在整理记录中查看
</Link>
</DetailSection>
) : null}
{e.download ? (
<DetailSection title="下载任务">
<KvList
items={[
{
label: "任务",
value: (
<Link className="underline underline-offset-2" to={`/console/tasks/${e.download.taskId}`}>
{downloadLabel(e.download.status).label}
</Link>
),
},
{
label: "进度",
value: e.download.progress != null ? `${Math.floor(e.download.progress * 100)}%` : null,
},
{
label: "H&R",
value: e.download.hnr === "satisfied" ? "已满足" : e.download.hnr === "unsatisfied" ? "未满足" : "未知",
},
]}
/>
</DetailSection>
) : null}
{stat.data?.bindings.length ? (
<DetailSection title="路径绑定">
<ul className="space-y-1 text-caption">
{stat.data.bindings.map((b) => (
<li key={b.bindingId}>
<Link className="underline underline-offset-2" to={`/console/library/${b.mediaEntityId}`}>
{b.title}
</Link>
{b.kind === "season" && b.seasonNumber != null ? ` · 第 ${b.seasonNumber} 季` : " · 作品目录"}
</li>
))}
</ul>
<p className="mt-1 text-meta text-muted-foreground">改名/移动此目录会同步修订绑定;元数据刷新不会改路径。</p>
</DetailSection>
) : null}
{stat.data?.sidecars.length ? (
<DetailSection title={`附属文件 ${stat.data.sidecars.length}`}>
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
{stat.data.sidecars.map((s) => (
<li key={s} className="truncate" title={s}>
{baseName(s)}
</li>
))}
</ul>
</DetailSection>
) : null}
{e.type === "file" && e.fileKind === "video" ? (
<DetailSection
title="媒体信息"
actions={
<Button
size="xs"
variant="outline"
disabled={probe.isPending}
onClick={() =>
probe.mutate(
{ items: [{ root: root.id, path: e.path }] },
{
onSuccess: (r) => {
const it = r.items[0];
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
},
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
},
)
}
>
{probe.isPending ? <LoaderCircle className="animate-spin" /> : <ScanSearch />}
探测
</Button>
}
>
{media ? (
<KvList
items={[
{ label: "分辨率", value: media.resolution },
{ label: "HDR", value: media.hdr },
{ label: "视频编码", value: media.videoCodec },
{ label: "音轨", value: media.audioTracks },
{
label: "时长",
value: media.durationSec ? `${Math.floor(media.durationSec / 60)} 分钟` : null,
},
...(probe.data?.items[0]
? [
{ label: "容器", value: probe.data.items[0].container },
{ label: "字幕轨", value: probe.data.items[0].subtitleTracks },
]
: []),
]}
/>
) : (
<p className="text-caption text-muted-foreground">
未登记文件不自动探测;点“探测”读取 ffprobe 摘要(缓存 24 小时)。
</p>
)}
</DetailSection>
) : null}
{previewable ? (
<DetailSection
title="预览"
actions={
filesMockEnabled() ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, e.path)}
download={e.name}
>
<Download />
下载
</a>
)
}
>
{e.fileKind === "image" ? (
filesMockEnabled() ? (
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
) : (
<a href={fileDownloadUrl(root.id, e.path)} target="_blank" rel="noreferrer" className="block">
<img
src={fileDownloadUrl(root.id, e.path)}
alt={e.name}
className="max-h-80 rounded-xl border border-border object-contain"
/>
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口打开
</span>
</a>
)
) : text.isPending ? (
<LoadingState rows={3} />
) : text.error ? (
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
) : (
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
{text.data}
</pre>
)}
</DetailSection>
) : null}
</DetailSheet>
);
}
+552
View File
@@ -0,0 +1,552 @@
import {
Check,
File,
FileImage,
FileText,
FileVideo,
Folder,
Link2Off,
LoaderCircle,
MoreHorizontal,
Music,
Subtitles,
} from "lucide-react";
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import type { FileEntry } from "~/api/files.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import type { MenuAnchor } from "./context-menu.tsx";
import { clickSelect, entryStatusView, moveFocus, type Selection, selectAll, toggleSelect } from "./model.ts";
export const ROW_H = 52;
export const CARD_H = 68;
function EntryIcon({ e }: { e: FileEntry }) {
const cls = "h-4 w-4 shrink-0 text-muted-foreground";
if (e.escape) return <Link2Off className={cn(cls, "text-destructive")} />;
if (e.type === "dir") return <Folder className={cls} />;
switch (e.fileKind) {
case "video":
return <FileVideo className={cls} />;
case "subtitle":
return <Subtitles className={cls} />;
case "image":
return <FileImage className={cls} />;
case "nfo":
return <FileText className={cls} />;
case "audio":
return <Music className={cls} />;
default:
return <File className={cls} />;
}
}
function sizeCell(e: FileEntry): ReactNode {
if (e.type === "dir") {
const s = e.dirSummary;
if (!s) return <span className="text-muted-foreground">—</span>;
return (
<span className="text-muted-foreground" title="视频数 / 已登记数">
{s.videoCount}
{s.partial ? "+" : ""} 视频 · {s.registeredCount} 登记
</span>
);
}
return formatBytes(e.size, 1);
}
/** 状态徽标 + 链接(作品 / 下载任务) */
export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: boolean }) {
const v = entryStatusView(e);
if (!v) return compact ? null : <span className="text-muted-foreground">—</span>;
return (
<span className="inline-flex min-w-0 items-center gap-1.5" title={v.hint}>
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
{!compact && e.download ? (
<Link
to={`/console/tasks/${e.download.taskId}`}
onClick={(ev) => ev.stopPropagation()}
className="truncate text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
任务
</Link>
) : null}
</span>
);
}
function useScrollBox(ref: React.RefObject<HTMLDivElement | null>) {
const [box, setBox] = useState({ top: 0, height: 600 });
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => setBox({ top: el.scrollTop, height: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
el.addEventListener("scroll", update, { passive: true });
return () => {
ro.disconnect();
el.removeEventListener("scroll", update);
};
}, [ref]);
return box;
}
/**
* 文件列表:role="grid" 的虚拟滚动表格(固定行高,只渲染可见行 ± overscan)。
* 桌面为多列行;窄屏(mobile)为单列卡片,长按进入多选。
* 键盘:↑↓/Home/End 移动焦点(Shift 扩选)、Enter 打开、Backspace 上一级、Space 勾选、
* Ctrl/Cmd+A 全选、F2 改名、Delete 删除、Esc 清除选择、Shift+F10/菜单键 打开右键菜单。
*/
export function FileList({
entries,
keyOf,
selection,
onSelection,
onOpen,
onUp,
onMenu,
onRename,
onDelete,
onDragRows,
onDropToDir,
hasMore,
onLoadMore,
loadingMore,
mobile,
footer,
gridRef,
}: {
entries: FileEntry[];
keyOf: (e: FileEntry) => string;
selection: Selection;
onSelection: (s: Selection) => void;
onOpen: (e: FileEntry) => void;
onUp: () => void;
onMenu: (e: FileEntry, at: MenuAnchor) => void;
onRename: (e: FileEntry) => void;
onDelete: (entries: FileEntry[]) => void;
onDragRows: (e: FileEntry, ev: React.DragEvent) => void;
onDropToDir: (dir: FileEntry, ev: React.DragEvent) => void;
hasMore: boolean;
onLoadMore: () => void;
loadingMore: boolean;
mobile: boolean;
footer?: ReactNode;
gridRef?: React.RefObject<HTMLDivElement | null>;
}) {
const innerRef = useRef<HTMLDivElement>(null);
const ref = gridRef ?? innerRef;
const box = useScrollBox(ref);
const rowH = mobile ? CARD_H : ROW_H;
const order = entries.map(keyOf);
const byKey = new Map(entries.map((e) => [keyOf(e), e]));
const overscan = 8;
const start = Math.max(0, Math.floor(box.top / rowH) - overscan);
const end = Math.min(entries.length, Math.ceil((box.top + box.height) / rowH) + overscan);
const focusIndex = selection.focus ? order.indexOf(selection.focus) : -1;
const [dropOver, setDropOver] = useState<string | null>(null);
const longPress = useRef<{ t: ReturnType<typeof setTimeout> | null; fired: boolean }>({ t: null, fired: false });
const selecting = mobile && selection.keys.size > 0;
// 滚到底自动取下一页
useEffect(() => {
if (hasMore && !loadingMore && end >= entries.length - 20) onLoadMore();
}, [hasMore, loadingMore, end, entries.length, onLoadMore]);
const scrollToIndex = useCallback(
(i: number) => {
const el = ref.current;
if (!el || i < 0) return;
const top = i * rowH;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + rowH > el.scrollTop + el.clientHeight) el.scrollTop = top + rowH - el.clientHeight;
},
[ref, rowH],
);
const selectedEntries = () =>
selection.keys.size
? entries.filter((e) => selection.keys.has(keyOf(e)))
: selection.focus && byKey.get(selection.focus)
? [byKey.get(selection.focus) as FileEntry]
: [];
const onKeyDown = (ev: React.KeyboardEvent) => {
if ((ev.target as HTMLElement).closest("input,textarea,[role=menu]")) return;
const focused = selection.focus ? byKey.get(selection.focus) : undefined;
const mod = ev.ctrlKey || ev.metaKey;
const step = (delta: number) => {
ev.preventDefault();
const next = moveFocus(selection, order, delta, ev.shiftKey);
onSelection(
ev.shiftKey
? next
: { ...next, keys: mod ? next.keys : new Set(next.focus ? [next.focus] : []), anchor: next.focus },
);
scrollToIndex(next.focus ? order.indexOf(next.focus) : -1);
};
switch (ev.key) {
case "ArrowDown":
return step(1);
case "ArrowUp":
return step(-1);
case "PageDown":
return step(Math.max(1, Math.floor(box.height / rowH) - 1));
case "PageUp":
return step(-Math.max(1, Math.floor(box.height / rowH) - 1));
case "Home":
return step(-order.length);
case "End":
return step(order.length);
case "Enter":
if (focused) {
ev.preventDefault();
onOpen(focused);
}
return;
case "Backspace":
ev.preventDefault();
onUp();
return;
case " ":
if (focused) {
ev.preventDefault();
onSelection(toggleSelect(selection, keyOf(focused)));
}
return;
case "F2":
if (focused) {
ev.preventDefault();
onRename(focused);
}
return;
case "Delete": {
const sel = selectedEntries();
if (sel.length) {
ev.preventDefault();
onDelete(sel);
}
return;
}
case "Escape":
if (selection.keys.size) {
ev.preventDefault();
onSelection({ ...selection, keys: new Set() });
}
return;
case "ContextMenu":
case "F10":
if (focused && (ev.key === "ContextMenu" || ev.shiftKey)) {
ev.preventDefault();
const row = ref.current?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(keyOf(focused))}"]`);
const r = row?.getBoundingClientRect();
onMenu(focused, { x: (r?.left ?? 0) + 48, y: (r?.bottom ?? 0) - 4 });
}
return;
default:
if ((ev.key === "a" || ev.key === "A") && mod) {
ev.preventDefault();
onSelection(selectAll(order, selection));
}
}
};
const onRowClick = (e: FileEntry, ev: React.MouseEvent) => {
const k = keyOf(e);
if (longPress.current.fired) {
longPress.current.fired = false;
return;
}
if (selecting) {
onSelection(toggleSelect(selection, k));
return;
}
if (mobile) {
onOpen(e);
return;
}
onSelection(clickSelect(selection, order, k, { shift: ev.shiftKey, ctrl: ev.ctrlKey || ev.metaKey }));
ref.current?.focus({ preventScroll: true });
};
const touchStart = (e: FileEntry) => {
if (!mobile) return;
longPress.current.fired = false;
longPress.current.t = setTimeout(() => {
longPress.current.fired = true;
onSelection(toggleSelect(selection, keyOf(e)));
if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(10);
}, 480);
};
const touchEnd = () => {
if (longPress.current.t) clearTimeout(longPress.current.t);
longPress.current.t = null;
};
const visible = entries.slice(start, end);
return (
// biome-ignore lint/a11y/useSemanticElements: 虚拟滚动网格(绝对定位行)无法用 table 元素
<div
ref={ref}
role="grid"
aria-label="文件列表"
aria-multiselectable
aria-rowcount={entries.length}
aria-activedescendant={focusIndex >= 0 ? `file-row-${focusIndex}` : undefined}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={() => {
if (!selection.focus && order[0]) onSelection({ ...selection, focus: order[0] });
}}
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
>
{!mobile ? (
// biome-ignore lint/a11y/useSemanticElements: 同上,虚拟网格的表头行
// biome-ignore lint/a11y/useFocusableInteractive: 焦点在 grid 容器上
<div
role="row"
className={cn(
"sticky top-0 z-10 grid items-center gap-3 border-b border-border bg-background/95 px-3 py-2 text-meta font-medium text-muted-foreground backdrop-blur",
DESKTOP_COLS,
)}
>
<Cell header className="grid place-items-center">
<Checkbox
checked={entries.length > 0 && selection.keys.size === entries.length}
partial={selection.keys.size > 0 && selection.keys.size < entries.length}
label="全选"
onToggle={() =>
onSelection(
selection.keys.size === entries.length
? { ...selection, keys: new Set() }
: selectAll(order, selection),
)
}
/>
</Cell>
<Cell header>名称</Cell>
<Cell header>状态</Cell>
<Cell header className="hidden xl:block">
季集
</Cell>
<Cell header className="text-right">
大小
</Cell>
<Cell header className="hidden lg:block">
修改时间
</Cell>
<Cell header className="sr-only">
操作
</Cell>
</div>
) : null}
<div style={{ height: entries.length * rowH, position: "relative" }}>
{visible.map((e, j) => {
const i = start + j;
const k = keyOf(e);
const selected = selection.keys.has(k);
const focused = selection.focus === k;
const isDropDir = e.type === "dir" && !e.escape;
const common = {
id: `file-row-${i}`,
role: "row",
"aria-rowindex": i + 1,
"aria-selected": selected,
"data-row-key": k,
draggable: !mobile && !e.escape,
onDragStart: (ev: React.DragEvent) => onDragRows(e, ev),
onDragOver: isDropDir
? (ev: React.DragEvent) => {
if (!ev.dataTransfer.types.includes(DRAG_MIME)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = ev.altKey ? "copy" : "move";
setDropOver(k);
}
: undefined,
onDragLeave: isDropDir ? () => setDropOver(null) : undefined,
onDrop: isDropDir
? (ev: React.DragEvent) => {
ev.preventDefault();
setDropOver(null);
onDropToDir(e, ev);
}
: undefined,
onClick: (ev: React.MouseEvent) => onRowClick(e, ev),
onDoubleClick: () => onOpen(e),
onContextMenu: (ev: React.MouseEvent) => {
ev.preventDefault();
if (!selection.keys.has(k)) onSelection({ keys: new Set([k]), anchor: k, focus: k });
onMenu(e, { x: ev.clientX, y: ev.clientY });
},
onTouchStart: () => touchStart(e),
onTouchEnd: touchEnd,
onTouchMove: touchEnd,
style: { position: "absolute" as const, top: i * rowH, left: 0, right: 0, height: rowH },
className: cn(
"cursor-default select-none border-b border-border/50 transition-colors duration-micro",
selected ? "bg-foreground/[0.07]" : "hover:bg-foreground/[0.035]",
focused && "ring-1 ring-ring/60 ring-inset",
dropOver === k && "bg-foreground/[0.1] ring-2 ring-ring ring-inset",
e.escape && "opacity-70",
),
};
const menuBtn = (
<button
type="button"
aria-label={`${e.name} 的操作`}
tabIndex={-1}
onClick={(ev) => {
ev.stopPropagation();
const r = (ev.currentTarget as HTMLElement).getBoundingClientRect();
if (!selection.keys.has(k)) onSelection({ keys: new Set([k]), anchor: k, focus: k });
onMenu(e, { x: r.right - 208, y: r.bottom + 4 });
}}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</button>
);
if (mobile) {
return (
<div key={k} {...common}>
<div className="flex h-full items-center gap-3 px-3">
{selecting ? (
<Checkbox
checked={selected}
label={`选择 ${e.name}`}
onToggle={() => onSelection(toggleSelect(selection, k))}
/>
) : (
<EntryIcon e={e} />
)}
<Cell as="div" className="min-w-0 flex-1">
<p className="truncate text-body text-foreground">{e.name}</p>
<div className="mt-0.5 flex min-w-0 items-center gap-2 text-meta text-muted-foreground">
<EntryStatus e={e} compact />
<span className="shrink-0">{sizeCell(e)}</span>
<span className="truncate">{relTime(e.mtime)}</span>
</div>
</Cell>
{menuBtn}
</div>
</div>
);
}
return (
<div key={k} {...common}>
<div className={cn("grid h-full items-center gap-3 px-3 text-sm", DESKTOP_COLS)}>
<Cell className="grid place-items-center">
<Checkbox
checked={selected}
label={`选择 ${e.name}`}
onToggle={() => onSelection(toggleSelect(selection, k))}
/>
</Cell>
<Cell className="flex min-w-0 items-center gap-2.5">
<EntryIcon e={e} />
<span className="min-w-0">
<span className="block truncate text-foreground" title={e.name}>
{e.name}
</span>
{e.asset ? (
<Link
to={`/console/library/${e.asset.mediaEntityId}`}
onClick={(ev) => ev.stopPropagation()}
className="block truncate text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{e.asset.title}
{e.asset.year ? ` (${e.asset.year})` : ""}
{e.asset.slotId ? " · Slot" : ""}
</Link>
) : null}
</span>
</Cell>
<Cell className="min-w-0">
<EntryStatus e={e} />
</Cell>
<Cell className="hidden font-mono text-caption text-muted-foreground xl:block">
{e.asset?.contentUnit ?? ""}
</Cell>
<Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell>
<Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell>
<Cell className="grid place-items-center">{menuBtn}</Cell>
</div>
</div>
);
})}
</div>
{loadingMore ? (
<div className="flex items-center justify-center gap-2 py-3 text-meta text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 加载下一页
</div>
) : null}
{footer}
</div>
);
}
/** 网格单元:虚拟滚动的行是绝对定位的 div,不能用 <table>;单元不单独获焦(焦点在 grid 上,aria-activedescendant 指向行) */
function Cell({
header = false,
as = "span",
className,
children,
}: {
header?: boolean;
as?: "span" | "div";
className?: string;
children?: ReactNode;
}) {
const Tag = as;
return (
<Tag role={header ? "columnheader" : "gridcell"} tabIndex={-1} className={className}>
{children}
</Tag>
);
}
export const DRAG_MIME = "application/x-umt-files";
// 列:勾选 | 名称 | 状态 | 季集(xl)| 大小 | 修改时间(lg)| ⋯
const DESKTOP_COLS =
"grid-cols-[28px_minmax(0,1fr)_10.5rem_7.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_10.5rem_7.5rem_6.5rem_32px] xl:grid-cols-[28px_minmax(0,1fr)_11rem_5.5rem_7.5rem_6.5rem_32px]";
export function Checkbox({
checked,
partial = false,
label,
onToggle,
}: {
checked: boolean;
partial?: boolean;
label: string;
onToggle: () => void;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: 行内勾选需要阻止冒泡并保持 16px 方框外观,原生 checkbox 无法套 token 样式
<button
type="button"
role="checkbox"
aria-checked={partial ? "mixed" : checked}
aria-label={label}
tabIndex={-1}
onClick={(ev) => {
ev.stopPropagation();
onToggle();
}}
className={cn(
"grid h-4 w-4 place-items-center rounded-tight border transition-colors duration-micro",
checked || partial
? "border-foreground/70 bg-foreground/10 text-foreground"
: "border-border hover:border-foreground/40",
)}
>
{checked ? <Check className="h-3 w-3" /> : partial ? <span className="h-0.5 w-2 bg-foreground" /> : null}
</button>
);
}
+921
View File
@@ -0,0 +1,921 @@
// 文件管理页演示数据(仅开发构建 ?mock=1):内存文件树 + 与后端同形状的读写实现,
// 用来在后端联调前把交互走通。守卫/授权/幂等等语义以服务端为准,这里只做到“看起来对”。
import type {
FileDeleteCheck,
FileEntry,
FileIdentifyItem,
FileKindTag,
FileRef,
FileRoot,
FileStat,
FilesListResponse,
FilesOrganizeGroup,
FilesTransferResult,
TrashItem,
} from "@umt/contracts";
import { ApiRequestError } from "~/api/parse.ts";
import type { FetcherArg } from "~/lib/api.ts";
type ItemRef = FileRef;
type FileKind = FileKindTag;
type FileListResponse = FilesListResponse;
type OrganizeGroup = FilesOrganizeGroup;
type Node = {
name: string;
dir: boolean;
size: number;
mtime: string;
children?: Node[];
meta?: Partial<Pick<FileEntry, "status" | "asset" | "download" | "media" | "escape">>;
/** 大目录:按需生成 n 个子项 */
lazyCount?: number;
};
const GB = 1024 ** 3;
const MB = 1024 ** 2;
const now = Date.now();
const ago = (h: number) => new Date(now - h * 3_600_000).toISOString();
const f = (name: string, size: number, h: number, meta?: Node["meta"]): Node => ({
name,
dir: false,
size: Math.round(size),
mtime: ago(h),
meta,
});
const d = (name: string, h: number, children: Node[], meta?: Node["meta"]): Node => ({
name,
dir: true,
size: 4096,
mtime: ago(h),
children,
meta,
});
const dune = (cu: string | null = null) => ({
assetId: "a-dune",
locationId: "l-dune",
mediaEntityId: "e-dune",
title: "沙丘2",
year: 2024,
contentUnit: cu,
slotId: "s-dune-uhd",
slotStatus: "satisfied" as const,
isPrimary: true,
});
const tlou = (ep: number) => ({
assetId: `a-tlou-${ep}`,
locationId: `l-tlou-${ep}`,
mediaEntityId: "e-tlou",
title: "最后生还者",
year: 2023,
contentUnit: `S02E${String(ep).padStart(2, "0")}`,
slotId: "s-tlou-uhd",
slotStatus: "satisfied" as const,
isPrimary: true,
});
const uhdInfo: FileEntry["media"] = {
resolution: "2160p",
hdr: "DV/HDR10",
videoCodec: "HEVC",
audioTracks: 3,
durationSec: 9960,
};
function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
const roots: FileRoot[] = [
{
id: "lib:movies",
kind: "library",
name: "电影库",
hostPath: "/mnt/media/movies",
mediaKind: "movie",
writable: true,
health: "healthy",
checkedAt: ago(0.01),
healthReason: null,
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
},
{
id: "lib:tv",
kind: "library",
name: "剧集库",
hostPath: "/mnt/media/tv",
mediaKind: "series",
writable: true,
health: "healthy",
checkedAt: ago(0.01),
healthReason: null,
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
},
{
id: "lib:nas-old",
kind: "library",
name: "旧 NAS(只读登记)",
hostPath: "/mnt/nas-old",
mediaKind: "mixed",
writable: false,
health: "offline",
checkedAt: ago(3),
healthReason: "挂载点无响应(ENOTCONN)",
usage: null,
},
{
id: "dl:qb-main:0",
kind: "download",
name: "qBittorrent · 下载",
hostPath: "/srv/downloads",
mediaKind: null,
writable: false,
health: "healthy",
checkedAt: ago(0.01),
healthReason: null,
usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB },
},
];
const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) =>
f(
`The Last of Us - S02E${String(ep).padStart(2, "0")} - 2160p WEB-DL.mkv`,
7.4 * GB + ep * 91 * MB,
400 - ep * 24,
{
status: "registered",
asset: tlou(ep),
media: uhdInfo,
},
),
);
const trees: Record<string, Node> = {
"lib:movies": d("", 1, [
d("Dune Part Two (2024)", 30, [
f("Dune Part Two (2024) - 2160p UHD BluRay.mkv", 71.2 * GB, 30, {
status: "registered",
asset: dune(),
media: uhdInfo,
}),
f("Dune Part Two (2024) - 2160p UHD BluRay.zh.srt", 96_000, 30, { status: "registered" }),
f("movie.nfo", 4_300, 30),
f("poster.jpg", 820_000, 30),
]),
d("Oppenheimer (2023)", 240, [
f("Oppenheimer (2023) - 1080p.mkv", 18.4 * GB, 240, {
status: "registered",
asset: {
...dune(),
assetId: "a-opp",
mediaEntityId: "e-opp",
title: "奥本海默",
year: 2023,
slotStatus: "upgrade_pending" as const,
},
}),
]),
d("_未归类", 12, [f("sample.mkv", 42 * MB, 12, { status: "unidentified" })]),
f(".DS_Store", 6_148, 900),
]),
"lib:tv": d("", 2, [
d("The Last of Us (2023)", 24, [
d("Season 01", 900, []),
d("Season 02", 24, tlouEps),
f("tvshow.nfo", 5_120, 24),
]),
d("Severance (2022)", 120, [d("Season 01", 1200, []), d("Season 02", 120, [])]),
]),
"dl:qb-main:0": d("", 0.5, [
d(
"Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
30,
[
f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv", 71.2 * GB, 30, {
status: "seeding",
download: { taskId: "t-dune", status: "completed", progress: 1, hnr: "unsatisfied" },
}),
],
{ status: "seeding", download: { taskId: "t-dune", status: "completed", progress: 1, hnr: "unsatisfied" } },
),
d(
"The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
20,
[1, 2, 3, 4, 5, 6, 7].map((ep) =>
f(
`The.Last.of.Us.S02E${String(ep).padStart(2, "0")}.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
7.4 * GB,
20,
{
status: "seeding",
download: { taskId: "t-tlou", status: "completed", progress: 1, hnr: "satisfied" },
},
),
),
{ status: "seeding", download: { taskId: "t-tlou", status: "completed", progress: 1, hnr: "satisfied" } },
),
d("散文件", 3, [
f("Severance.S02E01.Hello.Ms.Cobel.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.1 * GB, 3, {
status: "unidentified",
}),
f("Severance.S02E02.Goodbye.Mrs.Selvig.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.3 * GB, 3, {
status: "unidentified",
}),
f("Severance.S02E03.Who.Is.Alive.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.0 * GB, 3, {
status: "unidentified",
}),
f("Severance.S02E01.zh.ass", 88_000, 3),
]),
f("Oppenheimer.2023.2160p.BluRay.x265.10bit.HDR-GRP.mkv", 31.8 * GB, 50, { status: "unidentified" }),
f("Nosferatu.2024.2160p.WEB-DL.mkv", 14.2 * GB, 0.2, {
status: "downloading",
download: { taskId: "t-nos", status: "downloading", progress: 0.42, hnr: "unknown" },
}),
f("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }),
f("README.txt", 1_024, 700),
f("escape-link", 0, 700, { escape: true }),
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
f(".hidden-note", 64, 700),
]),
};
return { roots, trees };
}
let state = makeRoots();
let trash: TrashItem[] = [
{
id: "tr-1",
rootId: "dl:qb-main:0",
originalPath: "Old.Movie.2019.1080p.mkv",
trashPath: "/srv/downloads/.umt-trash/20260926/1/Old.Movie.2019.1080p.mkv",
isDir: false,
size: Math.round(9.2 * GB),
deletedAt: ago(30),
expiresAt: new Date(now + 5.7 * 86_400_000).toISOString(),
actor: "w31-admin",
assetSnapshot: null,
},
{
id: "tr-2",
rootId: "lib:movies",
originalPath: "Tenet (2020)",
trashPath: "/mnt/media/movies/.umt-trash/20260921/2/Tenet (2020)",
isDir: true,
size: 22 * GB,
deletedAt: ago(150),
expiresAt: new Date(now + 0.8 * 86_400_000).toISOString(),
actor: "w31-admin",
assetSnapshot: { title: "信条", contentUnit: null },
},
];
export function resetMock() {
state = makeRoots();
}
const VIDEO = new Set(["mkv", "mp4", "m2ts", "ts", "avi", "mov", "webm"]);
const SUB = new Set(["srt", "ass", "ssa", "sup", "vtt"]);
const IMG = new Set(["jpg", "jpeg", "png", "webp"]);
function kindOf(name: string, dir: boolean): { ext: string | null; fileKind: FileKind } {
if (dir) return { ext: null, fileKind: "other" };
const m = /\.([A-Za-z0-9]{1,6})$/.exec(name);
const ext = m ? (m[1] as string).toLowerCase() : null;
if (!ext) return { ext, fileKind: "other" };
if (VIDEO.has(ext)) return { ext, fileKind: "video" };
if (SUB.has(ext)) return { ext, fileKind: "subtitle" };
if (IMG.has(ext)) return { ext, fileKind: "image" };
if (ext === "nfo") return { ext, fileKind: "nfo" };
if (["flac", "mka", "ac3", "dts"].includes(ext)) return { ext, fileKind: "audio" };
return { ext, fileKind: "other" };
}
function lazyChildren(n: Node): Node[] {
if (!n.lazyCount) return n.children ?? [];
if (n.children && n.children.length >= Math.min(n.lazyCount, 20_000)) return n.children;
const kids: Node[] = [];
for (let i = 1; i <= Math.min(n.lazyCount, 20_000); i++) {
kids.push(
f(`sample-${String(i).padStart(5, "0")}.mkv`, 30 * MB + (i % 97) * MB, 5 + (i % 300), { status: "unidentified" }),
);
}
n.children = kids;
return kids;
}
function rootOf(root: string): FileRoot {
const r = state.roots.find((x) => x.id === root);
if (!r) throw new ApiRequestError("not_found", "not_found: 根不存在或未登记");
if (r.health === "offline")
throw new ApiRequestError("upstream_unavailable", `root_unavailable: ${r.healthReason ?? "挂载点无响应"}`);
return r;
}
function find(root: string, path: string): { node: Node; parent: Node | null } {
rootOf(root);
let node = state.trees[root];
if (!node) throw new ApiRequestError("not_found", "not_found: 根不存在");
let parent: Node | null = null;
for (const seg of path.split("/").filter(Boolean)) {
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape: 路径越界");
const next = lazyChildren(node).find((c) => c.name === seg);
if (!next) throw new ApiRequestError("not_found", `not_found: 不存在 ${path}`);
parent = node;
node = next;
}
return { node, parent };
}
const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
function toEntry(n: Node, path: string): FileEntry {
const { ext, fileKind } = kindOf(n.name, n.dir);
let status = n.meta?.status ?? "plain";
if (!n.meta?.status && fileKind === "video") status = "unidentified";
if (/\.(!qB|part|uhd-partial)$/.test(n.name)) status = "temp";
let dirSummary: FileEntry["dirSummary"] = null;
if (n.dir) {
const kids = n.lazyCount ? [] : (n.children ?? []);
const vids = kids.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video");
dirSummary = n.lazyCount
? { videoCount: 500, registeredCount: 0, partial: true }
: {
videoCount: vids.length,
registeredCount: vids.filter((v) => v.meta?.status === "registered").length,
partial: false,
};
}
return {
name: n.name,
path,
type: n.meta?.escape ? "symlink" : n.dir ? "dir" : "file",
size: n.dir ? 0 : n.size,
mtime: n.mtime,
ext,
fileKind: n.dir ? null : fileKind,
status,
escape: !!n.meta?.escape,
asset: n.meta?.asset ?? null,
download: n.meta?.download ?? null,
media: n.meta?.media ?? null,
dirSummary,
};
}
const STATUS_RANK: Record<string, number> = {
temp: 0,
downloading: 1,
registered: 2,
seeding: 3,
unidentified: 4,
plain: 5,
};
function globToRegex(q: string): RegExp {
if (!/[*?]/.test(q)) return new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
const src = q
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${src}$`, "i");
}
function list(params: Record<string, unknown>): FileListResponse {
const root = String(params.root ?? "");
const path = String(params.path ?? "");
const { node } = find(root, path);
if (!node.dir) throw new ApiRequestError("bad_request", "不是目录");
let kids = lazyChildren(node);
const truncated = (node.lazyCount ?? 0) > 20_000;
if (params.hidden !== "true") kids = kids.filter((k) => !k.name.startsWith("."));
let entries = kids.map((k) => toEntry(k, join(path, k.name)));
const q = String(params.q ?? "").trim();
if (q) {
const re = globToRegex(q);
entries = entries.filter((e) => re.test(e.name));
}
const filters = String(params.filter ?? "")
.split(",")
.filter(Boolean);
if (filters.length) {
entries = entries.filter((e) =>
filters.some((fl) =>
fl === "video"
? e.fileKind === "video"
: fl === "subtitle"
? e.fileKind === "subtitle"
: fl === "unregistered"
? e.status !== "registered"
: e.status === fl,
),
);
}
const sort = String(params.sort ?? "name");
const dir = params.order === "desc" ? -1 : 1;
const coll = new Intl.Collator("zh-CN", { numeric: true, sensitivity: "base" });
entries.sort((a, b) => {
const da = a.type === "dir" ? 0 : 1;
const db = b.type === "dir" ? 0 : 1;
if (da !== db) return da - db;
let c = 0;
if (sort === "size") c = a.size - b.size;
else if (sort === "mtime") c = (a.mtime ?? "").localeCompare(b.mtime ?? "");
else if (sort === "status") c = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
else c = coll.compare(a.name, b.name);
return (c || coll.compare(a.name, b.name)) * dir;
});
const offset = Number(params.cursor ?? 0);
const limit = Number(params.limit ?? 200);
return {
root,
path,
entries: entries.slice(offset, offset + limit),
nextCursor: offset + limit < entries.length ? String(offset + limit) : null,
total: entries.length,
truncated,
stale: false,
scannedAt: new Date().toISOString(),
};
}
function bindingsFor(root: string, path: string, node: Node) {
return node.dir && root.startsWith("lib:") && path && !path.includes("/")
? [
{
bindingId: `b-${node.name}`,
kind: "entity" as const,
mediaEntityId: "e-x",
title: node.name.replace(/ \(\d{4}\)$/, ""),
seasonNumber: null,
},
]
: [];
}
function stat(params: Record<string, unknown>): FileStat {
const root = String(params.root ?? "");
const path = String(params.path ?? "");
const r = rootOf(root);
const { node, parent } = find(root, path);
const entry = toEntry(node, path);
const stem = node.name.replace(/\.[^.]+$/, "");
const dirPath = path.split("/").slice(0, -1).join("/");
const sidecars = node.dir
? []
: (parent?.children ?? [])
.filter((c) => c !== node && c.name.startsWith(`${stem}.`))
.map((c) => join(dirPath, c.name));
return {
entry,
realPath: `${r.hostPath}/${path}`,
inode: 1_000_000 + path.length * 97,
device: 2051,
nlink: entry.status === "seeding" ? 2 : 1,
sharedInode: entry.status === "seeding",
sidecars,
bindings: bindingsFor(root, path, node),
};
}
function detach(ref: ItemRef): Node {
const { node, parent } = find(ref.root, ref.path);
if (!parent?.children) throw new ApiRequestError("forbidden", "不能操作根本身");
parent.children = parent.children.filter((c) => c !== node);
return node;
}
const clone = (n: Node): Node => ({ ...n, mtime: new Date().toISOString(), children: n.children?.map(clone) });
function walk(n: Node): Node[] {
return [n, ...(n.children ?? []).flatMap(walk)];
}
function deleteChecks(items: ItemRef[]): FileDeleteCheck[] {
return items.map((it) => {
const { node } = find(it.root, it.path);
const all = walk(node);
const dls = all
.filter((n) => n.meta?.download && (n.meta.status === "downloading" || n.meta.download.hnr !== "satisfied"))
.map((n) => n.meta?.download)
.filter((x): x is NonNullable<typeof x> => !!x)
.map((x) => ({ taskId: x.taskId, status: x.status, hnr: x.hnr }));
const downloads = [...new Map(dls.map((x) => [x.taskId, x])).values()];
const assets = all
.filter((n) => n.meta?.asset)
.map((n) => ({
assetId: n.meta?.asset?.assetId ?? "",
locationId: n.meta?.asset?.locationId ?? "",
mediaEntityId: n.meta?.asset?.mediaEntityId ?? "",
relativePath: n.name,
}));
const bindings = bindingsFor(it.root, it.path, node);
return {
root: it.root,
path: it.path,
isDir: node.dir,
size: all.reduce((s, n) => s + (n.dir ? 0 : n.size), 0),
blocked: !it.path
? "is_root"
: downloads.length
? "download_active"
: assets.length
? "needs_retire"
: bindings.length
? "needs_unbind"
: null,
downloads,
assets,
bindings,
sharedInode: node.meta?.status === "seeding",
};
});
}
function parseName(name: string) {
const base = name.replace(/\.[^.]+$/, "");
const se = /S(\d{1,2})E(\d{1,3})(?:-?E(\d{1,3}))?/i.exec(base);
const yr = /[.( ](19\d{2}|20\d{2})[.) ]/.exec(`${base} `);
const res = /(2160p|1080p|720p)/i.exec(base);
const cut = Math.min(...[se?.index, yr?.index, res?.index].filter((x): x is number => x != null), base.length);
const title = base
.slice(0, cut)
.replace(/[._]+/g, " ")
.replace(/[-( ]+$/, "")
.trim();
const grp = /-([A-Za-z0-9]+)$/.exec(base);
const eps: number[] = [];
if (se) {
const a = Number(se[2]);
const b = se[3] ? Number(se[3]) : a;
for (let i = a; i <= b; i++) eps.push(i);
}
return {
title: title || null,
year: yr ? Number(yr[1]) : null,
season: se ? Number(se[1]) : null,
episodes: eps,
resolution: res ? (res[1] as string).toLowerCase() : null,
source: /bluray/i.test(base) ? "BluRay" : /web-?dl/i.test(base) ? "WEB-DL" : null,
releaseGroup: grp ? (grp[1] as string) : null,
mediaKind: se ? ("series" as const) : ("movie" as const),
};
}
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
{ match: /severance/i, id: "e-sev", title: "人生切割术", year: 2022, kind: "series" },
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
{ match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" },
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
];
/** 本地作品搜索(向导里改选作品) */
export function mockEntitySearch(q: string) {
const t = q.trim().toLowerCase();
return KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({
id: k.id,
title: k.title,
year: k.year,
kind: k.kind,
}));
}
function identify(items: ItemRef[]): { items: FileIdentifyItem[]; truncated: boolean } {
const out: FileIdentifyItem[] = [];
for (const it of items) {
const { node } = find(it.root, it.path);
const files = node.dir
? lazyChildren(node)
.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video")
.slice(0, 200)
.map((c) => ({ path: join(it.path, c.name), name: c.name }))
: [{ path: it.path, name: node.name }];
for (const fl of files) {
const parsed = parseName(fl.name);
const hit = KNOWN.find((k) => k.match.test(parsed.title ?? ""));
const candidates = hit
? [
{
mediaEntityId: hit.id,
provider: "local",
externalId: null,
title: hit.title,
year: hit.year,
kind: hit.kind,
level: "high" as const,
score: 0.94,
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
},
{
mediaEntityId: null,
provider: "tmdb",
externalId: "99999",
title: `${parsed.title}(同名旧作)`,
year: (parsed.year ?? hit.year) - 30,
kind: hit.kind,
level: "low" as const,
score: 0.31,
reasons: ["仅标题相似"],
},
]
: [];
out.push({
root: it.root,
path: fl.path,
parsed,
candidates,
suggestion: { mediaEntityId: hit?.id ?? null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: hit ? [] : ["本地与 TMDB 都没有找到可信候选"],
});
}
}
return { items: out, truncated: false };
}
let seq = 0;
function doMove(body: {
items: ItemRef[];
target: ItemRef;
mode: "move" | "copy";
dryRun: boolean;
planId?: string;
}): FilesTransferResult {
const tRoot = rootOf(body.target.root);
const target = find(body.target.root, body.target.path).node;
if (!target.dir || !tRoot.writable)
throw new ApiRequestError("bad_request", "not_writable: 目标必须是可写库根下的目录");
const pairs = body.items.map((it) => {
const { node } = find(it.root, it.path);
const name = node.name;
const exists = (target.children ?? []).some((c) => c.name === name);
const inside =
it.root === body.target.root &&
(`${body.target.path}/`.startsWith(`${it.path}/`) || it.path === body.target.path);
return {
source: it,
target: { root: body.target.root, path: join(body.target.path, name) },
size: walk(node).reduce((s, n) => s + (n.dir ? 0 : n.size), 0),
conflict: exists || inside,
registered: walk(node).some((n) => !!n.meta?.asset),
};
});
const hnrBlocks =
body.mode === "move"
? body.items.flatMap((it) =>
walk(find(it.root, it.path).node)
.filter((n) => n.meta?.download && n.meta.download.hnr !== "satisfied")
.map((n) => ({
taskId: n.meta?.download?.taskId ?? "",
path: it.path,
hnr: n.meta?.download?.hnr ?? "unknown",
status: n.meta?.download?.status ?? "completed",
})),
)
: [];
const totalBytes = pairs.reduce((s, p) => s + p.size, 0);
const res: FilesTransferResult = {
dryRun: body.dryRun,
planId: body.planId ?? `plan-${++seq}`,
planExpiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
mode: body.mode,
pairs,
totalBytes,
freeBytes: tRoot.usage?.freeBytes ?? null,
spaceOk: true,
hnrBlocks,
truncated: false,
executed: null,
};
if (body.dryRun) return res;
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active: 含 H&R 未满足的做种文件,不能 Move");
const succeeded: string[] = [];
const failed: { path: string; code: string; message: string }[] = [];
for (const p of pairs) {
if (p.conflict) {
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
continue;
}
const n = body.mode === "move" ? detach(p.source) : clone(find(p.source.root, p.source.path).node);
target.children = [...(target.children ?? []), n];
succeeded.push(p.source.path);
}
return { ...res, executed: { succeeded, failed, recordId: `rec-${++seq}` } };
}
function organize(body: {
items: { root: string; path: string; mediaEntityId: string; season?: number; episodes?: number[] }[];
action: "copy" | "move";
dryRun: boolean;
}): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } {
const byEntity = new Map<string, typeof body.items>();
for (const it of body.items) byEntity.set(it.mediaEntityId, [...(byEntity.get(it.mediaEntityId) ?? []), it]);
const groups: OrganizeGroup[] = [];
for (const [id, items] of byEntity) {
const k = KNOWN.find((x) => x.id === id);
const tv = k?.kind === "series";
const libRoot = tv ? "/mnt/media/tv" : "/mnt/media/movies";
const dirName = id === "e-tlou" ? "The Last of Us (2023)" : `${k?.title ?? id} (${k?.year ?? ""})`;
const files = items.map((it) => {
const ext = it.path.split(".").pop();
const s = it.season != null ? `S${String(it.season).padStart(2, "0")}` : "";
const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : "";
const name = tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
const dst = tv
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
: `${libRoot}/${dirName}/${name}`;
const r = state.roots.find((x) => x.id === it.root);
const { node } = find(it.root, it.path);
return { source: `${r?.hostPath}/${it.path}`, target: dst, kind: "main", size: node.size };
});
groups.push({
mediaEntityId: id,
contentUnitId: null,
planId: `oplan-${id}`,
files,
truncated: false,
recordId: body.dryRun ? null : `rec-${++seq}`,
taskId: body.dryRun ? null : `task-${seq}`,
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
error: null,
});
}
return {
dryRun: body.dryRun,
groups,
recordIds: groups.map((g) => g.recordId).filter((x): x is string => !!x),
};
}
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** 与后端同路径同形状的内存实现 */
export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown> {
await delay(120 + Math.random() * 160);
const p = (arg.params ?? {}) as Record<string, unknown>;
// biome-ignore lint/suspicious/noExplicitAny: mock 请求体按端点解释
const b = (arg.body ?? {}) as any;
switch (path) {
case "/files/roots":
return { items: state.roots };
case "/files":
return list(p);
case "/files/stat":
return stat(p);
case "/files/mkdir": {
const { node } = find(b.root, b.path);
if ((node.children ?? []).some((c) => c.name === b.name))
throw new ApiRequestError("conflict", "exists: 同名目录已存在");
node.children = [...(node.children ?? []), d(b.name, 0, [])];
return { root: b.root, path: join(b.path, b.name) };
}
case "/files/rename": {
const { node, parent } = find(b.root, b.path);
if (node.meta?.download)
throw new ApiRequestError("conflict", "download_active: 做种中,改名会让下载器找不到文件");
if ((parent?.children ?? []).some((c) => c.name === b.newName && c !== node))
throw new ApiRequestError("conflict", "exists: 目标名已存在,不覆盖");
const stem = node.name.replace(/\.[^.]+$/, "");
const newStem = String(b.newName).replace(/\.[^.]+$/, "");
const dirPath = String(b.path).split("/").slice(0, -1).join("/");
const side =
node.dir || !b.withSidecars
? []
: (parent?.children ?? []).filter((c) => c !== node && c.name.startsWith(`${stem}.`));
const renames = [
{ from: b.path, to: join(dirPath, b.newName), role: "main" as const },
...side.map((c) => ({
from: join(dirPath, c.name),
to: join(dirPath, newStem + c.name.slice(stem.length)),
role: "sidecar" as const,
})),
];
const assetLocations =
walk(node).filter((n) => n.meta?.asset).length + side.filter((c) => c.meta?.status === "registered").length;
const bindings = bindingsFor(b.root, b.path, node).map((x) => ({
oldBindingId: x.bindingId,
newBindingId: null,
}));
if (!b.dryRun) {
node.name = b.newName;
for (const c of side) c.name = newStem + c.name.slice(stem.length);
}
return { dryRun: !!b.dryRun, renames, assetLocations, bindings, recordId: b.dryRun ? null : `rec-${++seq}` };
}
case "/files/move":
return doMove(b);
case "/files/delete": {
const checks = deleteChecks(b.items).map((c) => ({
...c,
blocked:
c.blocked === "needs_retire" && b.retireAssets
? c.bindings.length && !b.unbind
? ("needs_unbind" as const)
: null
: c.blocked === "needs_unbind" && b.unbind
? null
: c.blocked,
}));
const base = {
dryRun: !!b.dryRun,
planId: b.dryRun ? `dplan-${++seq}` : (b.planId ?? null),
planExpiresAt: null,
mode: b.mode ?? "trash",
checks,
trashed: [],
deleted: [],
failed: [],
recordId: null,
};
if (b.dryRun) return base;
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active: 有被引用项,不能删除");
const trashed: TrashItem[] = [];
for (const it of b.items as ItemRef[]) {
const n = detach(it);
const r = rootOf(it.root);
if (b.mode !== "permanent") {
const t: TrashItem = {
id: `tr-${++seq}`,
rootId: it.root,
originalPath: it.path,
trashPath: `${r.hostPath}/.umt-trash/20260927/${seq}/${it.path}`,
isDir: n.dir,
size: walk(n).reduce((s, x) => s + (x.dir ? 0 : x.size), 0),
deletedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
actor: "w31-admin",
assetSnapshot: n.meta?.asset ? { title: n.meta.asset.title, contentUnit: n.meta.asset.contentUnit } : null,
};
trashed.push(t);
trash.unshift(t);
}
}
return { ...base, trashed, deleted: (b.items as ItemRef[]).map((x) => x.path), recordId: `rec-${++seq}` };
}
case "/files/trash":
return { items: trash, nextCursor: null };
case "/files/trash/restore": {
const restored: { id: string; root: string; path: string }[] = [];
const failed: { id: string; code: string; message: string }[] = [];
for (const t of trash.filter((x) => b.ids.includes(x.id))) {
const name = t.originalPath.split("/").pop() ?? t.originalPath;
const parentPath = b.target ? b.target.path : t.originalPath.split("/").slice(0, -1).join("/");
const rootId = b.target ? b.target.root : t.rootId;
try {
const { node } = find(rootId, parentPath);
if ((node.children ?? []).some((c) => c.name === name)) {
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
continue;
}
node.children = [...(node.children ?? []), t.isDir ? d(name, 1, []) : f(name, t.size, 1)];
restored.push({ id: t.id, root: rootId, path: join(parentPath, name) });
} catch (e) {
failed.push({ id: t.id, code: "not_found", message: e instanceof Error ? e.message : "还原失败" });
}
}
const ok = new Set(restored.map((r) => r.id));
trash = trash.filter((x) => !ok.has(x.id));
return { restored, failed };
}
case "/files/trash/purge": {
const ids: string[] = b.ids ?? trash.filter((x) => Date.parse(x.expiresAt) <= Date.now()).map((x) => x.id);
trash = trash.filter((x) => !ids.includes(x.id));
return { purged: ids, failed: [] };
}
case "/files/identify":
await delay(500);
return identify(b.items);
case "/files/organize":
await delay(400);
return organize(b);
case "/files/scrape":
return {
mediaEntityId: b.mediaEntityId,
taskId: `run-${++seq}`,
lockedFields: ["title"],
nfo: b.writeNfo ? "not_implemented" : "not_requested",
images: b.images ? "not_implemented" : "not_requested",
};
case "/files/probe":
await delay(600);
return {
items: (b.items as ItemRef[]).map((it) => ({
...it,
status: "ok",
reason: null,
summary: uhdInfo,
container: "matroska",
subtitleTracks: 4,
savedToAsset: false,
cached: true,
})),
};
default:
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
}
}
export async function mockPreview(root: string, path: string): Promise<string> {
await delay(100);
if (path.endsWith(".nfo"))
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘2</title>\n <year>2024</year>\n</movie>`;
if (/\.(srt|ass)$/.test(path))
return "1\n00:00:01,000 --> 00:00:04,000\n厄拉科斯……\n\n2\n00:00:05,000 --> 00:00:07,500\n一切都从这里开始。";
return `(${root}:${path})`;
}
+249
View File
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import { resolveFilesMock } from "~/api/files.ts";
import {
clickSelect,
collapseCrumbs,
deleteBlockText,
emptySelection,
entryStatusView,
formatEpisodes,
moveFocus,
nameProblem,
opDisabledReason,
parseEpisodes,
pruneSelection,
selectAll,
shiftEpisodes,
splitCommon,
stemRange,
suggestName,
} from "./model.ts";
import { rowProblem, rowsFromIdentify, setRowEntity, type WizardRow } from "./organize-wizard.tsx";
const order = ["a", "b", "c", "d", "e"];
describe("选择模型", () => {
it("单击单选,Ctrl 切换,Shift 区间", () => {
let s = clickSelect(emptySelection(), order, "b", {});
expect([...s.keys]).toEqual(["b"]);
s = clickSelect(s, order, "d", { shift: true });
expect([...s.keys]).toEqual(["b", "c", "d"]);
s = clickSelect(s, order, "a", { shift: true });
expect([...s.keys]).toEqual(["a", "b"]);
s = clickSelect(s, order, "e", { ctrl: true });
expect([...s.keys].sort()).toEqual(["a", "b", "e"]);
s = clickSelect(s, order, "e", { ctrl: true });
expect([...s.keys].sort()).toEqual(["a", "b"]);
});
it("Shift+Ctrl 区间并入已有选择", () => {
let s = clickSelect(emptySelection(), order, "a", {});
s = clickSelect(s, order, "c", { ctrl: true });
s = clickSelect(s, order, "e", { shift: true, ctrl: true });
expect([...s.keys].sort()).toEqual(["a", "c", "d", "e"]);
});
it("方向键移动焦点,Shift 扩选,边界夹住", () => {
let s = moveFocus(emptySelection(), order, 1, false);
expect(s.focus).toBe("a");
s = moveFocus({ ...s, anchor: "a", keys: new Set(["a"]) }, order, 2, true);
expect([...s.keys]).toEqual(["a", "b", "c"]);
s = moveFocus(s, order, 99, false);
expect(s.focus).toBe("e");
s = moveFocus(s, order, -99, false);
expect(s.focus).toBe("a");
});
it("全选与刷新后剪枝", () => {
const s = selectAll(order, emptySelection());
expect(s.keys.size).toBe(5);
const p = pruneSelection({ ...s, focus: "e", anchor: "z" }, ["a", "e"]);
expect([...p.keys]).toEqual(["a", "e"]);
expect(p.anchor).toBeNull();
expect(p.focus).toBe("e");
});
});
const base = {
name: "x.mkv",
path: "x.mkv",
type: "file" as const,
size: 1,
mtime: null,
ext: "mkv",
fileKind: "video" as const,
status: "plain" as const,
escape: false,
asset: null,
download: null,
media: null,
dirSummary: null,
};
describe("状态与禁用原因", () => {
it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => {
expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已登记");
expect(
entryStatusView({
...base,
status: "seeding",
download: { taskId: "t", status: "completed", progress: 1, hnr: "unsatisfied" },
}),
).toMatchObject({ tone: "warning" });
expect(
entryStatusView({
...base,
status: "downloading",
download: { taskId: "t", status: "downloading", progress: 0.425, hnr: "unknown" },
})?.label,
).toBe("下载中 42%");
expect(entryStatusView({ ...base, escape: true })?.tone).toBe("destructive");
expect(entryStatusView(base)).toBeNull();
});
it("做种中不能改名/删除;只读登记根不能移动;未登记不能刮削", () => {
const lib = { kind: "library" as const, writable: true };
const seeding = {
...base,
status: "seeding" as const,
download: { taskId: "t", status: "completed" as const, progress: 1, hnr: "unsatisfied" as const },
};
expect(opDisabledReason("rename", seeding, lib)).toMatch(/做种/);
expect(opDisabledReason("delete", seeding, lib)).toMatch(/下载任务/);
expect(opDisabledReason("move", seeding, lib)).toMatch(/H&R/);
expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/);
expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull();
expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/);
expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull();
expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/根之外/);
});
it("删除阻断分软硬", () => {
expect(deleteBlockText("needs_retire")?.soft).toBe(true);
expect(deleteBlockText("download_active")?.soft).toBe(false);
expect(deleteBlockText(null)).toBeNull();
});
});
describe("路径与名称", () => {
it("面包屑折叠保留根与末两段", () => {
expect(collapseCrumbs([1, 2, 3, 4, 5], 3)).toEqual([1, null, 4, 5]);
expect(collapseCrumbs([1, 2], 3)).toEqual([1, 2]);
});
it("名称校验与改名默认选中主名", () => {
expect(nameProblem("a/b")).toBeTruthy();
expect(nameProblem("..")).toBeTruthy();
expect(nameProblem(" ok.mkv ")).toBeNull();
expect(stemRange("Movie (2024).mkv", false)).toEqual([0, 12]);
expect(stemRange(".hidden", false)).toEqual([0, 7]);
expect(stemRange("dir.name", true)).toEqual([0, 8]);
});
it("前后路径按段取公共前缀", () => {
expect(splitCommon("/a/b/c.mkv", "/a/b/d.mkv")).toEqual({ common: "/a/b/", a: "c.mkv", b: "d.mkv" });
expect(splitCommon("/x/1.mkv", "/y/1.mkv").common).toBe("/");
});
it("集号解析/格式化/偏移", () => {
expect(parseEpisodes("3-5")).toEqual([3, 4, 5]);
expect(parseEpisodes("3,7")).toEqual([3, 7]);
expect(parseEpisodes("x")).toBeNull();
expect(parseEpisodes("")).toEqual([]);
expect(formatEpisodes([5, 3, 4])).toBe("3-5");
expect(formatEpisodes([1, 3])).toBe("1,3");
expect(shiftEpisodes([1, 2], -1)).toEqual([1, 1]);
expect(shiftEpisodes([1, 2], 12)).toEqual([13, 14]);
});
it("建议名:电影与剧集", () => {
expect(
suggestName({ title: "沙丘2", year: 2024, kind: "movie" }, { season: null, episodes: [] }, "a.mkv", false),
).toBe("沙丘2 (2024).mkv");
expect(
suggestName({ title: "人生切割术", year: 2022, kind: "series" }, { season: 2, episodes: [1] }, "a.mkv", false),
).toBe("人生切割术 (2022) - S02E01.mkv");
expect(suggestName({ title: "A/B", year: null, kind: "movie" }, { season: null, episodes: [] }, "d", true)).toBe(
"A B",
);
});
});
const item = (path: string, over: Partial<{ id: string | null; level: "high" | "low" }> = {}) => ({
root: "lib:x",
path,
parsed: {
title: "T",
year: null,
season: 1,
episodes: [2],
resolution: null,
source: null,
releaseGroup: null,
mediaKind: "series" as const,
},
candidates:
over.id === null
? []
: [
{
mediaEntityId: over.id ?? "e1",
provider: "local",
externalId: null,
title: "T",
year: 2020,
kind: "series" as const,
level: over.level ?? ("high" as const),
score: 1,
reasons: [],
},
],
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: [],
});
describe("整理向导门禁", () => {
it("高置信自动选中;低置信/无候选为未决,排除后放行", () => {
const rows = rowsFromIdentify(
[item("d/a.mkv"), item("d/b.mkv", { level: "low" }), item("e/c.mkv", { id: null })],
new Set(),
);
expect(rows[0]?.entity?.id).toBe("e1");
expect(rows[0]?.episodes).toBe("2");
expect(rowProblem(rows[0] as WizardRow)).toBeNull();
expect(rowProblem(rows[1] as WizardRow)).toMatch(/作品/);
expect(rowProblem({ ...(rows[2] as WizardRow), excluded: true })).toBeNull();
});
it("改作品时同目录未手动改过的行跟随,别的目录不动", () => {
let rows = rowsFromIdentify(
[item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null }), item("e/c.mkv", { id: null })],
new Set(),
);
const e = { id: "e9", title: "X", year: 2001, kind: "series" };
rows = setRowEntity(rows, rows[0]?.key as string, e);
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e9", "e9", null]);
rows = setRowEntity(rows, rows[1]?.key as string, { ...e, id: "e8" });
rows = setRowEntity(rows, rows[0]?.key as string, { ...e, id: "e7" });
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e7", "e8", null]);
});
it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => {
const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"]));
expect(rows[0]?.hnrBlocked).toBe(true);
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
});
});
describe("演示开关", () => {
it("只在开发构建生效,?mock=1 记住,?mock=0 清掉", () => {
const store = new Map<string, string>();
const storage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
expect(resolveFilesMock({ dev: false, search: "?mock=1", storage })).toBe(false);
expect(resolveFilesMock({ dev: true, search: "?mock=1", storage })).toBe(true);
expect(resolveFilesMock({ dev: true, search: "", storage })).toBe(true);
expect(resolveFilesMock({ dev: true, search: "?mock=0", storage })).toBe(false);
});
});
+312
View File
@@ -0,0 +1,312 @@
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// ─── 状态徽标 ────────────────────────────────────────────────────
export type StatusView = { label: string; tone: StatusTone; hint?: string };
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
if (e.escape) return { label: "链接逃逸", tone: "destructive", hint: "符号链接指向登记根之外,已禁用所有操作" };
const s: FileStatus = e.status;
switch (s) {
case "temp":
return { label: "临时文件", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
case "downloading":
return {
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
tone: "neutral",
};
case "registered":
return { label: "已登记", tone: "success" };
case "seeding":
return e.download?.hnr === "unsatisfied"
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 未满足前不能改名、删除或 Move" }
: { label: "做种中", tone: "neutral" };
case "unidentified":
return { label: "待识别", tone: "warning" };
default:
return null;
}
}
export const FILTER_CHIPS = [
{ value: "", label: "全部" },
{ value: "video", label: "视频" },
{ value: "unidentified", label: "待识别" },
{ value: "registered", label: "已登记" },
{ value: "downloading", label: "下载中" },
{ value: "temp", label: "临时文件" },
] as const;
export const SORT_OPTIONS = [
{ value: "name", label: "名称" },
{ value: "mtime", label: "修改时间" },
{ value: "size", label: "大小" },
{ value: "status", label: "状态" },
] as const;
// ─── 禁用原因(菜单 tooltip 用) ───────────────────────────────────
export type FileOp =
| "open"
| "rename"
| "move"
| "copy"
| "organize"
| "identify"
| "scrape"
| "probe"
| "delete"
| "copyPath"
| "openMedia"
| "records";
/** 返回 null = 可用;否则为禁用原因 */
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
if (e.escape && op !== "copyPath") return "链接指向登记根之外,已禁用";
// 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项)
const rootWritable = root.writable || root.kind === "download";
const seeding = e.status === "seeding" || e.status === "downloading";
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
switch (op) {
case "rename":
if (!rootWritable) return "只读根,不能改名";
if (seeding) return "做种/下载中,改名会让下载器找不到文件";
if (e.status === "temp") return "临时文件不能改名";
return null;
case "move":
if (!rootWritable) return "只读登记根,不能移出(可用“复制到…”)";
if (hnrBlocked) return "做种中且 H&R 未满足,不能 Move";
return null;
case "delete":
if (!rootWritable) return "只读根,不能删除";
if (seeding) return "纳管下载任务仍在使用,不能删除";
return null;
case "organize":
case "identify":
if (e.status === "temp") return "临时文件不能整理";
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录可识别/整理";
return null;
case "scrape":
return e.asset ? null : "未登记,请先识别并整理入库";
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "openMedia":
return e.asset ? null : "未关联作品";
case "records":
return e.asset || e.status === "registered" ? null : "没有整理记录";
default:
return null;
}
}
// ─── 选择模型(Shift 区间 / Ctrl 切换 / 全选) ─────────────────────
export type Selection = { keys: Set<string>; anchor: string | null; focus: string | null };
export const emptySelection = (): Selection => ({ keys: new Set(), anchor: null, focus: null });
/** 点击一行:plain 单选;ctrl 切换;shift 从锚点到当前的区间(与 ctrl 同按则并入) */
export function clickSelect(
sel: Selection,
order: string[],
key: string,
mod: { shift?: boolean; ctrl?: boolean },
): Selection {
if (mod.shift && sel.anchor && order.includes(sel.anchor)) {
const a = order.indexOf(sel.anchor);
const b = order.indexOf(key);
const [lo, hi] = a < b ? [a, b] : [b, a];
const range = order.slice(lo, hi + 1);
const keys = mod.ctrl ? new Set([...sel.keys, ...range]) : new Set(range);
return { keys, anchor: sel.anchor, focus: key };
}
if (mod.ctrl) {
const keys = new Set(sel.keys);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { keys, anchor: key, focus: key };
}
return { keys: new Set([key]), anchor: key, focus: key };
}
/** Space:切换焦点行的勾选 */
export function toggleSelect(sel: Selection, key: string): Selection {
const keys = new Set(sel.keys);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { keys, anchor: key, focus: key };
}
export function selectAll(order: string[], sel: Selection): Selection {
return { keys: new Set(order), anchor: sel.anchor ?? order[0] ?? null, focus: sel.focus ?? order[0] ?? null };
}
/** ↑↓ 移动焦点;shift 时从锚点扩选 */
export function moveFocus(sel: Selection, order: string[], delta: number, shift: boolean): Selection {
if (order.length === 0) return sel;
const cur = sel.focus ? order.indexOf(sel.focus) : -1;
const next = Math.max(0, Math.min(order.length - 1, cur < 0 ? (delta > 0 ? 0 : order.length - 1) : cur + delta));
const key = order[next] as string;
if (shift) return clickSelect(sel.anchor ? sel : { ...sel, anchor: sel.focus ?? key }, order, key, { shift: true });
return { ...sel, focus: key };
}
/** 列表刷新后丢掉已不存在的键 */
export function pruneSelection(sel: Selection, order: string[]): Selection {
const present = new Set(order);
const keys = new Set([...sel.keys].filter((k) => present.has(k)));
return {
keys,
anchor: sel.anchor && present.has(sel.anchor) ? sel.anchor : null,
focus: sel.focus && present.has(sel.focus) ? sel.focus : null,
};
}
// ─── 路径工具(根内相对路径,"" = 根) ─────────────────────────────
export function joinRel(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name;
}
export function parentRel(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? "" : path.slice(0, i);
}
export function baseName(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? path : path.slice(i + 1);
}
export function segments(path: string): string[] {
return path ? path.split("/").filter(Boolean) : [];
}
/** 面包屑折叠:超过 max 段时保留首段(根)+ … + 最后 max-1 段 */
export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
if (items.length <= max) return items;
return [items[0] as T, null, ...items.slice(items.length - (max - 1))];
}
/** 名称校验(前端先挡一遍,服务端 sanitizeSegment 才是准绳) */
export function nameProblem(name: string): string | null {
const n = name.trim();
if (!n) return "名称不能为空";
if (n === "." || n === "..") return "不能用 . 或 ..";
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
if (n.length > 255) return "名称过长";
return null;
}
/** 改名时默认只选中主名(不含扩展名) */
export function stemRange(name: string, isDir: boolean): [number, number] {
if (isDir) return [0, name.length];
const i = name.lastIndexOf(".");
return [0, i > 0 ? i : name.length];
}
/** 选中项合计大小(目录不计) */
export function totalSize(entries: FileEntry[]): number {
return entries.reduce((s, e) => s + (e.type === "file" ? e.size : 0), 0);
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
}
/** 集号偏移:episodes 全体加 n(不小于 1) */
export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined {
if (!eps?.length) return eps;
return eps.map((e) => Math.max(1, e + n));
}
/** "3" / "3-5" / "3,4" → [3] / [3,4,5] / [3,4];非法返回 null */
export function parseEpisodes(text: string): number[] | null {
const t = text.trim();
if (!t) return [];
const out: number[] = [];
for (const part of t.split(/[,\s]+/)) {
const m = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(part);
if (!m) return null;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
if (b < a || b - a > 200) return null;
for (let i = a; i <= b; i++) out.push(i);
}
return out;
}
export function formatEpisodes(eps: number[] | undefined | null): string {
if (!eps?.length) return "";
const sorted = [...eps].sort((a, b) => a - b);
const first = sorted[0] as number;
const last = sorted[sorted.length - 1] as number;
if (sorted.length > 1 && last - first === sorted.length - 1) return `${first}-${last}`;
return sorted.join(",");
}
export function contentUnitLabel(season?: number | null, episodes?: number[] | null): string {
if (season == null) return "";
const s = `S${String(season).padStart(2, "0")}`;
if (!episodes?.length) return s;
const sorted = [...episodes].sort((a, b) => a - b);
const e = (n: number) => `E${String(n).padStart(2, "0")}`;
return sorted.length > 1
? `${s}${e(sorted[0] as number)}-${e(sorted[sorted.length - 1] as number)}`
: `${s}${e(sorted[0] as number)}`;
}
/** 前后路径对照:按 / 段取公共前缀,差异部分单独高亮 */
export function splitCommon(a: string, b: string): { common: string; a: string; b: string } {
const sa = a.split("/");
const sb = b.split("/");
let i = 0;
while (i < sa.length - 1 && i < sb.length - 1 && sa[i] === sb[i]) i++;
const common = i ? `${sa.slice(0, i).join("/")}/` : "";
return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") };
}
/** 识别建议名:电影 "标题 (年).ext",剧集 "标题 - S01E02.ext" */
export function suggestName(
cand: { title: string; year: number | null; kind: string },
parsed: { season: number | null; episodes: number[] },
currentName: string,
isDir: boolean,
): string {
const ext = isDir ? "" : (/\.[A-Za-z0-9]{1,6}$/.exec(currentName)?.[0] ?? "");
const base = `${cand.title}${cand.year ? ` (${cand.year})` : ""}`.replace(/[/\\:*?"<>|]/g, " ").trim();
if (isDir) return base;
if (cand.kind !== "movie" && parsed.season != null)
return `${base} - ${contentUnitLabel(parsed.season, parsed.episodes)}${ext}`;
return `${base}${ext}`;
}
/** 删除阻断原因文案;可通过勾选解除的返回 soft=true */
export function deleteBlockText(b: string | null): { text: string; soft: boolean } | null {
switch (b) {
case null:
return null;
case "download_active":
return { text: "纳管下载任务仍在做种或 H&R 未满足", soft: false };
case "is_root":
return { text: "不能删除根本身", soft: false };
case "needs_retire":
return { text: "含已登记资产,需勾选“同时退役资产”", soft: true };
case "needs_unbind":
return { text: "是作品/季的绑定目录,需勾选“解除绑定”", soft: true };
case "root_unavailable":
return { text: "根离线,不能确认文件状态", soft: false };
case "not_found":
return { text: "文件已不存在", soft: false };
case "escape":
return { text: "链接指向根外", soft: false };
default:
return { text: b, soft: false };
}
}
@@ -0,0 +1,856 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
FileStack,
LoaderCircle,
Search,
TriangleAlert,
Undo2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileIdentifyCandidate,
type FileIdentifyItem,
type FileRoot,
type FilesOrganizeGroup,
filesErrorText,
filesMockEnabled,
useIdentify,
useOrganizeFiles,
} from "~/api/files.ts";
import { useEntitySearch } from "~/api/records.ts";
import { useSlots } from "~/api/slots.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { PathPair } from "./dialogs.tsx";
import {
baseName,
contentUnitLabel,
formatEpisodes,
newIdempotencyKey,
parentRel,
parseEpisodes,
shiftEpisodes,
} from "./model.ts";
type Entity = { id: string; title: string; year: number | null; kind: string };
export type WizardRow = {
key: string;
root: string;
path: string;
item: FileIdentifyItem;
entity: Entity | null;
/** 用户手动改过作品(同组联动时不覆盖) */
touched: boolean;
season: string;
episodes: string;
slotId: string;
excluded: boolean;
/** 同目录分组键 */
group: string;
hnrBlocked: boolean;
};
const LEVEL: Record<string, { label: string; tone: "success" | "warning" | "neutral" }> = {
high: { label: "高", tone: "success" },
medium: { label: "中", tone: "neutral" },
low: { label: "低", tone: "warning" },
};
const STEPS = ["识别", "选项", "预览", "执行"] as const;
/** 行是否仍未决(必须处理或排除才能下一步) */
export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
if (parseEpisodes(r.episodes) === null) return "集号格式不对(如 3、3-5、3,4)";
if (r.season && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (r.entity.kind !== "movie" && !r.season.trim()) return "剧集需要季号";
return null;
}
export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string>, rootKey = ""): WizardRow[] {
return items.map((it) => {
const top = it.candidates[0];
const auto =
top?.mediaEntityId && (top.level === "high" || it.suggestion.mediaEntityId === top.mediaEntityId)
? { id: top.mediaEntityId, title: top.title, year: top.year, kind: top.kind }
: null;
return {
key: `${it.root}\u0000${it.path}${rootKey}`,
root: it.root,
path: it.path,
item: it,
entity: auto,
touched: false,
season: it.parsed.season != null ? String(it.parsed.season) : "",
episodes: formatEpisodes(it.parsed.episodes),
slotId: "",
excluded: false,
group: `${it.root}\u0000${parentRel(it.path)}`,
hnrBlocked: [...hnrPaths].some((p) => it.path === p || it.path.startsWith(`${p}/`)),
};
});
}
/** 改一行的作品:同组中未手动改过的行跟随 */
export function setRowEntity(rows: WizardRow[], key: string, entity: Entity | null, propagate = true): WizardRow[] {
const target = rows.find((r) => r.key === key);
if (!target) return rows;
return rows.map((r) => {
if (r.key === key) return { ...r, entity, touched: true };
if (propagate && r.group === target.group && !r.touched && !r.excluded) return { ...r, entity };
return r;
});
}
/**
* 批量整理向导(全高 Sheet,4 步):识别 → 选项 → dryRun 预览 → 执行。
* 逐项可改作品 / 季 / 集 / Slot / 排除;按作品分组预览,单组失败不影响其他组。
*/
export function OrganizeWizard({
roots,
entries,
rootId,
onClose,
}: {
roots: FileRoot[];
entries: FileEntry[];
rootId: string;
onClose: () => void;
}) {
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [rows, setRows] = useState<WizardRow[]>([]);
const [action, setAction] = useState<"copy" | "move">("copy");
const [targetRoot, setTargetRoot] = useState<string>("");
const [preview, setPreview] = useState<FilesOrganizeGroup[] | null>(null);
const [result, setResult] = useState<{ groups: FilesOrganizeGroup[]; recordIds: string[] } | null>(null);
const [offset, setOffset] = useState("1");
const identify = useIdentify();
const dry = useOrganizeFiles();
const exec = useOrganizeFiles();
const execKey = useMemo(() => newIdempotencyKey(), []);
const refs = useMemo(() => entries.map((e) => ({ root: rootId, path: e.path })), [entries, rootId]);
const hnrPaths = useMemo(
() =>
new Set(
entries
.filter((e) => e.download && (e.download.hnr !== "satisfied" || e.status === "downloading"))
.map((e) => e.path),
),
[entries],
);
const identifyMutate = identify.mutate;
useEffect(() => {
identifyMutate(
{ items: refs },
{
onSuccess: (r) => setRows(rowsFromIdentify(r.items, hnrPaths)),
onError: (e) => toast.error(filesErrorText(e, "识别失败")),
},
);
}, [identifyMutate, refs, hnrPaths]);
const active = rows.filter((r) => !r.excluded);
const problems = rows.filter((r) => rowProblem(r));
const anyHnr = active.some((r) => r.hnrBlocked);
const entityKinds = new Set(active.map((r) => r.entity?.kind).filter(Boolean));
const libRoots = roots.filter(
(r) =>
r.kind === "library" &&
r.writable &&
r.health !== "offline" &&
(r.mediaKind === "mixed" ||
r.mediaKind === "other" ||
[...entityKinds].every((k) => (k === "movie" ? r.mediaKind === "movie" : r.mediaKind !== "movie"))),
);
const entityTitle = new Map(active.filter((r) => r.entity).map((r) => [r.entity?.id, r.entity as Entity]));
const body = (dryRun: boolean, planIds?: string[]) => ({
idempotencyKey: dryRun ? `${execKey}-dry-${Date.now()}` : execKey,
action,
targetLibraryRootId: targetRoot ? targetRoot.replace(/^lib:/, "") : undefined,
dryRun,
planIds,
items: active.map((r) => ({
root: r.root,
path: r.path,
mediaEntityId: r.entity?.id ?? "",
season: r.season.trim() ? Number(r.season) : undefined,
episodes: parseEpisodes(r.episodes)?.length ? (parseEpisodes(r.episodes) ?? undefined) : undefined,
slotId: r.slotId || undefined,
})),
});
const runPreview = () => {
setPreview(null);
setStep(2);
dry.mutate(body(true), {
onSuccess: (r) => setPreview(r.groups),
onError: (e) => toast.error(filesErrorText(e, "预览失败")),
});
};
const okGroups = preview?.filter((g) => g.planId && !g.error) ?? [];
const runExec = () => {
setStep(3);
exec.mutate(
body(
false,
okGroups.map((g) => g.planId as string),
),
{
onSuccess: (r) => setResult({ groups: r.groups, recordIds: r.recordIds }),
onError: (e) => {
toast.error(filesErrorText(e, "执行失败"));
setStep(2);
},
},
);
};
const update = (key: string, patch: Partial<WizardRow>) =>
setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const applyOffset = () => {
const n = Number(offset);
if (!Number.isFinite(n) || !n) return;
setRows((rs) =>
rs.map((r) => {
if (r.excluded) return r;
const eps = parseEpisodes(r.episodes);
return eps?.length ? { ...r, episodes: formatEpisodes(shiftEpisodes(eps, n)) } : r;
}),
);
};
const recordsHref =
result && result.recordIds.length === 1
? `/console/organize/${result.recordIds[0]}`
: `/console/organize?ids=${result?.recordIds.join(",") ?? ""}`;
return (
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-5xl">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="text-lead">整理向导</SheetTitle>
<SheetDescription>
{entries.length === 1 ? entries[0]?.name : `${entries.length} 项`} ·
确认后才落库;与自动整理产出同格式的整理记录。
</SheetDescription>
<ol className="mt-3 flex flex-wrap items-center gap-1.5" aria-label="步骤">
{STEPS.map((s, i) => (
<li key={s} className="flex items-center gap-1.5">
{i > 0 ? <span aria-hidden className="h-px w-4 bg-border" /> : null}
<span
aria-current={step === i ? "step" : undefined}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-meta",
step === i
? "pill-selected text-foreground"
: i < step
? "border-border text-foreground"
: "border-border text-muted-foreground",
)}
>
{i < step ? <Check className="h-3 w-3" /> : <span className="tabular-nums">{i + 1}</span>}
{s}
</span>
</li>
))}
</ol>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{step === 0 ? (
identify.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 识别中:解析文件名并匹配本地作品与 TMDB…
</p>
) : identify.error ? (
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-caption">
<StatusBadge>{rows.length} 个视频</StatusBadge>
{problems.length ? (
<StatusBadge tone="warning">{problems.length} 项未决:选作品或排除</StatusBadge>
) : (
<StatusBadge tone="success">全部已确认</StatusBadge>
)}
{identify.data?.truncated ? (
<StatusBadge tone="warning">目录内视频过多,只取前 200 个</StatusBadge>
) : null}
<span className="ml-auto flex items-center gap-1.5">
集号偏移
<Input
value={offset}
onChange={(e) => setOffset(e.target.value)}
className="h-8 w-16 text-center"
aria-label="集号偏移量"
/>
<Button size="sm" variant="outline" onClick={applyOffset}>
应用到全部
</Button>
</span>
</div>
<ul className="space-y-2">
{rows.map((r) => (
<IdentifyRow
key={r.key}
row={r}
onEntity={(e) => setRows((rs) => setRowEntity(rs, r.key, e))}
onApplyGroup={() =>
r.entity &&
setRows((rs) =>
setRowEntity(
rs.map((x) => (x.group === r.group ? { ...x, touched: false } : x)),
r.key,
r.entity,
),
)
}
groupSize={rows.filter((x) => x.group === r.group).length}
onChange={(p) => update(r.key, p)}
/>
))}
</ul>
</div>
)
) : null}
{step === 1 ? (
<div className="max-w-2xl space-y-5">
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">动作</h3>
<div className="grid gap-2 sm:grid-cols-2">
{(["copy", "move"] as const).map((a) => {
const disabled = a === "move" && anyHnr;
return (
<button
key={a}
type="button"
disabled={disabled}
onClick={() => setAction(a)}
aria-pressed={action === a}
className={cn(
"rounded-xl border px-3 py-2.5 text-left transition-colors duration-micro disabled:cursor-not-allowed disabled:opacity-50",
action === a ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
<span className="block text-body font-medium">{a === "copy" ? "Copy" : "Move"}</span>
<span className="block text-meta text-muted-foreground">
{a === "copy"
? "保留源文件,做种不受影响"
: disabled
? "含做种中且 H&R 未满足的文件,不能 Move"
: "入库后删源;跨卷为 Copy → 核验 → 删源"}
</span>
</button>
);
})}
</div>
</section>
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">目标库根</h3>
<div className="space-y-1.5">
<RootOption
checked={!targetRoot}
onSelect={() => setTargetRoot("")}
title="自动"
hint="按作品已有路径绑定或类型选择"
/>
{libRoots.map((r) => (
<RootOption
key={r.id}
checked={targetRoot === r.id}
onSelect={() => setTargetRoot(r.id)}
title={r.name}
hint={`${r.hostPath}${r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}`}
/>
))}
</div>
</section>
<section className="space-y-1 rounded-xl border border-border px-3 py-2.5 text-caption text-muted-foreground">
<p>命名:按系统设置里的命名模板重命名;已绑定的作品/季沿用原目录。</p>
<p>目标已存在:不覆盖,列为冲突进待处理。</p>
</section>
</div>
) : null}
{step === 2 ? (
dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成整理计划…
</p>
) : preview ? (
<div className="space-y-4">
{preview.map((g) => (
<GroupCard
key={`${g.mediaEntityId}-${g.contentUnitId}`}
g={g}
title={entityTitle.get(g.mediaEntityId)}
/>
))}
</div>
) : (
<p className="text-caption text-destructive">{filesErrorText(dry.error, "预览失败")}</p>
)
) : null}
{step === 3 ? (
exec.isPending || !result ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 提交整理任务…
</p>
) : (
<div className="space-y-3">
{result.groups.map((g) => {
const t = entityTitle.get(g.mediaEntityId);
return (
<div
key={`${g.mediaEntityId}-${g.contentUnitId}`}
className="flex flex-wrap items-center gap-2 rounded-xl border border-border px-3 py-2.5"
>
{g.error ? (
<StatusBadge tone="destructive">失败</StatusBadge>
) : g.recordId ? (
<StatusBadge tone="success">已提交</StatusBadge>
) : (
<StatusBadge>未执行</StatusBadge>
)}
<span className="text-body">
{t ? `${t.title}${t.year ? ` (${t.year})` : ""}` : g.mediaEntityId}
</span>
<span className="text-meta text-muted-foreground">{g.files.length} 个文件</span>
{g.error ? <span className="w-full text-meta text-destructive">{g.error.message}</span> : null}
{g.recordId ? (
<Link className="ml-auto text-meta underline" to={`/console/organize/${g.recordId}`}>
整理记录
</Link>
) : null}
</div>
);
})}
<p className="text-caption text-muted-foreground">
整理在后台执行(逐文件可恢复),完成后列表会自动刷新状态。
</p>
</div>
)
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--shell-border)] px-5 py-3">
<div>
{step > 0 && step < 3 ? (
<Button variant="ghost" onClick={() => setStep(step === 2 ? 0 : step - 1)}>
<ArrowLeft />
{step === 2 ? "返回修改" : "上一步"}
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
{step === 0 ? (
<Button onClick={() => setStep(1)} disabled={!rows.length || problems.length > 0 || active.length === 0}>
下一步
<ArrowRight />
</Button>
) : null}
{step === 1 ? (
<Button onClick={runPreview}>
生成预览
<ArrowRight />
</Button>
) : null}
{step === 2 ? (
<Button onClick={runExec} disabled={!okGroups.length || dry.isPending}>
执行 {okGroups.length} 组
<ArrowRight />
</Button>
) : null}
{step === 3 && result ? (
<>
<Button variant="outline" onClick={onClose}>
关闭
</Button>
<Button
disabled={!result.recordIds.length}
onClick={() => {
onClose();
navigate(recordsHref);
}}
>
<FileStack />
查看整理记录
</Button>
</>
) : null}
</div>
</div>
</SheetContent>
</Sheet>
);
}
function RootOption({
checked,
onSelect,
title,
hint,
}: {
checked: boolean;
onSelect: () => void;
title: string;
hint: string;
}) {
return (
<button
type="button"
aria-pressed={checked}
onClick={onSelect}
className={cn(
"flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition-colors duration-micro",
checked ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
<span
className={cn(
"grid h-4 w-4 place-items-center rounded-full border",
checked ? "border-foreground" : "border-border",
)}
>
{checked ? <span className="h-2 w-2 rounded-full bg-foreground" /> : null}
</span>
<span className="min-w-0">
<span className="block text-body">{title}</span>
<span className="block truncate font-mono text-meta text-muted-foreground">{hint}</span>
</span>
</button>
);
}
function GroupCard({ g, title }: { g: FilesOrganizeGroup; title?: Entity }) {
const dirs = [...new Set(g.files.map((f) => (f.target ? parentRel(f.target) : null)).filter(Boolean))];
return (
<section className="rounded-2xl border border-border">
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<span className="text-body font-medium">
{title ? `${title.title}${title.year ? ` (${title.year})` : ""}` : g.mediaEntityId}
</span>
{g.error ? (
<StatusBadge tone="destructive">不能执行</StatusBadge>
) : (
<StatusBadge tone="success">计划就绪</StatusBadge>
)}
<span className="text-meta text-muted-foreground">
{g.files.length} 个文件 ·{" "}
{formatBytes(
g.files.reduce((s, f) => s + f.size, 0),
1,
)}
</span>
</header>
<div className="space-y-2 px-3 py-2.5">
{g.error ? (
<p className="flex items-start gap-1.5 text-caption text-destructive">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{g.error.message}(返回第 1 步修改后重新预览)
</p>
) : null}
{g.warnings.map((w) => (
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{w}
</p>
))}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
{dirs.length ? (
<p className="text-meta text-muted-foreground">
目标目录:<span className="font-mono">{dirs.join("、")}</span>
</p>
) : null}
<ul className="space-y-1.5">
{g.files.map((f) => (
<li key={f.source} className="rounded-lg border border-border/60 px-2.5 py-1.5">
<div className="mb-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span>{f.kind}</span>
<span>{formatBytes(f.size, 1)}</span>
{!f.target ? <StatusBadge tone="neutral">不入库</StatusBadge> : null}
</div>
<PathPair from={f.source} to={f.target} />
</li>
))}
</ul>
</div>
</section>
);
}
function IdentifyRow({
row,
onEntity,
onApplyGroup,
groupSize,
onChange,
}: {
row: WizardRow;
onEntity: (e: Entity | null) => void;
onApplyGroup: () => void;
groupSize: number;
onChange: (p: Partial<WizardRow>) => void;
}) {
const p = row.item.parsed;
const problem = rowProblem(row);
const isMovie = row.entity?.kind === "movie";
return (
<li
className={cn(
"rounded-xl border px-3 py-2.5",
row.excluded ? "border-border opacity-60" : problem ? "border-warning/50 bg-warning/[0.06]" : "border-border",
)}
>
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-caption text-foreground" title={row.path}>
{baseName(row.path)}
</p>
<p className="text-meta text-muted-foreground">
解析:{p.title ?? "?"}
{p.year ? ` · ${p.year}` : ""}
{p.season != null ? ` · ${contentUnitLabel(p.season, p.episodes)}` : ""}
{p.resolution ? ` · ${p.resolution}` : ""}
{p.source ? ` · ${p.source}` : ""}
{p.releaseGroup ? ` · ${p.releaseGroup}` : ""}
</p>
{row.item.warnings.map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ excluded: !row.excluded })}
aria-label={row.excluded ? "恢复该项" : "排除该项"}
>
{row.excluded ? <Undo2 /> : <X />}
{row.excluded ? "恢复" : "排除"}
</Button>
</div>
{!row.excluded ? (
<div className="mt-2 grid gap-2 sm:grid-cols-[minmax(0,1fr)_4.5rem_6.5rem_minmax(0,9rem)]">
<div className="min-w-0">
<EntityPicker candidates={row.item.candidates} value={row.entity} onChange={onEntity} />
{groupSize > 1 && row.entity ? (
<button
type="button"
onClick={onApplyGroup}
className="mt-1 text-meta text-muted-foreground underline hover:text-foreground"
>
应用到同目录 {groupSize} 项
</button>
) : null}
</div>
<Input
aria-label="季"
placeholder="季"
value={row.season}
disabled={isMovie}
onChange={(e) => onChange({ season: e.target.value })}
className="h-9 text-center"
/>
<Input
aria-label="集"
placeholder="集 如 3-5"
value={row.episodes}
disabled={isMovie}
onChange={(e) => onChange({ episodes: e.target.value })}
className="h-9 text-center"
/>
<SlotSelect
entityId={row.entity?.id ?? null}
value={row.slotId}
onChange={(slotId) => onChange({ slotId })}
/>
</div>
) : null}
{problem ? <p className="mt-1.5 text-meta text-warning">{problem}</p> : null}
</li>
);
}
function SlotSelect({
entityId,
value,
onChange,
}: {
entityId: string | null;
value: string;
onChange: (v: string) => void;
}) {
const mock = filesMockEnabled();
const slots = useSlots({ mediaEntityId: entityId ?? undefined }, !!entityId && !mock);
const items = slots.data?.items ?? [];
return (
<select
aria-label="Slot"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={!entityId}
className="h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
>
<option value="">Slot:自动</option>
{items.map((s) => (
<option key={s.id} value={s.id}>
{s.name}({s.status})
</option>
))}
</select>
);
}
function EntityPicker({
candidates,
value,
onChange,
}: {
candidates: FileIdentifyCandidate[];
value: Entity | null;
onChange: (e: Entity | null) => void;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const mock = filesMockEnabled();
const remote = useEntitySearch(mock ? "" : q);
const mockHits = useQuery({
queryKey: ["files", "mock-entity", q],
enabled: mock && q.trim().length > 0,
queryFn: async () => (await import("./mock.ts")).mockEntitySearch(q),
});
const results: Entity[] = mock
? (mockHits.data ?? [])
: (remote.data?.items ?? []).map((i) => ({
id: i.entity.id,
title: i.entity.title,
year: i.entity.year,
kind: i.entity.kind,
}));
const local = candidates.filter((c) => c.mediaEntityId);
const remoteOnly = candidates.filter((c) => !c.mediaEntityId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-9 w-full min-w-0 items-center gap-2 rounded-lg border px-2.5 text-left text-caption",
value ? "border-input" : "border-warning/60 text-warning",
)}
>
<span className="min-w-0 flex-1 truncate">
{value ? `${value.title}${value.year ? ` (${value.year})` : ""}` : "选择作品…"}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(26rem,calc(100vw-2rem))] p-2">
{local.length ? (
<p className="px-2 pt-1 pb-1.5 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
候选
</p>
) : null}
<ul className="space-y-0.5">
{local.map((c) => (
<li key={`${c.provider}-${c.mediaEntityId}`}>
<button
type="button"
onClick={() => {
onChange({ id: c.mediaEntityId as string, title: c.title, year: c.year, kind: c.kind });
setOpen(false);
}}
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-foreground/[0.06]"
>
<span className="flex items-center gap-2 text-caption">
{value?.id === c.mediaEntityId ? <Check className="h-3.5 w-3.5" /> : <span className="w-3.5" />}
<span className="min-w-0 flex-1 truncate">
{c.title}
{c.year ? ` (${c.year})` : ""}
</span>
<StatusBadge tone={LEVEL[c.level]?.tone ?? "neutral"}>
置信 {LEVEL[c.level]?.label ?? c.level}
</StatusBadge>
</span>
<span className="block pl-5.5 text-meta text-muted-foreground">{c.reasons.join(" · ")}</span>
</button>
</li>
))}
</ul>
{remoteOnly.length ? (
<p className="px-2 pt-2 text-meta text-muted-foreground">
TMDB 另有 {remoteOnly.length} 个未入库候选(
{remoteOnly.map((c) => `${c.title}${c.year ? ` ${c.year}` : ""}`).join("、")}),
需先在“身份与元数据”建立作品。
</p>
) : null}
<div className="relative mt-2">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索本地作品"
className="h-8 pl-8 text-caption"
/>
</div>
{results.length ? (
<ul className="mt-1 max-h-48 space-y-0.5 overflow-auto">
{results.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => {
onChange(e);
setOpen(false);
}}
className="w-full truncate rounded-lg px-2 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
>
{e.title}
{e.year ? ` (${e.year})` : ""}{" "}
<span className="text-muted-foreground">· {e.kind === "movie" ? "电影" : "剧集"}</span>
</button>
</li>
))}
</ul>
) : q.trim() ? (
<p className="px-2 py-1.5 text-meta text-muted-foreground">没有匹配的本地作品</p>
) : null}
{value ? (
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="mt-1 w-full rounded-lg px-2 py-1.5 text-left text-meta text-muted-foreground hover:bg-foreground/[0.06]"
>
清除选择
</button>
) : null}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,92 @@
import { ChevronRight, HardDrive } from "lucide-react";
import { useState } from "react";
import { cn } from "~/lib/utils.ts";
import { collapseCrumbs, segments } from "./model.ts";
/**
* 路径面包屑:根名 + 各段,可点击跳转;每段也是拖放目标(拖入 = 移动到该目录)。
* 窄屏折叠为"根 / … / 父 / 当前"。
*/
export function PathBreadcrumb({
rootName,
path,
onNavigate,
onDropTo,
className,
}: {
rootName: string;
path: string;
onNavigate: (path: string) => void;
/** 有值时每段可作为拖放目标 */
onDropTo?: (path: string, e: React.DragEvent) => void;
className?: string;
}) {
const segs = segments(path);
const crumbs = [
{ label: rootName, path: "" },
...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })),
];
const [over, setOver] = useState<string | null>(null);
const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => (
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
{items.map((c, i) => {
const last = i === items.length - 1;
return (
<li
key={c ? c.path || "__root" : `gap-${i}`}
className={cn("flex min-w-0 items-center gap-0.5", last && "shrink")}
>
{i > 0 ? <ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{c === null ? (
<span className="px-1 text-muted-foreground">…</span>
) : (
<button
type="button"
onClick={() => onNavigate(c.path)}
aria-current={last ? "page" : undefined}
onDragOver={
onDropTo
? (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
setOver(c.path);
}
: undefined
}
onDragLeave={onDropTo ? () => setOver(null) : undefined}
onDrop={
onDropTo
? (e) => {
e.preventDefault();
setOver(null);
onDropTo(c.path, e);
}
: undefined
}
title={c.label}
className={cn(
"flex min-w-0 items-center gap-1.5 truncate rounded-md px-1.5 py-1 text-body transition-colors duration-micro",
last
? "font-medium text-foreground"
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
over === c.path && "bg-foreground/[0.08] ring-1 ring-ring",
)}
>
{i === 0 ? <HardDrive className="h-3.5 w-3.5 shrink-0" /> : null}
<span className="truncate">{c.label}</span>
</button>
)}
</li>
);
})}
</ol>
);
return (
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
{render(crumbs, "full", "hidden md:flex")}
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
</nav>
);
}
+2
View File
@@ -15,6 +15,8 @@ export default [
route("identity", "routes/console/identity.tsx"), route("identity", "routes/console/identity.tsx"),
route("organize", "routes/console/organize.tsx"), route("organize", "routes/console/organize.tsx"),
route("organize/:id", "routes/console/organize.$id.tsx"), route("organize/:id", "routes/console/organize.$id.tsx"),
route("files", "routes/console/files.tsx"),
route("files/trash", "routes/console/files.trash.tsx"),
route("subscriptions", "routes/console/subscriptions.tsx"), route("subscriptions", "routes/console/subscriptions.tsx"),
route("preferences", "routes/console/preferences.tsx"), route("preferences", "routes/console/preferences.tsx"),
route("sites", "routes/console/sites.tsx"), route("sites", "routes/console/sites.tsx"),
+365
View File
@@ -0,0 +1,365 @@
import { ArrowLeft, Clock, File as FileIcon, Folder, LoaderCircle, RotateCcw, Trash, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useSession } from "~/api/auth.ts";
import {
type FileRef,
filesErrorText,
filesMockEnabled,
type TrashItem,
useFileRoots,
usePurge,
useRestore,
useTrash,
} from "~/api/files.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { DirTree } from "~/components/files/dir-tree.tsx";
import { Checkbox } from "~/components/files/file-list.tsx";
import { baseName, newIdempotencyKey } from "~/components/files/model.ts";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
const DAY = 86_400_000;
function expiryTone(iso: string): "destructive" | "warning" | "neutral" {
const left = Date.parse(iso) - Date.now();
if (left <= 0) return "destructive";
if (left < DAY) return "warning";
return "neutral";
}
export default function FilesTrashPage() {
const trash = useTrash();
const roots = useFileRoots();
const session = useSession();
const isAdmin = session.data?.actor?.type === "admin";
const restore = useRestore();
const purge = usePurge();
const [picked, setPicked] = useState<Set<string>>(new Set());
const [restoreTo, setRestoreTo] = useState<string[] | null>(null);
const [purgeAll, setPurgeAll] = useState(false);
const [purgeIds, setPurgeIds] = useState<string[] | null>(null);
const [confirmText, setConfirmText] = useState("");
const rows = trash.data?.pages.flatMap((p) => p.items);
const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]);
const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? [];
const toggle = (id: string) =>
setPicked((p) => {
const n = new Set(p);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const doRestore = (ids: string[], target?: FileRef) =>
restore.mutate(
{ idempotencyKey: newIdempotencyKey(), ids, target },
{
onSuccess: (r) => {
setPicked(new Set());
setRestoreTo(null);
if (r.restored.length) toast.success(`已还原 ${r.restored.length} 项`);
for (const f of r.failed)
toast.error(
f.code === "exists" || f.code === "conflict" ? "原位置已被占用,可用“还原到…”换个目录" : f.message,
{ description: rows?.find((x) => x.id === f.id)?.originalPath },
);
},
onError: (e) => toast.error(filesErrorText(e, "还原失败")),
},
);
const doPurge = (ids?: string[]) =>
purge
.mutateAsync({ idempotencyKey: newIdempotencyKey(), ids })
.then((r) => {
setPicked(new Set());
toast.success(`已清除 ${r.purged.length} 项${r.failed.length ? `,${r.failed.length} 项失败` : ""}`);
})
.catch((e) => {
toast.error(filesErrorText(e, "清除失败"));
throw e;
});
const columns: DataColumn<TrashItem>[] = [
{
key: "pick",
header: (
<Checkbox
checked={!!rows?.length && picked.size === rows.length}
partial={picked.size > 0 && picked.size < (rows?.length ?? 0)}
label="全选"
onToggle={() => setPicked(picked.size === rows?.length ? new Set() : new Set(rows?.map((r) => r.id)))}
/>
),
className: "w-10",
cell: (r) => (
<Checkbox checked={picked.has(r.id)} label={`选择 ${baseName(r.originalPath)}`} onToggle={() => toggle(r.id)} />
),
},
{
key: "name",
header: "原位置",
cell: (r) => {
const root = rootName.get(r.rootId);
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null } | null;
return (
<div className="flex min-w-0 max-w-lg items-start gap-2">
{r.isDir ? (
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="truncate text-foreground" title={r.originalPath}>
{baseName(r.originalPath)}
</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={r.trashPath}>
{root?.name ?? r.rootId} / {r.originalPath}
</p>
{snap?.title ? (
<p className="text-meta text-muted-foreground">
删除时关联:{snap.title}
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原不会自动恢复资产)
</p>
) : null}
</div>
</div>
);
},
},
{
key: "size",
header: "大小",
hideBelow: "sm",
align: "right",
cell: (r) => <span className="whitespace-nowrap">{formatBytes(r.size, 1)}</span>,
},
{
key: "deleted",
header: "删除",
hideBelow: "md",
cell: (r) => (
<span
className="block max-w-40 text-muted-foreground"
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
>
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
{r.actor ? (
<span className="block truncate text-meta">{r.actor.replace(/^admin_session:/, "管理员 ")}</span>
) : null}
</span>
),
},
{
key: "expires",
header: "到期清除",
cell: (r) => (
<span title={formatDateTime(r.expiresAt)}>
<StatusBadge tone={expiryTone(r.expiresAt)}>
{Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)}
</StatusBadge>
</span>
),
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<div className="flex justify-end gap-1">
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
<RotateCcw />
还原
</Button>
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
还原到…
</Button>
{isAdmin ? (
<Button
size="xs"
variant="ghost"
aria-label="立即清除"
title="立即清除"
onClick={() => setPurgeIds([r.id])}
>
<Trash2 />
</Button>
) : null}
</div>
),
},
];
return (
<>
<PageHeader
title="回收站"
description="删除的文件先移到这里,保留 7 天后由后台任务清除;期间可还原到原位置或其他目录。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
<Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}>
<ArrowLeft />
文件
</Link>
</>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={!picked.size || restore.isPending}
onClick={() => doRestore([...picked])}
>
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
还原选中 {picked.size || ""}
</Button>
{isAdmin ? (
<>
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
<Trash2 />
清除选中
</Button>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" disabled={!expired.length}>
<Clock />
清除已到期 {expired.length || ""}
</Button>
}
title="清除已到期的项?"
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
confirmLabel="清除"
destructive
onConfirm={() => doPurge(undefined)}
/>
<Button
size="sm"
variant="destructive"
className="ml-auto"
disabled={!rows?.length}
onClick={() => setPurgeAll(true)}
>
<Trash />
清空回收站
</Button>
</>
) : (
<span className="ml-auto text-meta text-muted-foreground">立即清除仅管理员会话可用。</span>
)}
</div>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={trash.isPending}
error={trash.error}
onRetry={() => void trash.refetch()}
hasMore={trash.hasNextPage}
onLoadMore={() => void trash.fetchNextPage()}
loadingMore={trash.isFetchingNextPage}
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会先移到这里。" }}
/>
<ConfirmDialog
open={!!purgeIds}
onOpenChange={(o) => !o && setPurgeIds(null)}
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
description="清除后不可恢复。"
confirmLabel="清除"
destructive
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
/>
{/* 清空全部:二次确认,需输入“清空回收站” */}
<ConfirmDialog
open={purgeAll}
onOpenChange={(o) => {
setPurgeAll(o);
if (!o) setConfirmText("");
}}
title="清空回收站?"
description={`将立即清除全部 ${rows?.length ?? 0} 项(含未到期的),不可恢复。`}
confirmLabel="清空"
destructive
onConfirm={() => {
if (confirmText.trim() !== "清空回收站") {
toast.error("请输入“清空回收站”确认");
return Promise.reject(new Error("unconfirmed"));
}
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
}}
>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 清空回收站"
aria-label="输入 清空回收站 以确认"
/>
</ConfirmDialog>
{restoreTo ? (
<RestoreToDialog
ids={restoreTo}
onClose={() => setRestoreTo(null)}
onPick={(t) => doRestore(restoreTo, t)}
pending={restore.isPending}
/>
) : null}
</>
);
}
function RestoreToDialog({
ids,
onClose,
onPick,
pending,
}: {
ids: string[];
onClose: () => void;
onPick: (t: FileRef) => void;
pending: boolean;
}) {
const roots = useFileRoots();
const [target, setTarget] = useState<FileRef | null>(null);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>还原到…</DialogTitle>
<DialogDescription>{ids.length} 项 · 选一个可写的目录;同名已存在的项会失败,不会覆盖。</DialogDescription>
</DialogHeader>
<div className="max-h-80 overflow-y-auto rounded-xl border border-border p-1.5">
<DirTree roots={roots.data ?? []} current={target} onSelect={setTarget} pickOnly />
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button disabled={!target || pending} onClick={() => target && onPick(target)}>
{pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
还原到这里
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+913
View File
@@ -0,0 +1,913 @@
import { useQueryClient } from "@tanstack/react-query";
import { EventType } from "@umt/contracts";
import {
ArrowDownAZ,
ArrowUpAZ,
ClipboardCopy,
Copy,
Eye,
EyeOff,
FileStack,
FolderInput,
FolderOpen,
FolderPlus,
FolderTree,
Library,
PenLine,
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,
filesErrorText,
filesKeys,
filesMockEnabled,
isRootOffline,
type ListQuery,
refKey,
useFileList,
useFileRoots,
useProbe,
} 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,
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,
opDisabledReason,
parentRel,
pruneSelection,
type Selection,
SORT_OPTIONS,
totalSize,
} from "~/components/files/model.ts";
import { OrganizeWizard } from "~/components/files/organize-wizard.tsx";
import { PathBreadcrumb } from "~/components/files/path-breadcrumb.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: "scrape"; entry: FileEntry }
| { kind: "wizard"; entries: FileEntry[] };
const TREE_W_KEY = "umt-files-tree-w";
const FILES_CHANGED = "files.changed";
function useIsMobile() {
const [m, setM] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const on = () => setM(mq.matches);
on();
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, []);
return m;
}
function useTreeWidth() {
const [w, setW] = useState(260);
useEffect(() => {
try {
const v = Number(localStorage.getItem(TREE_W_KEY));
if (v >= 200 && v <= 440) setW(v);
} catch {
// 存储不可用时用默认宽度
}
}, []);
const save = (v: number) => {
const c = Math.max(200, Math.min(440, Math.round(v)));
setW(c);
try {
localStorage.setItem(TREE_W_KEY, String(c));
} catch {
// 忽略
}
};
return [w, save] as const;
}
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 roots = useFileRoots();
const rootList = roots.data ?? [];
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 [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],
);
// 默认进第一个在线的库根
useEffect(() => {
if (rootId || !rootList.length) return;
const first = rootList.find((r) => r.health !== "offline") ?? rootList[0];
if (first) patch({ root: first.id, path: null }, true);
}, [rootId, rootList, patch]);
// 搜索 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; entry: FileEntry } | null>(null);
const [treeOpen, setTreeOpen] = 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) });
}
} else void qc.invalidateQueries({ queryKey: ["files", "list"] });
});
// "/" 聚焦搜索
useEffect(() => {
const on = (e: KeyboardEvent) => {
if (e.key !== "/" || e.ctrlKey || e.metaKey) return;
const t = e.target as HTMLElement;
if (t.closest("input,textarea,select,[contenteditable=true],[role=dialog]")) return;
e.preventDefault();
searchRef.current?.focus();
};
window.addEventListener("keydown", on);
return () => window.removeEventListener("keydown", on);
}, []);
const go = (loc: { root: string; path: string }) => {
setTreeOpen(false);
patch({ root: loc.root, path: loc.path || null, sel: 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 });
};
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] : []);
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 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 menuItems = (e: FileEntry): MenuItem[] => {
if (!root) return [];
const list = targets(e);
const multi = list.length > 1;
const why = (op: Parameters<typeof opDisabledReason>[0]) =>
list.map((x) => opDisabledReason(op, x, root)).find(Boolean) ?? null;
const items: MenuItem[] = [];
if (!multi)
items.push({
key: "open",
label: e.type === "dir" ? "打开" : "详情",
icon: FolderOpen,
hint: "Enter",
onSelect: () => open(e),
});
if (multi)
items.push({
key: "wizard",
label: `整理向导(${list.length} 项)`,
icon: Wand2,
disabled: why("organize"),
onSelect: () => setDialog({ kind: "wizard", entries: list }),
});
if (!multi)
items.push({
key: "rename",
label: "重命名",
icon: PenLine,
hint: "F2",
disabled: why("rename"),
onSelect: () => openRename(e),
});
items.push(
{
key: "move",
label: "移动到…",
icon: FolderInput,
disabled: why("move"),
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "move" }),
},
{
key: "copy",
label: "复制到…",
icon: Copy,
disabled: e.escape ? "链接指向登记根之外,已禁用" : null,
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "copy" }),
},
);
if (!multi)
items.push({
key: "organize",
label: "整理到…",
icon: Wand2,
disabled: why("organize"),
onSelect: () => setDialog({ kind: "wizard", entries: list }),
});
items.push({
key: "identify",
label: "识别",
icon: Sparkles,
disabled: why("identify"),
onSelect: () => setDialog({ kind: "wizard", entries: list }),
});
if (!multi)
items.push({
key: "scrape",
label: "刮削",
icon: Library,
disabled: why("scrape"),
onSelect: () => setDialog({ kind: "scrape", entry: e }),
});
items.push({
key: "probe",
label: "探测",
icon: ScanSearch,
disabled: list.some((x) => x.type === "file" && x.fileKind === "video") ? null : "只能探测视频文件",
onSelect: () => doProbe(list),
});
items.push({ key: "sep1", separator: true });
if (!multi) {
items.push({
key: "copyPath",
label: "复制路径…",
icon: ClipboardCopy,
onSelect: () => setDialog({ kind: "copyPath", entry: e }),
});
items.push({
key: "openMedia",
label: "打开作品",
icon: Library,
disabled: why("openMedia"),
onSelect: () => e.asset && navigate(`/console/library/${e.asset.mediaEntityId}`),
});
items.push({
key: "records",
label: "在整理记录中查看",
icon: FileStack,
disabled: why("records"),
onSelect: () => e.asset && navigate(`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`),
});
items.push({ key: "sep2", separator: true });
}
items.push({
key: "delete",
label: multi ? `删除 ${list.length} 项` : "删除",
icon: Trash2,
hint: "Del",
destructive: true,
disabled: why("delete"),
onSelect: () => openDelete(list),
});
return items;
};
// 拖拽:列表项 → 树节点 / 面包屑 / 列表里的目录 = 移动到(Alt 复制),一律先出 dryRun 预览
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.some((i) => i.root === target.root && (i.path === target.path || parentRel(i.path) === target.path))) {
if (items.every((i) => i.root === target.root && 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 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" />
回收站
</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="空目录"
description={hidden ? undefined : "隐藏文件未显示,可在工具栏打开。"}
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({ entry, at })}
onRename={openRename}
onDelete={openDelete}
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 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>
<Button
size="sm"
onClick={() => setDialog({ kind: "wizard", entries: selected })}
disabled={!!selected.map((e) => opDisabledReason("organize", e, root)).find(Boolean)}
>
<Wand2 />
整理
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDialog({ kind: "transfer", items: refs(selected), mode: "move" })}
disabled={!!selected.map((e) => opDisabledReason("move", e, root)).find(Boolean)}
>
<FolderInput />
移动
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDialog({ kind: "transfer", items: refs(selected), mode: "copy" })}
>
<Copy />
复制
</Button>
<Button size="sm" variant="outline" onClick={() => doProbe(selected)} disabled={probe.isPending}>
<ScanSearch />
探测
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => openDelete(selected)}
disabled={!!selected.map((e) => opDisabledReason("delete", e, root)).find(Boolean)}
>
<Trash2 />
删除
</Button>
<Button size="icon-sm" variant="ghost" aria-label="清除选择" onClick={() => setSel(emptySelection())}>
<X />
</Button>
</div>
) : null;
return (
<>
<PageHeader
title="文件"
description="只浏览已登记的库根与下载目录;每行带业务状态,删除进回收站,改名同步资产与路径绑定。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
<Trash2 />
回收站
</Link>
</>
}
/>
<div className="flex min-w-0 gap-4">
{/* 桌面左栏:根 + 目录树(可拖宽) */}
<aside
className="pg-chrome relative hidden shrink-0 self-start overflow-hidden rounded-2xl md:block"
style={{ width: treeW }}
aria-label="根与目录树"
>
<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"
/>
</aside>
<section
className="pg-slab relative flex h-[calc(100dvh-11rem)] min-h-[28rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
aria-label="目录内容"
>
{/* 工具栏 */}
<div className="space-y-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<Button
variant="outline"
size="icon-sm"
className="md:hidden"
aria-label="根与目录"
onClick={() => setTreeOpen(true)}
>
<FolderTree />
</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="outline"
size="sm"
disabled={!root?.writable}
title={root?.writable ? undefined : "只读根不能新建目录"}
onClick={() => setDialog({ kind: "mkdir" })}
>
<FolderPlus />
<span className="hidden sm:inline">新建目录</span>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<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("");
gridRef.current?.focus();
}
if (e.key === "ArrowDown") gridRef.current?.focus();
}}
placeholder="名称或 glob,如 *.mkv ( / )"
aria-label="搜索当前目录"
className="h-8 pl-9"
/>
</div>
<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>
<div className="ml-auto 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>
</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" ? (
<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}
{root.usage ? <span className="ml-auto">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable ? <StatusBadge>只读</StatusBadge> : null}
</div>
) : null}
{!mobile ? actionBar : null}
</section>
</div>
{mobile ? actionBar : null}
{/* 手机:根与目录树收进抽屉 */}
<Sheet open={treeOpen} onOpenChange={setTreeOpen}>
<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 ? menuItems(menu.entry) : []} onClose={() => setMenu(null)} />
{detail && root ? (
<FileDetail
root={root}
entry={detail}
onClose={() => patch({ sel: null })}
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => openRename(detail)}
disabled={!!opDisabledReason("rename", detail, root)}
>
<PenLine />
重命名
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDialog({ kind: "transfer", items: refs([detail]), mode: "move" })}
disabled={!!opDisabledReason("move", detail, root)}
>
<FolderInput />
移动
</Button>
{detail.asset ? null : (
<Button
size="sm"
onClick={() => setDialog({ kind: "wizard", entries: [detail] })}
disabled={!!opDisabledReason("organize", detail, root)}
>
<Wand2 />
整理
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => openDelete([detail])}
disabled={!!opDisabledReason("delete", detail, root)}
>
<Trash2 />
删除
</Button>
</>
}
/>
) : null}
{dialog?.kind === "rename" && root ? (
<RenameDialog root={root} entry={dialog.entry} 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 === "scrape" ? <ScrapeDialog entry={dialog.entry} onClose={() => setDialog(null)} /> : null}
{dialog?.kind === "wizard" && root ? (
<OrganizeWizard roots={rootList} entries={dialog.entries} rootId={root.id} onClose={() => setDialog(null)} />
) : null}
</>
);
}
+2
View File
@@ -27,6 +27,8 @@ const STATIC_ROUTES = [
"/console/library", "/console/library",
"/console/identity", "/console/identity",
"/console/organize", "/console/organize",
"/console/files",
"/console/files/trash",
"/console/subscriptions", "/console/subscriptions",
"/console/preferences", "/console/preferences",
"/console/sites", "/console/sites",