feat(web): naming template form for global, library root and work overrides (FE-3)

- template editor per movie / tv / variety with variable insertion, live preview by segment role, error line:col and reset to default
- settings > library: global templates, lockFileNameForBound and per-root overrides
- work detail: naming override dialog (preview uses the existing path binding)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:06:16 +08:00
1 parent b908b4a0c9
commit 2c6a89f309
7 files changed
+993 -1

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
EntityNamingOverridePut,
LibraryRootNamingPut,
type NamingOverride,
type NamingPreviewInput,
NamingPreviewResult,
NamingSettings,
NamingTemplatesView,
NamingVariablesResponse,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
// 整理命名模板(W9-C):变量表、三级模板(全局 > 库根 > 作品)、预览与保存。
export const namingKeys = {
all: ["naming"] as const,
variables: ["naming", "variables"] as const,
templates: (q: NamingScope) => ["naming", "templates", q] as const,
preview: (input: NamingPreviewInput) => ["naming", "preview", input] as const,
};
export type NamingScope = { libraryRootId?: string; entityId?: string };
export function useNamingVariables() {
return useQuery({
queryKey: namingKeys.variables,
queryFn: ({ signal }) => getParsed(NamingVariablesResponse, "/naming/variables", undefined, signal),
staleTime: 30 * 60_000,
});
}
/** 三级模板 + MP 默认 + 按类型的生效模板与来源 */
export function useNamingTemplates(scope: NamingScope = {}, enabled = true) {
return useQuery({
queryKey: namingKeys.templates(scope),
enabled,
queryFn: ({ signal }) => getParsed(NamingTemplatesView, "/naming/templates", scope, signal),
});
}
/** 预览:input 为 null 时不请求;调用方负责防抖 */
export function useNamingPreview(input: NamingPreviewInput | null) {
return useQuery({
queryKey: namingKeys.preview(input ?? { kind: "movie" }),
enabled: !!input,
placeholderData: keepPreviousData,
retry: false,
queryFn: async () =>
parseOrThrow(NamingPreviewResult, await api.post<unknown>("/naming/preview", input), "/naming/preview"),
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: namingKeys.all });
void qc.invalidateQueries({ queryKey: qk.settings });
void qc.invalidateQueries({ queryKey: libraryKeys.roots });
};
}
/** 保存全局模板(逐条编译,有错 422,消息带 "movie: 行:列 …") */
export function useSaveGlobalNaming() {
const done = useInvalidate();
return useMutation({
mutationFn: (body: Partial<NamingSettings>) =>
api.put<unknown>("/naming/templates", body).then((d) => parseOrThrow(NamingSettings, d, "/naming/templates")),
onSuccess: done,
});
}
/** 库根覆盖;null = 清除覆盖 */
export function useSaveRootNaming() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, naming }: { id: string; naming: NamingOverride | null }) =>
api
.put<unknown>(`/library-roots/${id}/naming`, { naming })
.then((d) => parseOrThrow(LibraryRootNamingPut, d, "/library-roots/naming")),
onSuccess: done,
});
}
/** 作品命名覆盖;null = 清除覆盖 */
export function useSaveEntityNaming() {
const done = useInvalidate();
return useMutation({
mutationFn: ({ id, namingOverride }: { id: string; namingOverride: NamingOverride | null }) =>
api
.put<unknown>(`/media-entities/${id}/naming-override`, { namingOverride })
.then((d) => parseOrThrow(EntityNamingOverridePut, d, "/media-entities/naming-override")),
onSuccess: done,
});
}
@@ -0,0 +1,127 @@
import type { MediaKind, NamingKind } from "@umt/contracts";
import { FileSignature, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useNamingTemplates, useSaveEntityNaming } from "~/api/naming.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.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 { errorMessage } from "~/lib/api.ts";
import { overrideBody, SOURCE_LABEL, splitNamingErrors } from "./model.ts";
import { NAMING_NOTE, NamingTemplateEditor } from "./template-editor.tsx";
// 作品详情 → 命名覆盖:只编辑该作品类型对应的模板;留空沿用库根 / 全局。
export function namingKindOf(kind: MediaKind): NamingKind {
if (kind === "series") return "tv";
if (kind === "variety") return "variety";
return "movie";
}
/** 作品头部按钮 + 对话框(自带开关状态,挂载处只放一行) */
export function EntityNamingButton({ entityId, kind }: { entityId: string; kind: MediaKind }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<FileSignature />
命名覆盖
</Button>
{open ? (
<EntityNamingDialog entityId={entityId} kind={namingKindOf(kind)} onClose={() => setOpen(false)} />
) : null}
</>
);
}
function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kind: NamingKind; onClose: () => void }) {
const q = useNamingTemplates({ entityId });
const save = useSaveEntityNaming();
const [draft, setDraft] = useState<Partial<Record<NamingKind, string>> | null>(null);
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
useEffect(() => {
if (q.data && !draft) setDraft({ ...(q.data.entity ?? {}) });
}, [q.data, draft]);
const saved = q.data?.entity ?? {};
const inheritedTemplate = q.data ? (q.data.root?.[kind] ?? q.data.global[kind]) : "";
const inheritedSource = q.data?.root?.[kind] ? SOURCE_LABEL.root : SOURCE_LABEL.global;
const dirty = !!draft && (draft[kind] ?? "") !== (saved[kind] ?? "");
const put = async (clear: boolean) => {
if (!draft) return;
setErrors(undefined);
// 只改本类型,其它类型的已有覆盖原样保留
const next = clear ? { ...saved, [kind]: "" } : { ...saved, [kind]: draft[kind] ?? "" };
const body = overrideBody(next);
try {
await save.mutateAsync({ id: entityId, namingOverride: body });
setDraft({ ...(body ?? {}) });
toast.success(clear ? "已清除作品命名覆盖" : "作品命名覆盖已保存");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>命名覆盖</DialogTitle>
<DialogDescription>{NAMING_NOTE}</DialogDescription>
</DialogHeader>
{q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : q.isPending || !draft ? (
<LoadingState rows={4} />
) : (
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={saved[kind] ? "success" : "neutral"}>
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
</StatusBadge>
</div>
<NamingTemplateEditor
kinds={[kind]}
values={draft}
onChange={(k, v) => {
setDraft((d) => ({ ...d, [k]: v }));
setErrors(undefined);
}}
defaults={q.data.defaults}
inherited={{ [kind]: { template: inheritedTemplate, source: inheritedSource } }}
entityId={entityId}
saveErrors={errors}
/>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="ghost"
className="hover:text-destructive"
disabled={!saved[kind] || save.isPending}
onClick={() => put(true)}
>
<Trash2 />
清除覆盖
</Button>
<Button onClick={() => put(false)} disabled={!dirty || save.isPending}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { NamingKind, NamingOverride } from "@umt/contracts";
// 命名模板表单的纯函数:保存错误拆分、覆盖请求体、来源文案。
export const NAMING_KINDS: NamingKind[] = ["movie", "tv", "variety"];
export const SOURCE_LABEL = { global: "全局", root: "库根", entity: "作品" } as const;
/** 422 消息 "movie: 1:3 …; tv: …" → 按类型拆开,行列号改写成中文 */
export function splitNamingErrors(message: string): Partial<Record<NamingKind, string>> | null {
const out: Partial<Record<NamingKind, string>> = {};
for (const m of message.matchAll(/(movie|tv|variety):\s*([^;]+)/g)) {
const [, k, msg] = m;
const pos = /^(\d+):(\d+)\s*(.*)$/.exec((msg ?? "").trim());
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
}
return Object.keys(out).length ? out : null;
}
/** 覆盖草稿 → 请求体;全部留空 = null(清除覆盖) */
export function overrideBody(draft: Partial<Record<NamingKind, string>>): NamingOverride | null {
const o: NamingOverride = {};
for (const k of NAMING_KINDS) {
const v = draft[k];
if (v?.trim()) o[k] = v;
}
return Object.keys(o).length ? o : null;
}
@@ -0,0 +1,503 @@
import type { NamingKind, NamingPreviewResult, NamingVariablesResponse } from "@umt/contracts";
import { Braces, ChevronRight, RotateCcw, Search, Undo2 } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useNamingPreview, useNamingVariables } from "~/api/naming.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { cn } from "~/lib/utils.ts";
// 命名模板编辑器(设置全局 / 库根覆盖 / 作品覆盖共用):按类型分段,变量点击插入,实时预览带段角色与错误位置。
export const NAMING_KIND_LABEL: Record<NamingKind, string> = { movie: "电影", tv: "剧集", variety: "综艺" };
const GROUP_LABEL: Record<NamingVariablesResponse["items"][number]["group"], string> = {
title: "标题",
episode: "季集",
tech: "技术参数",
id: "外部身份",
ours: "其他",
};
const GROUP_ORDER = ["title", "episode", "tech", "id", "ours"] as const;
const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: string; className: string }> = {
category: { label: "分类", className: "text-muted-foreground" },
root: { label: "作品根", className: "text-primary font-medium" },
season: { label: "季目录", className: "text-foreground" },
sub: { label: "子目录", className: "text-muted-foreground" },
file: { label: "文件名", className: "text-foreground font-medium" },
};
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
function useDebounced<T>(v: T, ms = 300): T {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
/** 1 起的行列号 → 字符偏移 */
function offsetOf(text: string, line: number, col: number): number {
const lines = text.split("\n");
let off = 0;
for (let i = 0; i < Math.min(line - 1, lines.length); i++) off += (lines[i]?.length ?? 0) + 1;
return Math.min(text.length, off + Math.max(0, col - 1));
}
export type NamingEditorProps = {
kinds: NamingKind[];
values: Partial<Record<NamingKind, string>>;
onChange: (kind: NamingKind, value: string) => void;
/** MP 默认(恢复默认) */
defaults: Record<NamingKind, string>;
/** 覆盖模式:留空沿用的上级模板与来源说明;不传 = 全局模式(必填) */
inherited?: Partial<Record<NamingKind, { template: string; source: string }>>;
/** 预览上下文 */
libraryRootId?: string;
entityId?: string;
/** 保存失败时服务端给的逐类型错误 */
saveErrors?: Partial<Record<NamingKind, string>>;
/** 各类型是否与已保存值不同 */
dirtyKinds?: Partial<Record<NamingKind, boolean>>;
};
export function NamingTemplateEditor({
kinds,
values,
onChange,
defaults,
inherited,
libraryRootId,
entityId,
saveErrors,
dirtyKinds,
}: NamingEditorProps) {
const [kind, setKind] = useState<NamingKind>(kinds[0] ?? "movie");
const active = kinds.includes(kind) ? kind : (kinds[0] ?? "movie");
const vars = useNamingVariables();
const ref = useRef<HTMLTextAreaElement>(null);
const override = !!inherited;
const value = values[active] ?? "";
const effective = value.trim() ? value : (inherited?.[active]?.template ?? "");
const insert = (token: string) => {
const el = ref.current;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const next = value.slice(0, start) + token + value.slice(end);
onChange(active, next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
};
const jumpTo = (line: number, col: number) => {
const el = ref.current;
if (!el) return;
const off = offsetOf(el.value, line, col);
el.focus();
el.setSelectionRange(off, Math.min(el.value.length, off + 1));
};
return (
<div className="grid min-w-0 gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
{kinds.length > 1 ? (
<Tabs value={active} onValueChange={(v) => setKind(v as NamingKind)}>
<TabsList aria-label="作品类型">
{kinds.map((k) => (
<TabsTrigger key={k} value={k}>
{NAMING_KIND_LABEL[k]}
{dirtyKinds?.[k] ? (
<span className="h-1.5 w-1.5 rounded-full bg-warning">
<span className="sr-only">未保存</span>
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : (
<span className="text-body font-medium text-foreground">{NAMING_KIND_LABEL[active]}模板</span>
)}
<div className="flex items-center gap-1">
{override ? (
<Button type="button" variant="ghost" size="sm" disabled={!value} onClick={() => onChange(active, "")}>
<Undo2 />
沿用上级
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
disabled={value === defaults[active]}
onClick={() => onChange(active, defaults[active])}
>
<RotateCcw />
恢复默认
</Button>
</div>
</div>
<div className="space-y-1.5">
<Textarea
ref={ref}
value={value}
onChange={(e) => onChange(active, e.target.value)}
rows={4}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
aria-label={`${NAMING_KIND_LABEL[active]}命名模板`}
placeholder={override ? (inherited?.[active]?.template ?? "") : "{{title}}/{{title}}{{fileExt}}"}
aria-invalid={!!saveErrors?.[active] || undefined}
className="max-h-60 min-h-24 font-mono text-caption leading-relaxed break-all field-sizing-content"
/>
<div className="flex flex-wrap items-center justify-between gap-2 text-meta text-muted-foreground">
<span>
{override && !value.trim()
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
</span>
<span className="tabular-nums">{value.length} / 1024</span>
</div>
{saveErrors?.[active] ? <p className="text-caption text-destructive">{saveErrors[active]}</p> : null}
</div>
<NamingPreview
kind={active}
template={effective}
libraryRootId={libraryRootId}
entityId={entityId}
samples={vars.data?.samples ?? []}
onJump={jumpTo}
/>
</div>
<VariablePalette data={vars.data} loading={vars.isPending} error={vars.error} onInsert={insert} />
</div>
);
}
// ── 预览 ──
const CUSTOM = "custom";
function NamingPreview({
kind,
template,
libraryRootId,
entityId,
samples,
onJump,
}: {
kind: NamingKind;
template: string;
libraryRootId?: string;
entityId?: string;
samples: NamingVariablesResponse["samples"];
onJump: (line: number, col: number) => void;
}) {
const ofKind = samples.filter((s) => s.kind === kind);
const [sample, setSample] = useState<string>("");
const [fileName, setFileName] = useState("");
// 切换类型后选中该类型的第一条样本
const sampleId = sample && (sample === CUSTOM || ofKind.some((s) => String(s.id) === sample)) ? sample : "";
const selected = sampleId || (ofKind[0] ? String(ofKind[0].id) : "");
const input = useMemo(() => {
if (!template.trim()) return null;
const s =
selected === CUSTOM
? fileName.trim()
? { fileName: fileName.trim() }
: undefined
: selected
? { builtin: Number(selected) }
: undefined;
return {
template,
kind,
...(s ? { sample: s } : {}),
...(libraryRootId ? { libraryRootId } : {}),
...(entityId ? { entityId } : {}),
};
}, [template, kind, selected, fileName, libraryRootId, entityId]);
const debounced = useDebounced(input, 300);
const q = useNamingPreview(debounced);
const r = q.data;
const stale = q.isFetching || debounced !== input;
return (
<div className="min-w-0 rounded-xl border border-border/70">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<span className="text-caption font-medium text-foreground">预览</span>
<Select value={selected} onValueChange={setSample}>
<SelectTrigger size="sm" aria-label="预览样本" className="max-w-full min-w-0 sm:max-w-80">
<SelectValue placeholder="选择样本" />
</SelectTrigger>
<SelectContent position="popper">
{ofKind.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.label}
</SelectItem>
))}
<SelectItem value={CUSTOM}>输入文件名</SelectItem>
</SelectContent>
</Select>
{stale && input ? <span className="text-meta text-muted-foreground">预览中…</span> : null}
</div>
<div className="space-y-3 px-3 py-3">
{selected === CUSTOM ? (
<Input
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="Show.Name.S01E02.1080p.WEB-DL.H264-GROUP.mkv"
aria-label="样例文件名"
className="font-mono text-caption"
/>
) : null}
{!input ? (
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
) : q.isError && !r ? (
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
) : !r ? (
<p className="text-caption text-muted-foreground">预览中…</p>
) : (
<PreviewBody r={r} template={template} onJump={onJump} />
)}
</div>
</div>
);
}
function PreviewBody({
r,
template,
onJump,
}: {
r: NamingPreviewResult;
template: string;
onJump: (line: number, col: number) => void;
}) {
return (
<>
<p className="text-meta break-all text-muted-foreground">
样本:<span className="font-mono">{r.sample.fileName}</span>
{r.sample.entityTitle ? ` · 作品 ${r.sample.entityTitle}` : ""}
</p>
{r.errors.length ? (
<ul className="space-y-2">
{r.errors.map((e, i) => (
<li key={`${e.code}-${i}`}>
<ErrorExcerpt template={template} line={e.line} col={e.col} message={e.message} onJump={onJump} />
</li>
))}
</ul>
) : (
<div className="flex min-w-0 flex-wrap items-start gap-x-1 gap-y-2 font-mono text-caption">
{r.segments.map((s, i) => (
<span key={`${i}-${s.text}`} className="flex min-w-0 items-start gap-1">
{i > 0 ? <span className="text-muted-foreground">/</span> : null}
<span className="flex min-w-0 flex-col">
<span className={cn("break-all", ROLE[s.role].className)}>{s.text}</span>
<span className="font-sans text-micro text-muted-foreground">{ROLE[s.role].label}</span>
</span>
</span>
))}
</div>
)}
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
{r.missing.length ? (
<p className="text-meta text-muted-foreground">
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
</p>
) : null}
{r.warnings.map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</>
);
}
function Note({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 text-caption text-foreground">
{children}
</p>
);
}
/** 出错行摘录,出错位置之后的一个词加下划线;点击跳到模板里对应位置 */
function ErrorExcerpt({
template,
line,
col,
message,
onJump,
}: {
template: string;
line: number;
col: number;
message: string;
onJump: (line: number, col: number) => void;
}) {
const text = template.split("\n")[line - 1] ?? "";
const at = Math.max(0, Math.min(text.length, col - 1));
const m = /^[\w.]+|^./.exec(text.slice(at));
const len = m ? m[0].length : 0;
return (
<button
type="button"
onClick={() => onJump(line, col)}
className="block w-full min-w-0 cursor-pointer rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-left transition-colors duration-micro hover:bg-destructive/15"
>
<span className="flex flex-wrap items-center gap-2">
<StatusBadge tone="destructive">
第 {line} 行第 {col} 列
</StatusBadge>
<span className="text-caption break-words text-destructive">{message}</span>
</span>
{text ? (
<span className="mt-1.5 block font-mono text-meta break-all text-foreground">
{text.slice(Math.max(0, at - 40), at)}
<mark className="rounded-sm bg-destructive/20 text-destructive underline decoration-wavy underline-offset-4">
{text.slice(at, at + len) || " "}
</mark>
{text.slice(at + len, at + len + 40)}
</span>
) : null}
</button>
);
}
// ── 变量清单 ──
function VariablePalette({
data,
loading,
error,
onInsert,
}: {
data: NamingVariablesResponse | undefined;
loading: boolean;
error: unknown;
onInsert: (token: string) => void;
}) {
const [q, setQ] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const term = q.trim().toLowerCase();
const items = (data?.items ?? []).filter(
(v) => !term || v.name.toLowerCase().includes(term) || v.label.toLowerCase().includes(term),
);
return (
<aside className="min-w-0 space-y-3 rounded-xl border border-border/70 px-3 py-3">
<div className="flex items-center gap-2">
<Braces className="h-4 w-4 text-muted-foreground" />
<span className="text-caption font-medium text-foreground">变量</span>
<span className="text-meta text-muted-foreground">点击插入到光标处</span>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索变量"
aria-label="搜索变量"
className="h-8 pl-8 text-caption"
/>
</div>
{loading ? (
<p className="text-meta text-muted-foreground">加载中…</p>
) : error ? (
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
) : (
<div className="max-h-96 space-y-3 overflow-y-auto pr-1">
{GROUP_ORDER.map((g) => {
const list = items.filter((v) => v.group === g);
if (!list.length) return null;
return (
<div key={g}>
<p className="mb-1 text-micro font-semibold tracking-[0.12em] text-muted-foreground uppercase">
{GROUP_LABEL[g]}
</p>
<ul className="space-y-0.5">
{list.map((v) => (
<li key={v.name}>
<button
type="button"
onClick={() => onInsert(`{{${v.name}}}`)}
title={`插入 {{${v.name}}}`}
className="block w-full min-w-0 cursor-pointer rounded-md px-2 py-1 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<span className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-caption text-foreground">{v.name}</span>
{v.ours ? <span className="text-micro text-muted-foreground">MP 无</span> : null}
{v.example ? (
<span className="ml-auto max-w-full truncate font-mono text-meta text-muted-foreground">
{v.example}
</span>
) : null}
</span>
<span className="block text-meta text-muted-foreground">{v.label}</span>
</button>
</li>
))}
</ul>
</div>
);
})}
{items.length === 0 ? <p className="text-meta text-muted-foreground">没有匹配的变量</p> : null}
</div>
)}
{data?.filters.length ? (
<div className="border-t border-border/60 pt-2">
<button
type="button"
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((o) => !o)}
className="flex w-full cursor-pointer items-center gap-1 text-caption font-medium text-foreground"
>
<ChevronRight
className={cn("h-3.5 w-3.5 transition-transform duration-micro", filtersOpen && "rotate-90")}
/>
过滤器与语法
</button>
{filtersOpen ? (
<ul className="mt-2 space-y-1.5">
<li className="text-meta text-muted-foreground">
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
<span className="font-mono text-foreground">{"{{ a or b }}"}</span> 取第一个有值的
</li>
{data.filters.map((f) => (
<li key={f.name} className="text-meta text-muted-foreground">
<button
type="button"
onClick={() => onInsert(` | ${f.name}`)}
className="cursor-pointer font-mono text-foreground hover:underline"
title="插入到光标处"
>
{f.name}
</button>{" "}
{f.doc}
</li>
))}
</ul>
) : null}
</div>
) : null}
</aside>
);
}
+3 -1
View File
@@ -1,9 +1,10 @@
import { LibraryRootsTable } from "./instances.tsx";
import { NamingSection } from "./naming.tsx";
import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 命名模板
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
@@ -45,6 +46,7 @@ export function LibrarySection() {
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
<NamingSection />
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import type { LibraryRoot, NamingKind, NamingOverride, NamingTemplatesView } from "@umt/contracts";
import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useLibraryRoots } from "~/api/library.ts";
import { useNamingTemplates, useSaveGlobalNaming, useSaveRootNaming } from "~/api/naming.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { NAMING_KINDS, overrideBody, SOURCE_LABEL, splitNamingErrors } from "~/components/naming/model.ts";
import { NAMING_KIND_LABEL, NAMING_NOTE, NamingTemplateEditor } from "~/components/naming/template-editor.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { SettingsBlock } from "./settings-block.tsx";
// 设置 → 媒体库与目录 → 命名模板:全局模板 + 库根覆盖(作品覆盖在作品详情)。
const KINDS = NAMING_KINDS;
export function NamingSection() {
return (
<>
<GlobalNaming />
<RootNaming />
</>
);
}
function GlobalNaming() {
const q = useNamingTemplates();
const save = useSaveGlobalNaming();
const [draft, setDraft] = useState<Record<NamingKind, string> | null>(null);
const [lock, setLock] = useState(false);
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
useEffect(() => {
if (q.data && !draft) {
setDraft({ movie: q.data.global.movie, tv: q.data.global.tv, variety: q.data.global.variety });
setLock(q.data.global.lockFileNameForBound);
}
}, [q.data, draft]);
const saved = q.data?.global;
const dirtyKinds = Object.fromEntries(KINDS.map((k) => [k, !!saved && !!draft && draft[k] !== saved[k]]));
const dirty = KINDS.some((k) => dirtyKinds[k]) || (!!saved && lock !== saved.lockFileNameForBound);
const onSave = async () => {
if (!draft) return;
setErrors(undefined);
try {
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
setLock(r.lockFileNameForBound);
toast.success("命名模板已保存");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
return (
<SettingsBlock
section="library"
anchor="naming"
title="命名模板"
description={NAMING_NOTE}
dirty={dirty}
actions={
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
}
>
{!q.data || !draft ? (
q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : (
<LoadingState rows={4} />
)
) : (
<div className="space-y-5">
<NamingTemplateEditor
kinds={KINDS}
values={draft}
onChange={(k, v) => {
setDraft((d) => (d ? { ...d, [k]: v } : d));
setErrors((e) => (e?.[k] ? { ...e, [k]: undefined } : e));
}}
defaults={q.data.defaults}
saveErrors={errors}
dirtyKinds={dirtyKinds}
/>
<label
htmlFor="naming-lock-filename"
className="flex cursor-pointer items-start justify-between gap-4 border-t border-border/60 pt-4"
>
<span className="min-w-0">
<span className="block text-body text-foreground">已有目录的作品固定文件名</span>
<span className="block text-meta text-muted-foreground">
开启后,这些作品的新文件按建立目录时的模板版本命名
</span>
</span>
<Switch
id="naming-lock-filename"
checked={lock}
onCheckedChange={setLock}
aria-label="已有目录的作品固定文件名"
/>
</label>
</div>
)}
</SettingsBlock>
);
}
function RootNaming() {
const roots = useLibraryRoots();
const [rootId, setRootId] = useState("");
const list = roots.data?.items ?? [];
const root = list.find((r) => r.id === rootId) ?? list[0];
return (
<SettingsBlock
section="library"
anchor="naming-roots"
title="库根命名覆盖"
description="按类型覆盖全局模板;留空的类型沿用全局。"
>
{roots.isPending ? (
<LoadingState rows={2} />
) : roots.isError ? (
<ErrorState plain error={roots.error} onRetry={() => roots.refetch()} />
) : !root ? (
<p className="text-caption text-muted-foreground">还没有库根。先在上方「库根」添加。</p>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Select value={root.id} onValueChange={setRootId}>
<SelectTrigger size="sm" aria-label="库根" className="max-w-full min-w-48">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{list.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="min-w-0 truncate font-mono text-meta text-muted-foreground">{root.localPath}</span>
</div>
<RootNamingEditor key={root.id} root={root} />
</div>
)}
</SettingsBlock>
);
}
function RootNamingEditor({ root }: { root: LibraryRoot }) {
const q = useNamingTemplates({ libraryRootId: root.id });
const save = useSaveRootNaming();
const [draft, setDraft] = useState<Partial<Record<NamingKind, string>> | null>(null);
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
useEffect(() => {
if (q.data && !draft) setDraft({ ...(q.data.root ?? {}) });
}, [q.data, draft]);
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.isPending || !draft) return <LoadingState rows={3} />;
const view: NamingTemplatesView = q.data;
const saved = view.root ?? {};
const dirtyKinds = Object.fromEntries(KINDS.map((k) => [k, (draft[k] ?? "") !== (saved[k] ?? "")]));
const dirty = KINDS.some((k) => dirtyKinds[k]);
const hasOverride = !!view.root && Object.keys(view.root).length > 0;
const put = async (body: NamingOverride | null) => {
setErrors(undefined);
try {
await save.mutateAsync({ id: root.id, naming: body });
setDraft({ ...(body ?? {}) });
toast.success(body ? "库根命名覆盖已保存" : "已清除库根命名覆盖");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{KINDS.map((k) => (
<StatusBadge key={k} tone={saved[k] ? "success" : "neutral"}>
{NAMING_KIND_LABEL[k]}:{saved[k] ? "库根覆盖" : "沿用全局"}
</StatusBadge>
))}
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="hover:text-destructive"
disabled={!hasOverride || save.isPending}
onClick={() => put(null)}
>
<Trash2 />
清除覆盖
</Button>
<Button size="sm" disabled={!dirty || save.isPending} onClick={() => put(overrideBody(draft))}>
<Save />
{save.isPending ? "保存中…" : "保存覆盖"}
</Button>
</div>
</div>
<NamingTemplateEditor
kinds={KINDS}
values={draft}
onChange={(k, v) => {
setDraft((d) => ({ ...d, [k]: v }));
setErrors((e) => (e?.[k] ? { ...e, [k]: undefined } : e));
}}
defaults={view.defaults}
inherited={Object.fromEntries(KINDS.map((k) => [k, { template: view.global[k], source: SOURCE_LABEL.global }]))}
libraryRootId={root.id}
saveErrors={errors}
dirtyKinds={dirtyKinds}
/>
</div>
);
}
@@ -53,6 +53,7 @@ import {
} from "~/components/library/labels.ts";
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -644,6 +645,7 @@ export default function LibraryDetailPage() {
合并作品
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</div>
</header>