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

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

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

No files matched your search

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