diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts new file mode 100644 index 00000000..4ea8ebdd --- /dev/null +++ b/apps/web/app/api/files.ts @@ -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 | 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 | null = null; +const loadMock = () => { + mockModule ??= import("~/components/files/mock.ts"); + return mockModule; +}; + +/** 统一出口:mock 时走内存实现,否则走后端并按合同校验;失败抛 ApiRequestError */ +async function call(schema: S, path: string, arg: FetcherArg = {}): Promise> { + if (filesMockEnabled()) { + const m = await loadMock(); + return (await m.mockFiles(path, arg)) as z.infer; + } + return apiParsed(schema, `/api/v1${path}`, arg); +} +const post = (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(fn: (b: B) => Promise, 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): FileRef => ({ root, path: e.path }); diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 473123fe..25051741 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -3,8 +3,8 @@ import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts"; describe("控制台导航", () => { it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => { - expect(NAV_ITEMS).toHaveLength(14); - expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(14); + expect(NAV_ITEMS).toHaveLength(15); + expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(15); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 574347a3..a4b19784 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -5,6 +5,7 @@ import { Download, FileStack, Fingerprint, + FolderTree, Globe, House, Inbox, @@ -63,6 +64,12 @@ export const NAV_GROUPS: NavGroup[] = [ icon: FileStack, description: "Copy/Move 入库前后路径与逐文件结果", }, + { + to: "/console/files", + label: "文件", + icon: FolderTree, + description: "浏览已登记的库根与下载目录,改名、移动、删除与批量整理", + }, ], }, { diff --git a/apps/web/app/components/files/context-menu.tsx b/apps/web/app/components/files/context-menu.tsx new file mode 100644 index 00000000..1991775b --- /dev/null +++ b/apps/web/app/components/files/context-menu.tsx @@ -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(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( +
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 ? ( +
+ ) : ( + + ), + )} +
, + document.body, + ); +} diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx new file mode 100644 index 00000000..3c0ecb47 --- /dev/null +++ b/apps/web/app/components/files/dialogs.tsx @@ -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

{from}

; + const s = splitCommon(from, to); + return ( +
+

+ {s.common} + {s.a} +

+

+ + + {s.common} + {s.b} + +

+
+ ); +} + +function Notice({ tone, children }: { tone: "warning" | "destructive" | "neutral"; children: React.ReactNode }) { + return ( +
+ {tone !== "neutral" ? : null} +
{children}
+
+ ); +} + +function useDebounced(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(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 ( + + +
+ + 新建目录 + + {root.hostPath} + {path ? `/${path}` : ""}/ + + + + { + setName(e.target.value); + setErr(null); + }} + /> + + + + + +
+
+
+ ); +} + +// ─── 重命名 ───────────────────────────────────────────────────── + +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(null); + const inputRef = useRef(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 ( + !o && onClose()}> + +
+ + 重命名 + {entry.name} + + +
+ { + setName(e.target.value); + setErr(null); + }} + className="font-mono" + /> + {entry.fileKind === "video" || isDir ? ( + + ) : null} +
+
+ {!isDir ? ( +
+ + 同名附属文件一起改 + 同 stem 的字幕 / NFO / 图片 + + +
+ ) : null} + {changed && !problem ? ( +
+ {preview.isPending ? ( +

+ 预览中 +

+ ) : null} + {pvErr ? {filesErrorText(pvErr)} : null} + {pv ? ( + <> +
    + {pv.renames.map((r) => ( +
  • +
    + {r.role === "main" ? "本体" : "附属"} +
    + +
  • + ))} +
+ {pv.assetLocations > 0 ? ( + + 将同步 {pv.assetLocations} 条资产位置,并写一条整理记录(action=rename)。 + + ) : null} + {pv.bindings.length > 0 ? ( + + 这是作品/季的绑定目录:将新建绑定并把旧绑定标记为已替代,之后新集入库进入新目录。 + + ) : null} + + ) : null} +
+ ) : null} + + + + +
+
+
+ ); +} + +// ─── 移动 / 复制到… ───────────────────────────────────────────── + +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(initialTarget ?? null); + const [plan, setPlan] = useState(null); + const [err, setErr] = useState(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 ( + !o && onClose()}> + + + {mode === "move" ? "移动到…" : "复制到…"} + + {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · + 先预览前后路径、冲突与空间,确认后才执行; 统一走可恢复的整理计划,跨卷为 Copy → 核验 → 删源。 + + +
+
+
+ {(["move", "copy"] as const).map((m) => ( + + ))} +
+
+ r.writable && r.health !== "offline").map((r) => r.id)} + onSelect={(loc) => { + setTarget(loc); + runPreview(loc, mode); + }} + /> +
+

