import {
ArrowRight,
Copy,
FileDown,
FolderInput,
FolderPlus,
LoaderCircle,
PenLine,
RefreshCw,
Trash2,
TriangleAlert,
Undo2,
Wand2,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileRef,
type FileRoot,
type FilesScrapeBatchResult,
type FilesScrapeNeedsChoice,
type FilesSuggestNameResult,
type FilesTransferResult,
filesErrorReason,
filesErrorText,
useDelete,
useFileStat,
useMkdir,
useMove,
useRename,
useScrape,
useScrapeBatch,
useScrapeRecords,
useSuggestName,
} from "~/api/files.ts";
import { useSettings } from "~/api/settings.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ScrapeRecordView } from "~/components/library/scrape-panel.tsx";
import { EntityPicker } from "~/components/organize-wizard/entity-picker.tsx";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { PathSegments } from "~/components/organize-wizard/path-segments.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { DirPicker } from "./dir-picker.tsx";
import {
baseName,
deleteBlockText,
nameProblem,
nameTaken,
newIdempotencyKey,
splitCommon,
stemRange,
transferTargetProblem,
} from "./model.ts";
// ─── 共用 ───────────────────────────────────────────────────────
/** 前后路径对照:公共前缀淡显,差异部分高亮 */
export function PathPair({ from, to, tone }: { from: string; to: string | null; tone?: "conflict" | "skip" }) {
if (!to) return
{from}
;
const s = splitCommon(from, to);
return (
{s.common}
{s.a}
{s.common}
{s.b}
);
}
function Notice({ tone, children }: { tone: "warning" | "destructive" | "neutral"; children: React.ReactNode }) {
return (
{tone !== "neutral" ?
: null}
{children}
);
}
function useDebounced(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(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 (
);
}
// ─── 重命名 ─────────────────────────────────────────────────────
const SUGGEST_REASON: Record = {
identity_uncertain: "识别不可信,没有推荐名",
};
const TEMPLATE_SOURCE: Record = {
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(null);
const [suggested, setSuggested] = useState(null);
const inputRef = useRef(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 (
);
}
// ─── 移动 / 复制到… ─────────────────────────────────────────────
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(initialTarget ?? null);
const [plan, setPlan] = useState(null);
const [err, setErr] = useState(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 (
);
}
// ─── 删除 ───────────────────────────────────────────────────────
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(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 (
);
}
// ─── 复制路径 ───────────────────────────────────────────────────
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 (
);
}
// ─── 刷新元数据 ─────────────────────────────────────────────
/**
* 刷新元数据(§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(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 (
);
}
// ─── 刮削(SCR-2) ─────────────────────────────────────────
const SKIP_TEXT: Record = {
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([]);
const [choices, setChoices] = useState([]);
const [picked, setPicked] = useState>({});
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 (
);
}