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

1289 lines
52 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,
Upload,
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,
useUpload,
} 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,
displayPath,
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;
}
// ─── 上传 ───────────────────────────────────────────────────────
/**
* 上传到当前目录(对照 MP 文件管理的上传;Alist 根经服务端转传):选多个文件逐个上传,同名不覆盖并提示。
* 只读目录与下载目录不能上传。
*/
export function UploadButton({ root, path }: { root: FileRoot; path: string }) {
const upload = useUpload();
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState<string | null>(null);
const disabled = !root.writable || busy !== null;
const run = async (files: FileList | null) => {
if (!files?.length) return;
let ok = 0;
const failed: string[] = [];
for (const file of Array.from(files)) {
setBusy(file.name);
try {
await upload.mutateAsync({ root: root.id, path, file });
ok++;
} catch (e) {
failed.push(`${file.name}:${filesErrorReason(e) === "exists" ? "同名已存在,没有覆盖" : filesErrorText(e)}`);
}
}
setBusy(null);
if (input.current) input.current.value = "";
if (ok) toast.success(`已上传 ${ok} 个文件到 ${displayPath(root, path)}`);
if (failed.length) toast.error(`${failed.length} 个文件上传失败`, { description: failed.join("\n") });
};
return (
<span title={root.writable ? undefined : "只读目录,不能上传"}>
<input ref={input} type="file" multiple hidden onChange={(e) => void run(e.currentTarget.files)} />
<Button variant="outline" size="sm" disabled={disabled} onClick={() => input.current?.click()}>
{busy ? <LoaderCircle className="animate-spin" /> : <Upload />}
{busy ? "上传中" : "上传"}
</Button>
</span>
);
}
// ─── 新建目录 ───────────────────────────────────────────────────
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">{displayPath(root, 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}
includeStorage
onChange={(loc) => {
setTarget(loc);
runPreview(loc, mode);
}}
className="max-h-72 md:max-h-96"
/>
<p className="text-meta text-muted-foreground">目标可以是可写的媒体库目录或 Alist 存储。</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">{displayPath(targetRoot, 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 未满足,不能移动。可以改用复制。
<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 ? displayPath(sr, p.source.path) : `${p.source.root}/${p.source.path}`}
to={tr ? displayPath(tr, p.target.path) : `${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"
? "永久删除没有开启。改用回收站"
: 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>硬链接:只删这一处,其他位置的文件不受影响</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
? "无法恢复。部署时设置 FILES_ALLOW_PERMANENT_DELETE=true 才能用。"
: "只有管理员会话可用。"}
</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 = displayPath(root, entry.path);
const rows = [
{ label: root.storage ? "Alist 路径" : "本机路径", 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/logs?type=task&run=${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" | "update" | null>(null);
const curMode = mode ?? settings.data?.organize.scrapeMode ?? "update";
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 和图片。没整理过的文件也能刮削。</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">
{(
[
["update", "更新"],
["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>
);
}