- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px - 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多" - 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB - 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行), TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、 plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录 - 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…" - 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1058 lines
41 KiB
TypeScript
1058 lines
41 KiB
TypeScript
import {
|
|
ArrowRight,
|
|
Copy,
|
|
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 FilesSuggestNameResult,
|
|
type FilesTransferResult,
|
|
filesErrorReason,
|
|
filesErrorText,
|
|
useDelete,
|
|
useFileStat,
|
|
useMkdir,
|
|
useMove,
|
|
useRename,
|
|
useScrapeBatch,
|
|
useSuggestName,
|
|
} from "~/api/files.ts";
|
|
import { FormField } from "~/components/console/form-field.tsx";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
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,
|
|
} 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);
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─── 刷新元数据(MP 的"刮削") ─────────────────────────────────────
|
|
|
|
/**
|
|
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。
|
|
* NFO / 图片投影还没实现,不显示这两项。
|
|
*/
|
|
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);
|
|
(r.skipped.length ? toast.warning : toast.success)(
|
|
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
|
|
);
|
|
},
|
|
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
|
|
},
|
|
);
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
|
|
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的“刮削”。</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 === "not_registered"
|
|
? "没有已登记资产"
|
|
: 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>
|
|
);
|
|
}
|