只有可写的媒体库根能作为目标。

+
+
+ {target && targetRoot ? ( +
+

目标目录

+

+ {targetRoot.hostPath} + {target.path ? `/${target.path}` : ""} +

+
+ ) : ( + 在左侧选择目标目录。 + )} + {dry.isPending ? ( +

+ 生成预览… +

+ ) : null} + {err ? {err} : null} + {plan ? ( + <> +
+ + {plan.pairs.length} 项 · {formatBytes(plan.totalBytes, 1)} + + + {plan.spaceOk ? "空间足够" : "空间不足"} + {plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""} + + {conflicts.length ? ( + 冲突 {conflicts.length}(跳过,不覆盖) + ) : null} + {plan.pairs.some((p) => p.registered) ? 含已登记资产,执行后同步位置 : null} +
+ {hnrBlocked ? ( + + {plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能 Move。可改用“复制”,或等 H&R 满足后再移动。 + + + ) : null} + {plan.truncated ? 源文件过多,预览只列出前一部分;执行覆盖全部。 : null} +
    + {plan.pairs.map((p) => { + const sr = rootById.get(p.source.root); + const tr = rootById.get(p.target.root); + return ( +
  • +
    + {p.conflict ? ( + 目标已存在 · 跳过 + ) : ( + {mode === "move" ? "移动" : "复制"} + )} + {formatBytes(p.size, 1)} +
    + +
  • + ); + })} +
+ + ) : null} +
+
+ + + + +
+
+ ); +} + +// ─── 删除 ─────────────────────────────────────────────────────── + +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(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 ( + !o && onClose()}> + + + {single ? `删除 ${single}?` : `删除 ${items.length} 项?`} + + 删除前已做引用检查:做种任务、已登记资产、路径绑定。删除不会动下载器里的任务。 + + +
+ {check.isPending ? ( +

+ 引用检查中… +

+ ) : null} + {err ? {err} : null} + {checks.length ? ( +
    + {checks.map((c) => { + const b = deleteBlockText(effective(c.blocked)); + const r = rootById.get(c.root); + return ( +
  • +
    + {b ? ( + + {b.soft ? "需确认" : "不能删除"} + + ) : ( + 可删除 + )} + + {baseName(c.path) || r?.name} + + + {c.isDir ? "目录 · " : ""} + {formatBytes(c.size, 1)} + +
    + {b ? ( +

    {b.text}

    + ) : null} +
    + {c.downloads.map((d) => ( +

    + 下载任务{" "} + + {d.taskId.slice(0, 8)} + {" "} + · {d.status} · H&R{" "} + {d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"} +

    + ))} + {c.assets.length ? ( +

    已登记资产 {c.assets.length} 个(删除后退役,满足的 Slot 退回待处理)

    + ) : null} + {c.bindings.map((bd) => ( +

    + 路径绑定:{bd.title} + {bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""} +

    + ))} + {c.sharedInode ?

    共享 inode(硬链接):只删除这一处链接,其他位置的文件不受影响

    : null} +
    +
  • + ); + })} +
+ ) : null} + {hasAssets ? ( + + ) : null} + {hasBindings ? ( + + ) : null} + {checks.length && deletable > 0 ? ( +
+ + + {mode === "permanent" ? ( + + 输入 {confirmPhrase} 以确认 + + } + htmlFor="del-confirm" + > + setConfirmText(e.target.value)} + className="font-mono" + autoComplete="off" + /> + + ) : null} +
+ ) : null} +
+ + + + +
+
+ ); +} + +// ─── 复制路径 ─────────────────────────────────────────────────── + +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 ( + !o && onClose()}> + + + 复制路径 + {entry.name} + +
    + {rows.map((r) => ( +
  • +
    +

    {r.label}

    +

    {r.value ?? (stat.isPending ? "…" : "—")}

    +
    + {r.value ? ( + + ) : null} +
  • + ))} +
