import { type PathMapping, PathMappingCreate } from "@umt/contracts"; import { LoaderCircle, Plus, ShieldCheck, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { libraryRoots, pathMappings } from "~/api/instances.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { zodFieldErrors } from "~/components/console/form-field.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { healthLabel } from "~/lib/labels.ts"; import { FieldControl, type FormValues, num, str } from "./resource-section.tsx"; // 实例抽屉里的路径映射:只列本实例的映射,可新增、校验、删除。 // 对方看到的路径前缀 → 本进程可访问的前缀;前缀越长越优先,同长按优先级。 const EMPTY: FormValues = { remotePrefix: "", localPrefix: "", libraryRootId: "none", priority: "0" }; export function PathMappingsPanel({ viewKind, instanceId, }: { viewKind: "downloader" | "media_server"; instanceId: string; }) { const list = pathMappings.useList(); const create = pathMappings.useCreate(); const remove = pathMappings.useRemove(); const verify = pathMappings.useVerify(); const roots = libraryRoots.useList(); const [adding, setAdding] = useState(false); const [v, setV] = useState(EMPTY); const [errors, setErrors] = useState>({}); const [deleting, setDeleting] = useState(null); const rows = (list.data?.items ?? []).filter((m) => viewKind === "downloader" ? m.downloaderInstanceId === instanceId : m.mediaServerInstanceId === instanceId, ); const rootOpts = [ { value: "none", label: "不关联" }, ...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })), ]; const rootName = (id: string | null) => roots.data?.items.find((r) => r.id === id)?.name; const submit = async (e: React.FormEvent) => { e.preventDefault(); const root = str(v.libraryRootId); const parsed = PathMappingCreate.safeParse({ viewKind, downloaderInstanceId: viewKind === "downloader" ? instanceId : null, mediaServerInstanceId: viewKind === "media_server" ? instanceId : null, remotePrefix: str(v.remotePrefix), localPrefix: str(v.localPrefix), libraryRootId: root && root !== "none" ? root : null, priority: num(v.priority) ?? 0, enabled: true, }); if (!parsed.success) { const fe = zodFieldErrors(parsed.error.issues); if (fe.localPrefix) fe.localPrefix = "填以 / 开头的绝对路径"; if (fe.remotePrefix) fe.remotePrefix = "必填"; setErrors(fe); return; } setErrors({}); try { await create.mutateAsync(parsed.data as never); toast.success("路径映射已添加"); setV(EMPTY); setAdding(false); } catch (err) { toast.error(errorMessage(err, "添加失败")); } }; return ( setAdding(true)}> 添加映射 ) } >

把{viewKind === "downloader" ? "下载器" : "媒体服务器"} 看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。

{list.isPending ? (

加载中…

) : rows.length === 0 ? (

还没有映射,路径两边一致时不用加。

) : (
    {rows.map((m) => { const h = healthLabel(m.verifyStatus); return (
  • {m.remotePrefix}

    → {m.localPrefix}

    {rootName(m.libraryRootId) ? (

    媒体库目录:{rootName(m.libraryRootId)}

    ) : null}
    {h.label}
  • ); })}
)} {adding ? (
setV((s) => ({ ...s, remotePrefix: x }))} disabled={create.isPending} /> setV((s) => ({ ...s, localPrefix: x }))} disabled={create.isPending} /> setV((s) => ({ ...s, libraryRootId: x }))} disabled={create.isPending} /> setV((s) => ({ ...s, priority: x }))} disabled={create.isPending} />
) : null} !o && setDeleting(null)} title="删除路径映射?" description="依赖这条映射的路径换算会失效,磁盘文件不动。" confirmLabel="删除映射" destructive onConfirm={async () => { if (!deleting) return; try { await remove.mutateAsync(deleting.id); toast.success("路径映射已删除"); } catch (err) { toast.error(errorMessage(err, "删除失败")); throw err; } }} />
); }