From 2c6a89f309dc4b8fc4a43e2a1552016f57a40990 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:05:58 +0800 Subject: [PATCH] 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 --- apps/web/app/api/naming.ts | 97 ++++ .../naming/entity-naming-dialog.tsx | 127 +++++ apps/web/app/components/naming/model.ts | 27 + .../app/components/naming/template-editor.tsx | 503 ++++++++++++++++++ apps/web/app/components/settings/library.tsx | 4 +- apps/web/app/components/settings/naming.tsx | 234 ++++++++ apps/web/app/routes/console/library.$id.tsx | 2 + 7 files changed, 993 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/api/naming.ts create mode 100644 apps/web/app/components/naming/entity-naming-dialog.tsx create mode 100644 apps/web/app/components/naming/model.ts create mode 100644 apps/web/app/components/naming/template-editor.tsx create mode 100644 apps/web/app/components/settings/naming.tsx diff --git a/apps/web/app/api/naming.ts b/apps/web/app/api/naming.ts new file mode 100644 index 00000000..39fd33d3 --- /dev/null +++ b/apps/web/app/api/naming.ts @@ -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("/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) => + api.put("/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(`/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(`/media-entities/${id}/naming-override`, { namingOverride }) + .then((d) => parseOrThrow(EntityNamingOverridePut, d, "/media-entities/naming-override")), + onSuccess: done, + }); +} diff --git a/apps/web/app/components/naming/entity-naming-dialog.tsx b/apps/web/app/components/naming/entity-naming-dialog.tsx new file mode 100644 index 00000000..f8de8cce --- /dev/null +++ b/apps/web/app/components/naming/entity-naming-dialog.tsx @@ -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 ( + <> + + {open ? ( + 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> | null>(null); + const [errors, setErrors] = useState> | 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 ( + !o && onClose()}> + + + 命名覆盖 + {NAMING_NOTE} + + {q.isError ? ( + q.refetch()} /> + ) : q.isPending || !draft ? ( + + ) : ( +
+
+ + 当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`} + +
+ { + setDraft((d) => ({ ...d, [k]: v })); + setErrors(undefined); + }} + defaults={q.data.defaults} + inherited={{ [kind]: { template: inheritedTemplate, source: inheritedSource } }} + entityId={entityId} + saveErrors={errors} + /> +
+ )} + + + + +
+
+ ); +} diff --git a/apps/web/app/components/naming/model.ts b/apps/web/app/components/naming/model.ts new file mode 100644 index 00000000..392c71cd --- /dev/null +++ b/apps/web/app/components/naming/model.ts @@ -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> | null { + const out: Partial> = {}; + 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>): 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; +} diff --git a/apps/web/app/components/naming/template-editor.tsx b/apps/web/app/components/naming/template-editor.tsx new file mode 100644 index 00000000..0eb3422b --- /dev/null +++ b/apps/web/app/components/naming/template-editor.tsx @@ -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 = { movie: "电影", tv: "剧集", variety: "综艺" }; + +const GROUP_LABEL: Record = { + title: "标题", + episode: "季集", + tech: "技术参数", + id: "外部身份", + ours: "其他", +}; +const GROUP_ORDER = ["title", "episode", "tech", "id", "ours"] as const; + +const ROLE: Record = { + 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(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>; + onChange: (kind: NamingKind, value: string) => void; + /** MP 默认(恢复默认) */ + defaults: Record; + /** 覆盖模式:留空沿用的上级模板与来源说明;不传 = 全局模式(必填) */ + inherited?: Partial>; + /** 预览上下文 */ + libraryRootId?: string; + entityId?: string; + /** 保存失败时服务端给的逐类型错误 */ + saveErrors?: Partial>; + /** 各类型是否与已保存值不同 */ + dirtyKinds?: Partial>; +}; + +export function NamingTemplateEditor({ + kinds, + values, + onChange, + defaults, + inherited, + libraryRootId, + entityId, + saveErrors, + dirtyKinds, +}: NamingEditorProps) { + const [kind, setKind] = useState(kinds[0] ?? "movie"); + const active = kinds.includes(kind) ? kind : (kinds[0] ?? "movie"); + const vars = useNamingVariables(); + const ref = useRef(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 ( +
+
+
+ {kinds.length > 1 ? ( + setKind(v as NamingKind)}> + + {kinds.map((k) => ( + + {NAMING_KIND_LABEL[k]} + {dirtyKinds?.[k] ? ( + + 未保存 + + ) : null} + + ))} + + + ) : ( + {NAMING_KIND_LABEL[active]}模板 + )} +
+ {override ? ( + + ) : null} + +
+
+ +
+