import { ArrowRight, Copy, FolderInput, FolderPlus, LoaderCircle, PenLine, Sparkles, Trash2, TriangleAlert, } 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 FilesTransferResult, filesErrorReason, filesErrorText, useDelete, useFileStat, useIdentify, useMkdir, useMove, useRename, useScrape, } from "~/api/files.ts"; import { FormField } from "~/components/console/form-field.tsx"; import { StatusBadge } from "~/components/console/status-badge.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 { DirTree } from "./dir-tree.tsx"; import { baseName, deleteBlockText, nameProblem, newIdempotencyKey, splitCommon, stemRange, suggestName, } 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 (
新建目录 {root.hostPath} {path ? `/${path}` : ""}/ { setName(e.target.value); setErr(null); }} />
); } // ─── 重命名 ───────────────────────────────────────────────────── export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) { const isDir = entry.type === "dir"; const [name, setName] = useState(entry.name); const [withSidecars, setWithSidecars] = useState(true); const [err, setErr] = useState(null); const inputRef = useRef(null); const key = useMemo(() => newIdempotencyKey(), []); const preview = useRename(); const exec = useRename(); const identify = useIdentify(); const debounced = useDebounced(name.trim(), 350); const problem = nameProblem(name); const changed = name.trim() !== entry.name; useEffect(() => { const el = inputRef.current; if (!el) return; el.focus(); const [a, b] = stemRange(entry.name, isDir); el.setSelectionRange(a, b); }, [entry.name, isDir]); // 输入停顿后 dryRun:列出将一起改名的 sidecars 与资产/绑定同步 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 suggest = () => identify.mutate( { items: [{ root: root.id, path: entry.path }] }, { onSuccess: (r) => { const it = r.items[0]; const c = it?.candidates[0]; if (!it || !c) return toast.info("识别结果不可信,没有建议名"); setName(suggestName(c, it.parsed, entry.name, isDir)); setErr(null); }, onError: (e) => toast.error(filesErrorText(e, "识别失败")), }, ); const submit = (ev: React.FormEvent) => { ev.preventDefault(); if (problem) return setErr(problem); if (!changed) return onClose(); 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; return ( !o && onClose()}>
重命名 {entry.name}
{ setName(e.target.value); setErr(null); }} className="font-mono" /> {entry.fileKind === "video" || isDir ? ( ) : null}
{!isDir ? (
同名附属文件一起改 同 stem 的字幕 / NFO / 图片
) : null} {changed && !problem ? (
{preview.isPending ? (

预览中

) : null} {pvErr ? {filesErrorText(pvErr)} : null} {pv ? ( <>
    {pv.renames.map((r) => (
  • {r.role === "main" ? "本体" : "附属"}
  • ))}
{pv.assetLocations > 0 ? ( 将同步 {pv.assetLocations} 条资产位置,并写一条整理记录。 ) : null} {pv.bindings.length > 0 ? ( 这是作品/季的绑定目录。改名后旧绑定标为已替代,新集入库到新目录。 ) : null} ) : null}
) : null}
); } // ─── 移动 / 复制到… ───────────────────────────────────────────── 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); 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 ( !o && onClose()}> {mode === "move" ? "移动到…" : "复制到…"} {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览路径、冲突与空间,确认后执行。跨卷按复制 → 核验 → 删源执行。
{(["move", "copy"] as const).map((m) => ( ))}
r.writable && r.health !== "offline").map((r) => r.id)} onSelect={(loc) => { setTarget(loc); runPreview(loc, mode); }} />

只有可写的库根能作为目标。

