Files
UHD_Media_Tool/apps/web/app/components/files/dialogs.tsx
T

1258 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowRight,
Copy,
FileDown,
FolderInput,
FolderPlus,
LoaderCircle,
PenLine,
RefreshCw,
Trash2,
TriangleAlert,
Undo2,
Wand2,
} 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 FilesScrapeBatchResult,
type FilesScrapeNeedsChoice,
type FilesSuggestNameResult,
type FilesTransferResult,
filesErrorReason,
filesErrorText,
useDelete,
useFileStat,
useMkdir,
useMove,
useRename,
useScrape,
useScrapeBatch,
useScrapeRecords,
useSuggestName,
} from "~/api/files.ts";
import { useSettings } from "~/api/settings.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ScrapeRecordView } from "~/components/library/scrape-panel.tsx";
import { EntityPicker } from "~/components/organize-wizard/entity-picker.tsx";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { PathSegments } from "~/components/organize-wizard/path-segments.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 { DirPicker } from "./dir-picker.tsx";
import {
baseName,
deleteBlockText,
nameProblem,
nameTaken,
newIdempotencyKey,
splitCommon,
stemRange,
transferTargetProblem,
} 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>
);
}
// ─── 重命名 ─────────────────────────────────────────────────────
const SUGGEST_REASON: Record<string, string> = {
identity_uncertain: "识别不可信,没有推荐名",
};
const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "媒体库目录",
global: "全局",
override: "覆写",
};
function suggestReasonText(r: string | null): string {
if (!r) return "没有推荐名";
if (SUGGEST_REASON[r]) return SUGGEST_REASON[r] as string;
if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`;
return r;
}
/**
* 重命名(§8):默认值取命名模板推荐名(POST /files/suggest-name),用户没改过时自动填入;
* `按模板生成` 重新取;同名即时提示;输入停顿后 dryRun 列出一起改名的附属文件与资产/绑定同步。
*/
export function RenameDialog({
root,
entry,
siblings = [],
onClose,
}: {
root: FileRoot;
entry: FileEntry;
/** 当前目录其他项的名称(同名即时提示) */
siblings?: string[];
onClose: () => void;
}) {
const isDir = entry.type === "dir";
const [name, setName] = useState(entry.name);
const edited = useRef(false);
const [withSidecars, setWithSidecars] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [suggested, setSuggested] = useState<FilesSuggestNameResult | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const key = useMemo(() => newIdempotencyKey(), []);
const preview = useRename();
const exec = useRename();
const suggest = useSuggestName();
const debounced = useDebounced(name.trim(), 350);
const problem = nameProblem(name);
const changed = name.trim() !== entry.name;
const taken = nameTaken(name, entry.name, siblings);
const canSuggest = isDir || entry.fileKind === "video";
const selectStem = (v: string) => {
const el = inputRef.current;
if (!el) return;
el.focus();
const [a, b] = stemRange(v, isDir);
requestAnimationFrame(() => el.setSelectionRange(a, b));
};
// biome-ignore lint/correctness/useExhaustiveDependencies: 打开时选中主名一次
useEffect(() => selectStem(entry.name), [entry.name, isDir]);
const runSuggest = (apply: boolean) =>
suggest.mutate(
{ root: root.id, path: entry.path },
{
onSuccess: (r) => {
setSuggested(r);
if (r.name && apply) {
setName(r.name);
setErr(null);
selectStem(r.name);
}
},
},
);
// 打开时取推荐名;用户还没动过输入框就直接填入
const suggestMutate = suggest.mutate;
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开时取一次
useEffect(() => {
if (!canSuggest) return;
suggestMutate(
{ root: root.id, path: entry.path },
{
onSuccess: (r) => {
setSuggested(r);
if (r.name && r.name !== entry.name && !edited.current) {
setName(r.name);
selectStem(r.name);
}
},
},
);
}, [suggestMutate]);
// 输入停顿后 dryRun:列出将一起改名的附属文件与资产/绑定同步
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 submit = (ev: React.FormEvent) => {
ev.preventDefault();
if (problem) return setErr(problem);
if (!changed) return onClose();
if (taken) return setErr("同名已存在,不会覆盖");
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;
const usingSuggestion = !!suggested?.name && name.trim() === suggested.name;
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 : taken ? "同名已存在,不会覆盖" : null)}
>
<div className="flex gap-2">
<Input
id="rename-name"
ref={inputRef}
value={name}
aria-invalid={!!err || taken}
onChange={(e) => {
setName(e.target.value);
edited.current = true;
setErr(null);
}}
className="font-mono"
/>
{canSuggest ? (
<Button type="button" variant="outline" onClick={() => runSuggest(true)} disabled={suggest.isPending}>
{suggest.isPending ? <LoaderCircle className="animate-spin" /> : <Wand2 />}
按模板生成
</Button>
) : null}
</div>
</FormField>
{canSuggest ? (
<div className="-mt-2 space-y-1 text-meta text-muted-foreground">
{suggest.isPending && !suggested ? (
<p className="flex items-center gap-1.5">
<LoaderCircle className="h-3 w-3 animate-spin" /> 取推荐名…
</p>
) : suggest.error ? (
<p className="text-destructive">{filesErrorText(suggest.error, "取推荐名失败")}</p>
) : suggested && !suggested.name ? (
<p>{suggestReasonText(suggested.reason)}</p>
) : suggested?.name ? (
<>
<p className="flex flex-wrap items-center gap-x-2">
{suggested.name === entry.name
? "当前名称与命名模板一致"
: usingSuggestion
? "已填入推荐名"
: `推荐名:${suggested.name}`}
{suggested.templateSource
? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}`
: ""}
{usingSuggestion && name.trim() !== entry.name ? (
<button
type="button"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:text-foreground"
onClick={() => {
setName(entry.name);
edited.current = true;
selectStem(entry.name);
}}
>
<Undo2 className="h-3 w-3" />
恢复原名
</button>
) : null}
</p>
{suggested.segments.length > 1 ? (
<PathSegments segments={suggested.segments} className="text-muted-foreground" />
) : null}
</>
) : null}
</div>
) : null}
{!isDir ? (
<div className="flex items-center justify-between gap-3 text-caption">
<span>
同名附属文件一起改
<span className="block text-meta text-muted-foreground">主干相同的字幕、NFO、图片</span>
</span>
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" />
</div>
) : null}
{changed && !problem && !taken ? (
<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 || taken}>
{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);
const problem = transferTargetProblem(items, t);
if (problem) {
setErr(problem);
return;
}
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>
<DirPicker
roots={roots}
value={target}
onChange={(loc) => {
setTarget(loc);
runPreview(loc, mode);
}}
className="max-h-72 md:max-h-96"
/>
<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/downloads/${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>
);
}
// ─── 刷新元数据 ─────────────────────────────────────────────
/**
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。只更新本工具的字段,不写文件。
*/
export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) {
const batch = useScrapeBatch();
const key = useMemo(() => newIdempotencyKey(), []);
const [result, setResult] = useState<FilesScrapeBatchResult | null>(null);
const entities = [...new Map(items.filter((e) => e.asset).map((e) => [e.asset?.mediaEntityId, e.asset])).values()];
const single = entities.length === 1 ? entities[0] : null;
const run = () =>
batch.mutate(
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) },
{
onSuccess: (r) => {
setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : "";
(r.skipped.length ? toast.warning : toast.success)(
`已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
},
);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
刷新元数据
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle>
<DialogDescription>按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
{entities.map((a) => (
<li key={a?.mediaEntityId} className="truncate">
{a?.title}
{a?.year ? ` (${a.year})` : ""}
</li>
))}
</ul>
) : null}
{result ? (
<div className="space-y-2 text-caption">
{result.submitted.map((s) => (
<div key={s.mediaEntityId} className="rounded-lg border border-border/60 px-3 py-2">
<p className="flex items-center justify-between gap-2">
<span className="truncate">{s.title}</span>
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
查看运行
</Link>
</p>
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
</div>
))}
{result.skipped.map((s) => (
<p key={s.ref} className="text-meta text-warning">
{s.ref}:
{s.reason === "no_entity" ? "不属于媒体库里的作品" : s.reason === "not_found" ? "作品不存在" : s.reason}
</p>
))}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{result ? "完成" : "取消"}
</Button>
{result ? null : (
<Button onClick={run} disabled={batch.isPending || !entities.length}>
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 刮削(SCR-2) ─────────────────────────────────────────
const SKIP_TEXT: Record<string, string> = {
scrape_root_read_only: "只登记模式的目录,不写文件",
scrape_not_video: "不是视频文件或目录",
scrape_no_videos: "目录里没有视频",
};
/**
* 刮削(照 MP 文件管理的刮削):任意目录 / 视频文件 → 服务端按路径识别作品 → 在所在目录写 NFO 与图片。
* 不要求已登记;认不准的项列出候选,选一个后「继续刮削」(可顺手加入媒体库)。覆盖方式缺省按设置,这里可临时改。
*/
export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; root: FileRoot; onClose: () => void }) {
const settings = useSettings();
const scrape = useScrape();
const [mode, setMode] = useState<"missing" | "overwrite" | null>(null);
const curMode = mode ?? settings.data?.organize.scrapeMode ?? "missing";
const [recordIds, setRecordIds] = useState<string[]>([]);
const [choices, setChoices] = useState<FilesScrapeNeedsChoice[]>([]);
const [picked, setPicked] = useState<Record<string, WizardEntity | null>>({});
const [skipped, setSkipped] = useState<{ path: string; reason: string }[]>([]);
const [addToLibrary, setAddToLibrary] = useState(false);
const [started, setStarted] = useState(false);
const records = useScrapeRecords(recordIds);
const single = items.length === 1 ? items[0] : null;
const submit = (list: { path: string; pick?: WizardEntity | null }[]) =>
scrape.mutate(
{
idempotencyKey: newIdempotencyKey(),
mode: curMode,
addToLibrary,
items: list.map((x) => ({
root: root.id,
path: x.path,
...(x.pick?.id ? { mediaEntityId: x.pick.id } : {}),
...(x.pick && !x.pick.id && x.pick.external ? { external: x.pick.external } : {}),
})),
},
{
onSuccess: (r) => {
setStarted(true);
const done = new Set(list.map((x) => x.path));
setRecordIds((ids) => [...ids, ...r.records.map((x) => x.id)]);
setChoices((cur) => [...cur.filter((c) => !done.has(c.path)), ...r.needsChoice]);
setSkipped((cur) => [...cur, ...r.skipped.map((x) => ({ path: x.path, reason: x.reason }))]);
if (r.entities.length) toast.success(`已加入媒体库:${r.entities.map((e) => e.title).join("、")}`);
},
onError: (e) => toast.error(filesErrorText(e, "刮削没有提交")),
},
);
const ready = choices.filter((c) => picked[c.path]);
const recs = records.data?.records ?? [];
const pending = recs.some((r) => r.status === "queued" || r.status === "running");
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
<DialogDescription>
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没入库的也能刮削。
</DialogDescription>
</DialogHeader>
{!started ? (
<div className="grid gap-3">
{!single ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
{items.map((e) => (
<li key={e.path} className="truncate">
{e.path}
</li>
))}
</ul>
) : null}
<FormField label="已有文件">
<fieldset aria-label="已有文件" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
{(
[
["missing", "只补缺失"],
["overwrite", "全部覆盖"],
] as const
).map(([v, label]) => (
<button
key={v}
type="button"
aria-pressed={curMode === v}
onClick={() => setMode(v)}
className={cn(
"inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium",
curMode === v
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{label}
</button>
))}
</fieldset>
</FormField>
{curMode === "overwrite" ? (
<p className="text-meta text-warning">目录里已有的 NFO 和图片会被替换,包括其他工具写的。</p>
) : null}
</div>
) : (
<div className="grid max-h-[60vh] gap-4 overflow-auto">
{choices.length ? (
<section className="grid gap-2">
<p className="text-sm font-medium">认不准作品,选一个再刮削</p>
{choices.map((c) => (
<div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2">
<p className="text-caption break-all">{c.path || "目录根"}</p>
<EntityPicker
candidates={c.candidates}
value={picked[c.path] ?? null}
onChange={(e) => setPicked((p) => ({ ...p, [c.path]: e }))}
placeholder={c.candidates.length ? "选择作品…" : "搜索作品…"}
/>
{c.warnings.map((w) => (
<p key={w} className="text-meta text-muted-foreground">
{w}
</p>
))}
</div>
))}
<div className="flex items-center gap-2 text-caption">
<Switch id="scrape-add-to-library" checked={addToLibrary} onCheckedChange={setAddToLibrary} />
<label htmlFor="scrape-add-to-library">同时加入媒体库</label>
</div>
</section>
) : null}
{recs.map((r) => (
<div key={r.id} className="rounded-lg border border-border/60 px-3 py-2">
<ScrapeRecordView record={r} showPath />
{r.match?.mediaEntityId ? (
<Link className="text-meta underline" to={`/console/library/${r.match.mediaEntityId}`}>
打开作品
</Link>
) : null}
</div>
))}
{recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
{skipped.map((x) => (
<p key={x.path} className="text-meta text-warning break-all">
{x.path || "目录根"}:{SKIP_TEXT[x.reason] ?? x.reason}
</p>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{started ? "完成" : "取消"}
</Button>
{!started ? (
<Button onClick={() => submit(items.map((e) => ({ path: e.path })))} disabled={scrape.isPending}>
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <FileDown />}
{scrape.isPending ? "识别中…" : "刮削"}
</Button>
) : choices.length ? (
<Button
onClick={() => submit(ready.map((c) => ({ path: c.path, pick: picked[c.path] })))}
disabled={scrape.isPending || !ready.length}
>
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <FileDown />}
继续刮削
</Button>
) : pending ? (
<Button disabled>
<LoaderCircle className="animate-spin" />
刮削中…
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}