控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。
- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
1290 lines
52 KiB
TypeScript
1290 lines
52 KiB
TypeScript
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 { RecordLink, recordPath } from "~/components/console/record-sheet.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(recordPath("organize", r.recordId as string)),
|
||
},
|
||
}
|
||
: 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(recordPath("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?view=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}>
|
||
下载任务{" "}
|
||
<RecordLink className="underline hover:text-foreground" page="downloads" id={d.taskId}>
|
||
{d.taskId.slice(0, 8)}
|
||
</RecordLink>{" "}
|
||
· {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>
|
||
);
|
||
}
|