{target && targetRoot ? (

目标目录

{targetRoot.hostPath} {target.path ? `/${target.path}` : ""}

) : ( 在左侧选择目标目录。 )} {dry.isPending ? (

生成预览…

) : null} {err ? {err} : null} {plan ? ( <>
{plan.pairs.length} 项 · {formatBytes(plan.totalBytes, 1)} {plan.spaceOk ? "空间足够" : "空间不足"} {plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""} {conflicts.length ? ( 冲突 {conflicts.length}(跳过,不覆盖) ) : null} {plan.pairs.some((p) => p.registered) ? 含已登记资产,执行后同步位置 : null}
{hnrBlocked ? ( {plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 ) : null} {plan.truncated ? 源文件过多,预览只列出前一部分,执行时处理全部。 : null}
    {plan.pairs.map((p) => { const sr = rootById.get(p.source.root); const tr = rootById.get(p.target.root); return (
  • {p.conflict ? ( 目标已存在 · 跳过 ) : ( {mode === "move" ? "移动" : "复制"} )} {formatBytes(p.size, 1)}
  • ); })}
) : null}
); } // ─── 删除 ─────────────────────────────────────────────────────── 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 ( !o && onClose()}> {single ? `删除 ${single}?` : `删除 ${items.length} 项?`} 已检查做种任务、已登记资产和路径绑定。下载器里的任务不受影响。
{check.isPending ? (

引用检查中…

) : null} {err ? {err} : null} {checks.length ? (
    {checks.map((c) => { const b = deleteBlockText(effective(c.blocked)); const r = rootById.get(c.root); return (
  • {b ? ( {b.soft ? "需确认" : "不能删除"} ) : ( 可删除 )} {baseName(c.path) || r?.name} {c.isDir ? "目录 · " : ""} {formatBytes(c.size, 1)}
    {b ? (

    {b.text}

    ) : null}
    {c.downloads.map((d) => (

    下载任务{" "} {d.taskId.slice(0, 8)} {" "} · {d.status} · H&R{" "} {d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}

    ))} {c.assets.length ? (

    已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)

    ) : null} {c.bindings.map((bd) => (

    路径绑定:{bd.title} {bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}

    ))} {c.sharedInode ?

    共享 inode(硬链接):只删这一处链接,其他位置不受影响

    : null}
  • ); })}
) : null} {hasAssets ? ( ) : null} {hasBindings ? ( ) : null} {checks.length && deletable > 0 ? (
{mode === "permanent" ? ( 输入 {confirmPhrase} 以确认 } htmlFor="del-confirm" > setConfirmText(e.target.value)} className="font-mono" autoComplete="off" /> ) : null}
) : null}
); } // ─── 复制路径 ─────────────────────────────────────────────────── 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 ( !o && onClose()}> 复制路径 {entry.name}
    {rows.map((r) => (
  • {r.label}

    {r.value ?? (stat.isPending ? "…" : "—")}

    {r.value ? ( ) : null}
  • ))}

下载器与媒体服务器看到的路径按“设置 → 路径映射”换算。

); } // ─── 刮削 ─────────────────────────────────────────────────────── export function ScrapeDialog({ entry, onClose }: { entry: FileEntry; onClose: () => void }) { const [writeNfo, setWriteNfo] = useState(false); const [images, setImages] = useState(false); const scrape = useScrape(); const key = useMemo(() => newIdempotencyKey(), []); const asset = entry.asset; if (!asset) return null; const run = () => scrape.mutate( { idempotencyKey: key, mediaEntityId: asset.mediaEntityId, writeNfo, images }, { onSuccess: (r) => { const extra = r.nfo === "not_implemented" || r.images === "not_implemented" ? "还不支持写 NFO 和图片,只刷新了字段。" : r.nfo === "skipped" ? "目标 NFO 不是本工具生成且字段已锁定,未覆盖。" : ""; toast.success(`已提交元数据刷新${r.lockedFields.length ? `,${r.lockedFields.length} 个锁定字段不动` : ""}`, { description: extra || undefined, action: { label: "查看运行", onClick: () => window.location.assign(`/console/engine/${r.taskId}`) }, }); onClose(); }, onError: (e) => toast.error(filesErrorText(e, "刮削失败")), }, ); return ( !o && onClose()}> 刮削 {asset.title} 按字段刷新元数据,锁定的字段和路径不变。不覆盖非本工具生成的 NFO。
写 NFO 到绑定目录
下载海报/背景图
); }