+

+ 下载器与媒体服务器视角的路径按系统设置里的路径映射换算,见“系统设置 → 路径映射”。 +

+
+
+ ); +} + +// ─── 刮削 ─────────────────────────────────────────────────────── + +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 ( + !o && onClose()}> + + + 刮削 {asset.title} + + 字段级刷新元数据:用户锁定的字段不动,不改路径。非本系统生成的 NFO 不会被覆盖。 + + +
+ 写 NFO 到绑定目录 + +
+
+ 下载海报/背景图 + +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/components/files/dir-tree.tsx b/apps/web/app/components/files/dir-tree.tsx new file mode 100644 index 00000000..079ca039 --- /dev/null +++ b/apps/web/app/components/files/dir-tree.tsx @@ -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>(() => 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 ( + + ); +} + +function RootNode({ + root, + current, + open, + toggle, + onSelect, + onDropTo, + pickOnly, +}: { + root: FileRoot; + current: Loc | null; + open: Set; + 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 ( +
+ 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} + > + + + {root.name} + + {root.health !== "healthy" ? ( + + {h.tone !== "neutral" ? : null} + {h.label} + + ) : ( + + + 正常 + + )} + {root.usage ? 剩余 {formatBytes(root.usage.freeBytes, 1)} : null} + {!root.writable ? 只读 : null} + + + + {isOpen ? ( + + ) : null} +
+ ); +} + +function Children({ + root, + path, + depth, + current, + open, + toggle, + onSelect, + onDropTo, + pickOnly, +}: { + root: FileRoot; + path: string; + depth: number; + current: Loc | null; + open: Set; + toggle: (k: string) => void; + onSelect: (loc: Loc) => void; + onDropTo?: DropHandler; + pickOnly: boolean; +}) { + const q = useDirChildren(root.id, path, true); + if (q.isPending) + return ( +
+ 加载中 +
+ ); + if (q.error) + return ( +

+ 读取失败 +

+ ); + const dirs = q.data?.dirs ?? []; + if (!dirs.length) + return ( +

+ 无子目录 +

+ ); + return ( +
+ {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 ( +
+ 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 ? ( + + ) : ( + + )} + {d.name} + + {isOpen ? ( + + ) : null} +
+ ); + })} + {q.data?.truncated ? ( +

+ 只显示前 1000 个子目录 +

+ ) : null} +
+ ); +} + +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: 整行是拖放目标,交互本体是里面的两个按钮 +
{ + 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 + } + > + + +
+ ); +} diff --git a/apps/web/app/components/files/file-detail.tsx b/apps/web/app/components/files/file-detail.tsx new file mode 100644 index 00000000..1de8a2e9 --- /dev/null +++ b/apps/web/app/components/files/file-detail.tsx @@ -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 ( + !o && onClose()} + title={e.name} + description={} + footer={actions} + > + + + {stat.isPending ? : null} + {stat.error ? ( +

{filesErrorText(stat.error, "读取详情失败")}

+ ) : null} +
+ + {e.asset ? ( + + + {e.asset.title} + {e.asset.year ? ` (${e.asset.year})` : ""} + + ), + }, + { label: "内容单元", value: e.asset.contentUnit ?? "整部" }, + { + label: "Slot", + value: e.asset.slotId ? ( + + {e.asset.slotStatus ?? "查看"} + + ) : ( + "未满足任何 Slot" + ), + }, + { label: "主资产", value: e.asset.isPrimary ? "是" : "否" }, + ]} + /> + + 在整理记录中查看 + + + ) : null} + + {e.download ? ( + + + {downloadLabel(e.download.status).label} + + ), + }, + { + 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" ? "未满足" : "未知", + }, + ]} + /> + + ) : null} + + {stat.data?.bindings.length ? ( + +
    + {stat.data.bindings.map((b) => ( +
  • + + {b.title} + + {b.kind === "season" && b.seasonNumber != null ? ` · 第 ${b.seasonNumber} 季` : " · 作品目录"} +
  • + ))} +
+

改名/移动此目录会同步修订绑定;元数据刷新不会改路径。

+
+ ) : null} + + {stat.data?.sidecars.length ? ( + +
    + {stat.data.sidecars.map((s) => ( +
  • + {baseName(s)} +
  • + ))} +
+
+ ) : null} + + {e.type === "file" && e.fileKind === "video" ? ( + + 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 ? : } + 探测 + + } + > + {media ? ( + + ) : ( +

+ 未登记文件不自动探测;点“探测”读取 ffprobe 摘要(缓存 24 小时)。 +

+ )} +
+ ) : null} + + {previewable ? ( + + + 下载 + + ) + } + > + {e.fileKind === "image" ? ( + filesMockEnabled() ? ( +

演示数据没有图片内容。

+ ) : ( + + {e.name} + + 新窗口打开 + + + ) + ) : text.isPending ? ( + + ) : text.error ? ( +

{filesErrorText(text.error, "预览失败")}

+ ) : ( +
+              {text.data}
+            
+ )} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/files/file-list.tsx b/apps/web/app/components/files/file-list.tsx new file mode 100644 index 00000000..6a07b9d5 --- /dev/null +++ b/apps/web/app/components/files/file-list.tsx @@ -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 ; + if (e.type === "dir") return ; + switch (e.fileKind) { + case "video": + return ; + case "subtitle": + return ; + case "image": + return ; + case "nfo": + return ; + case "audio": + return ; + default: + return ; + } +} + +function sizeCell(e: FileEntry): ReactNode { + if (e.type === "dir") { + const s = e.dirSummary; + if (!s) return —; + return ( + + {s.videoCount} + {s.partial ? "+" : ""} 视频 · {s.registeredCount} 登记 + + ); + } + return formatBytes(e.size, 1); +} + +/** 状态徽标 + 链接(作品 / 下载任务) */ +export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: boolean }) { + const v = entryStatusView(e); + if (!v) return compact ? null : —; + return ( + + {v.label} + {!compact && e.download ? ( + ev.stopPropagation()} + className="truncate text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" + > + 任务 + + ) : null} + + ); +} + +function useScrollBox(ref: React.RefObject) { + 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; +}) { + const innerRef = useRef(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(null); + const longPress = useRef<{ t: ReturnType | 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(`[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 元素 +
= 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 容器上 +
+ + 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), + ) + } + /> + + 名称 + 状态 + + 季集 + + + 大小 + + + 修改时间 + + + 操作 + +
+ ) : null} +
+ {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 = ( + + ); + if (mobile) { + return ( +
+
+ {selecting ? ( + onSelection(toggleSelect(selection, k))} + /> + ) : ( + + )} + +

{e.name}

+
+ + {sizeCell(e)} + {relTime(e.mtime)} +
+
+ {menuBtn} +
+
+ ); + } + return ( +
+
+ + onSelection(toggleSelect(selection, k))} + /> + + + + + + {e.name} + + {e.asset ? ( + 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" : ""} + + ) : null} + + + + + + + {e.asset?.contentUnit ?? ""} + + {sizeCell(e)} + {relTime(e.mtime)} + {menuBtn} +
+
+ ); + })} +
+ {loadingMore ? ( +
+ 加载下一页 +
+ ) : null} + {footer} +
+ ); +} + +/** 网格单元:虚拟滚动的行是绝对定位的 div,不能用 ;单元不单独获焦(焦点在 grid 上,aria-activedescendant 指向行) */ +function Cell({ + header = false, + as = "span", + className, + children, +}: { + header?: boolean; + as?: "span" | "div"; + className?: string; + children?: ReactNode; +}) { + const Tag = as; + return ( + + {children} + + ); +} + +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 样式 + + ); +} diff --git a/apps/web/app/components/files/mock.ts b/apps/web/app/components/files/mock.ts new file mode 100644 index 00000000..0ef8aada --- /dev/null +++ b/apps/web/app/components/files/mock.ts @@ -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>; + /** 大目录:按需生成 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 } { + 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 = { + "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 = { + 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): 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): 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 => !!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(); + 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 { + await delay(120 + Math.random() * 160); + const p = (arg.params ?? {}) as Record; + // 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 { + await delay(100); + if (path.endsWith(".nfo")) + return `\n\n\n 沙丘2\n 2024\n`; + 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})`; +} diff --git a/apps/web/app/components/files/model.test.ts b/apps/web/app/components/files/model.test.ts new file mode 100644 index 00000000..6382f78d --- /dev/null +++ b/apps/web/app/components/files/model.test.ts @@ -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(); + 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); + }); +}); diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts new file mode 100644 index 00000000..3d5af357 --- /dev/null +++ b/apps/web/app/components/files/model.ts @@ -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): 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): 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; 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(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 }; + } +} diff --git a/apps/web/app/components/files/organize-wizard.tsx b/apps/web/app/components/files/organize-wizard.tsx new file mode 100644 index 00000000..07285a15 --- /dev/null +++ b/apps/web/app/components/files/organize-wizard.tsx @@ -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 = { + 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, 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([]); + const [action, setAction] = useState<"copy" | "move">("copy"); + const [targetRoot, setTargetRoot] = useState(""); + const [preview, setPreview] = useState(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) => + 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 ( + !o && onClose()}> + + + 整理向导 + + {entries.length === 1 ? entries[0]?.name : `${entries.length} 项`} · + 确认后才落库;与自动整理产出同格式的整理记录。 + +
    + {STEPS.map((s, i) => ( +
  1. + {i > 0 ? : null} + + {i < step ? : {i + 1}} + {s} + +
  2. + ))} +
+
+ +
+ {step === 0 ? ( + identify.isPending ? ( +

+ 识别中:解析文件名并匹配本地作品与 TMDB… +

+ ) : identify.error ? ( +

{filesErrorText(identify.error, "识别失败")}

+ ) : ( +
+
+ {rows.length} 个视频 + {problems.length ? ( + {problems.length} 项未决:选作品或排除 + ) : ( + 全部已确认 + )} + {identify.data?.truncated ? ( + 目录内视频过多,只取前 200 个 + ) : null} + + 集号偏移 + setOffset(e.target.value)} + className="h-8 w-16 text-center" + aria-label="集号偏移量" + /> + + +
+
    + {rows.map((r) => ( + 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)} + /> + ))} +
+
+ ) + ) : null} + + {step === 1 ? ( +
+
+

动作

+
+ {(["copy", "move"] as const).map((a) => { + const disabled = a === "move" && anyHnr; + return ( + + ); + })} +
+
+
+

目标库根

+
+ setTargetRoot("")} + title="自动" + hint="按作品已有路径绑定或类型选择" + /> + {libRoots.map((r) => ( + setTargetRoot(r.id)} + title={r.name} + hint={`${r.hostPath}${r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}`} + /> + ))} +
+
+
+

