Files
UHD_Media_Tool/apps/web/app/components/files/dialogs.tsx
T
2026-09-28 02:09:39 +08:00

924 lines
37 KiB
TypeScript

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} 条资产位置,并写一条整理记录。</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} 项`} ·
先预览路径、冲突与空间,确认后执行。跨卷按复制 → 核验 → 删源执行。
</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 未满足,不能移动。改用复制,或等 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} 个(删除后退役,已满足的版本目标退回待处理)</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">
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
</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">到期前可在回收站还原。</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>
);
}