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:
1 parent
2e2d56184c
commit
89ea68465e
17 files changed
+6269
-2
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user