命名:按系统设置里的命名模板重命名;已绑定的作品/季沿用原目录。

+

目标已存在:不覆盖,列为冲突进待处理。

+
+
+ ) : null} + + {step === 2 ? ( + dry.isPending ? ( +

+ 生成整理计划… +

+ ) : preview ? ( +
+ {preview.map((g) => ( + + ))} +
+ ) : ( +

{filesErrorText(dry.error, "预览失败")}

+ ) + ) : null} + + {step === 3 ? ( + exec.isPending || !result ? ( +

+ 提交整理任务… +

+ ) : ( +
+ {result.groups.map((g) => { + const t = entityTitle.get(g.mediaEntityId); + return ( +
+ {g.error ? ( + 失败 + ) : g.recordId ? ( + 已提交 + ) : ( + 未执行 + )} + + {t ? `${t.title}${t.year ? ` (${t.year})` : ""}` : g.mediaEntityId} + + {g.files.length} 个文件 + {g.error ? {g.error.message} : null} + {g.recordId ? ( + + 整理记录 + + ) : null} +
+ ); + })} +

+ 整理在后台执行(逐文件可恢复),完成后列表会自动刷新状态。 +

+
+ ) + ) : null} +
+ +
+
+ {step > 0 && step < 3 ? ( + + ) : null} +
+
+ {step === 0 ? ( + + ) : null} + {step === 1 ? ( + + ) : null} + {step === 2 ? ( + + ) : null} + {step === 3 && result ? ( + <> + + + + ) : null} +
+
+
+
+ ); +} + +function RootOption({ + checked, + onSelect, + title, + hint, +}: { + checked: boolean; + onSelect: () => void; + title: string; + hint: string; +}) { + return ( + + ); +} + +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 ( +
+
+ + {title ? `${title.title}${title.year ? ` (${title.year})` : ""}` : g.mediaEntityId} + + {g.error ? ( + 不能执行 + ) : ( + 计划就绪 + )} + + {g.files.length} 个文件 ·{" "} + {formatBytes( + g.files.reduce((s, f) => s + f.size, 0), + 1, + )} + +
+
+ {g.error ? ( +

+ + {g.error.message}(返回第 1 步修改后重新预览) +

+ ) : null} + {g.warnings.map((w) => ( +

+ + {w} +

+ ))} + {g.truncated ?

文件过多,预览只列出前一部分。

: null} + {dirs.length ? ( +

+ 目标目录:{dirs.join("、")} +

+ ) : null} +
    + {g.files.map((f) => ( +
  • +
    + {f.kind} + {formatBytes(f.size, 1)} + {!f.target ? 不入库 : null} +
    + +
  • + ))} +
+
+
+ ); +} + +function IdentifyRow({ + row, + onEntity, + onApplyGroup, + groupSize, + onChange, +}: { + row: WizardRow; + onEntity: (e: Entity | null) => void; + onApplyGroup: () => void; + groupSize: number; + onChange: (p: Partial) => void; +}) { + const p = row.item.parsed; + const problem = rowProblem(row); + const isMovie = row.entity?.kind === "movie"; + return ( +
  • +
    +
    +

    + {baseName(row.path)} +

    +

    + 解析:{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}` : ""} +

    + {row.item.warnings.map((w) => ( +

    + {w} +

    + ))} +
    + +
    + {!row.excluded ? ( +
    +
    + + {groupSize > 1 && row.entity ? ( + + ) : null} +
    + onChange({ season: e.target.value })} + className="h-9 text-center" + /> + onChange({ episodes: e.target.value })} + className="h-9 text-center" + /> + onChange({ slotId })} + /> +
    + ) : null} + {problem ?

    {problem}

    : null} +
  • + ); +} + +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 ( + + ); +} + +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 ( + + + + + + {local.length ? ( +

    + 候选 +

    + ) : null} +
      + {local.map((c) => ( +
    • + +
    • + ))} +
    + {remoteOnly.length ? ( +

    + TMDB 另有 {remoteOnly.length} 个未入库候选( + {remoteOnly.map((c) => `${c.title}${c.year ? ` ${c.year}` : ""}`).join("、")}), + 需先在“身份与元数据”建立作品。 +

    + ) : null} +
    + + setQ(e.target.value)} + placeholder="搜索本地作品" + className="h-8 pl-8 text-caption" + /> +
    + {results.length ? ( +
      + {results.map((e) => ( +
    • + +
    • + ))} +
    + ) : q.trim() ? ( +

    没有匹配的本地作品

    + ) : null} + {value ? ( + + ) : null} +
    +
    + ); +} diff --git a/apps/web/app/components/files/path-breadcrumb.tsx b/apps/web/app/components/files/path-breadcrumb.tsx new file mode 100644 index 00000000..6e49f1de --- /dev/null +++ b/apps/web/app/components/files/path-breadcrumb.tsx @@ -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(null); + + const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => ( +
      + {items.map((c, i) => { + const last = i === items.length - 1; + return ( +
    1. + {i > 0 ? : null} + {c === null ? ( + … + ) : ( + + )} +
    2. + ); + })} +
    + ); + + return ( + + ); +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index e5144947..00a6b31f 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -15,6 +15,8 @@ export default [ route("identity", "routes/console/identity.tsx"), route("organize", "routes/console/organize.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("preferences", "routes/console/preferences.tsx"), route("sites", "routes/console/sites.tsx"), diff --git a/apps/web/app/routes/console/files.trash.tsx b/apps/web/app/routes/console/files.trash.tsx new file mode 100644 index 00000000..e29d0f5b --- /dev/null +++ b/apps/web/app/routes/console/files.trash.tsx @@ -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>(new Set()); + const [restoreTo, setRestoreTo] = useState(null); + const [purgeAll, setPurgeAll] = useState(false); + const [purgeIds, setPurgeIds] = useState(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[] = [ + { + key: "pick", + header: ( + 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) => ( + 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 ( +
    + {r.isDir ? ( + + ) : ( + + )} +
    +

    + {baseName(r.originalPath)} +

    +

    + {root?.name ?? r.rootId} / {r.originalPath} +

    + {snap?.title ? ( +

    + 删除时关联:{snap.title} + {snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原不会自动恢复资产) +

    + ) : null} +
    +
    + ); + }, + }, + { + key: "size", + header: "大小", + hideBelow: "sm", + align: "right", + cell: (r) => {formatBytes(r.size, 1)}, + }, + { + key: "deleted", + header: "删除", + hideBelow: "md", + cell: (r) => ( + + {relTime(r.deletedAt)} + {r.actor ? ( + {r.actor.replace(/^admin_session:/, "管理员 ")} + ) : null} + + ), + }, + { + key: "expires", + header: "到期清除", + cell: (r) => ( + + + {Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)} + + + ), + }, + { + key: "act", + header: 操作, + align: "right", + cell: (r) => ( +
    + + + {isAdmin ? ( + + ) : null} +
    + ), + }, + ]; + + return ( + <> + + {filesMockEnabled() ? 演示数据 : null} + + + 文件 + + + } + /> +
    + + {isAdmin ? ( + <> + + + + 清除已到期 {expired.length || ""} + + } + title="清除已到期的项?" + description={`${expired.length} 项已过保留期,清除后不可恢复。`} + confirmLabel="清除" + destructive + onConfirm={() => doPurge(undefined)} + /> + + + ) : ( + 立即清除仅管理员会话可用。 + )} +
    + 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: "在文件页删除的项会先移到这里。" }} + /> + + !o && setPurgeIds(null)} + title={`立即清除 ${purgeIds?.length ?? 0} 项?`} + description="清除后不可恢复。" + confirmLabel="清除" + destructive + onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))} + /> + + {/* 清空全部:二次确认,需输入“清空回收站” */} + { + 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("")); + }} + > + setConfirmText(e.target.value)} + placeholder="输入 清空回收站" + aria-label="输入 清空回收站 以确认" + /> + + + {restoreTo ? ( + 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(null); + return ( + !o && onClose()}> + + + 还原到… + {ids.length} 项 · 选一个可写的目录;同名已存在的项会失败,不会覆盖。 + +
    + +
    + + + + +
    +
    + ); +} diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx new file mode 100644 index 00000000..26c7782f --- /dev/null +++ b/apps/web/app/routes/console/files.tsx @@ -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 = { + 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, 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(emptySelection); + // biome-ignore lint/correctness/useExhaustiveDependencies: 换目录清空选择 + useEffect(() => setSel(emptySelection()), [rootId, path]); + useEffect(() => { + setSel((s) => { + const n = pruneSelection(s, entries.map(keyOf)); + return n.keys.size === s.keys.size && n.anchor === s.anchor && n.focus === s.focus ? s : n; + }); + }, [entries, keyOf]); + + const [dialog, setDialog] = useState(null); + const [menu, setMenu] = useState<{ at: MenuAnchor; entry: FileEntry } | null>(null); + const [treeOpen, setTreeOpen] = useState(false); + const dragRefs = useRef([]); + const gridRef = useRef(null); + const searchRef = useRef(null); + const probe = useProbe(); + + const selected = useMemo(() => entries.filter((e) => sel.keys.has(keyOf(e))), [entries, sel.keys, keyOf]); + const detail = selParam != null ? (entries.find((e) => e.path === selParam) ?? null) : null; + + // SSE:files.changed 与整理事件 → 刷新受影响目录 + useSse([FILES_CHANGED, EventType.organizationChanged], (ev) => { + const p = ev.payload as { rootId?: string; dirs?: string[] } | undefined; + if (ev.type === FILES_CHANGED && p?.rootId) { + for (const d of p.dirs ?? [""]) { + void qc.invalidateQueries({ queryKey: ["files", "list", p.rootId, d] }); + void qc.invalidateQueries({ queryKey: filesKeys.tree(p.rootId, d) }); + } + } 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[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 = ( +
    + {roots.isPending ? : null} + {roots.error ? void roots.refetch()} /> : null} + {rootList.length ? ( + + ) : null} + {roots.data && !rootList.length ? ( + + ) : null} + + + 回收站 + +
    + ); + + // ── 列表区主体 ── + const offline = list.error && isRootOffline(list.error); + const filtered = !!(q || filter); + let body: React.ReactNode; + if (!root) { + body = roots.isPending ? : null; + } else if (root.health === "offline" || offline) { + body = ( +
    + + + +

    {root.name} 离线

    +

    + {offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)} + 。不会把离线当成空目录,也不会改变任何资产状态。 +

    +

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

    + +
    + ); + } else if (list.isPending) { + body = ; + } else if (list.error && !entries.length) { + body = ( + void list.refetch()} + /> + ); + } else if (!entries.length) { + body = filtered ? ( + + + 清除筛选 + + } + /> + ) : ( + setDialog({ kind: "mkdir" })}> + + 新建目录 + + ) : undefined + } + /> + ); + } else { + body = ( + setMenu({ 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 ? ( +
    + + 已选 {selCount} 项{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} + + + + + + + +
    + ) : null; + + return ( + <> + + {filesMockEnabled() ? 演示数据 : null} + + + 回收站 + + + } + /> +
    + {/* 桌面左栏:根 + 目录树(可拖宽) */} + + +
    + {/* 工具栏 */} +
    +
    + + {root ? ( + go({ root: root.id, path: p })} + onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)} + className="min-w-0 flex-1" + /> + ) : ( + + )} + + +
    +
    +
    + + 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" + /> +
    +
    + {FILTER_CHIPS.map((c) => ( + + ))} +
    +
    + + + +
    +
    +
    + + {first?.truncated ? ( +
    + 目录过大,只读取了前 {first.total.toLocaleString("zh-CN")} 项。请用搜索或进入子目录缩小范围。 +
    + ) : null} + +
    {body}
    + + {/* 状态栏 */} + {root && !offline && root.health !== "offline" ? ( +
    + + {first ? `${entries.length.toLocaleString("zh-CN")} / ${first.total.toLocaleString("zh-CN")} 项` : "—"} + + {selCount ? ( + + 已选 {selCount} + {totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} + + ) : null} + {root.usage ? 剩余 {formatBytes(root.usage.freeBytes, 1)} : null} + {!root.writable ? 只读 : null} +
    + ) : null} + {!mobile ? actionBar : null} +
    +
    + {mobile ? actionBar : null} + + {/* 手机:根与目录树收进抽屉 */} + + + + 根与目录 + +
    {tree}
    +
    +
    + + setMenu(null)} /> + + {detail && root ? ( + patch({ sel: null })} + actions={ + <> + + + {detail.asset ? null : ( + + )} + + + } + /> + ) : null} + + {dialog?.kind === "rename" && root ? ( + setDialog(null)} /> + ) : null} + {dialog?.kind === "transfer" ? ( + setDialog(null)} + /> + ) : null} + {dialog?.kind === "delete" ? ( + setDialog(null)} /> + ) : null} + {root ? ( + !o && setDialog(null)} + /> + ) : null} + {dialog?.kind === "copyPath" && root ? ( + setDialog(null)} /> + ) : null} + {dialog?.kind === "scrape" ? setDialog(null)} /> : null} + {dialog?.kind === "wizard" && root ? ( + setDialog(null)} /> + ) : null} + + ); +} diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index 8b84f020..d2feabf9 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -27,6 +27,8 @@ const STATIC_ROUTES = [ "/console/library", "/console/identity", "/console/organize", + "/console/files", + "/console/files/trash", "/console/subscriptions", "/console/preferences", "/console/sites",