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/api/rules.ts b/apps/web/app/api/rules.ts new file mode 100644 index 00000000..4d822be7 --- /dev/null +++ b/apps/web/app/api/rules.ts @@ -0,0 +1,235 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AcquisitionSlot, + cursorPage, + PreferenceSuppression, + RuleAtom, + type RuleAtomCreate, + type RuleAtomUpdate, + RuleDefaults, + RuleGroup, + type RuleGroupCreate, + type RuleGroupKind, + RuleGroupRevisionList, + type RuleGroupSource, + type RuleGroupUpdate, + RuleKeywordList, + type RuleLayer, + RuleParseResult, + type RulePreviewRequest, + RulePreviewResult, + Subscription, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。 +// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。 + +export const ruleKeys = { + all: ["rules"] as const, + groups: (f: RuleGroupFilters) => ["rules", "groups", f] as const, + group: (id: string) => ["rules", "group", id] as const, + revisions: (id: string) => ["rules", "revisions", id] as const, + keywords: ["rules", "keywords"] as const, + atoms: ["rules", "atoms"] as const, + defaults: ["rules", "defaults"] as const, + suppressions: ["rules", "suppressions"] as const, + refs: ["rules", "refs"] as const, +}; + +export type RuleGroupFilters = { + kind?: RuleGroupKind; + source?: RuleGroupSource; + enabled?: "true" | "false"; + includeDeleted?: "true"; + q?: string; +}; + +const GroupPage = cursorPage(RuleGroup); +const AtomPage = cursorPage(RuleAtom); +const SupPage = cursorPage(PreferenceSuppression); + +export function useRuleGroups(filters: RuleGroupFilters = {}) { + return useQuery({ + queryKey: ruleKeys.groups(filters), + queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal), + placeholderData: keepPreviousData, + }); +} + +export function useRuleGroupRevisions(id: string | null | undefined) { + return useQuery({ + queryKey: ruleKeys.revisions(id ?? ""), + enabled: !!id, + queryFn: async ({ signal }) => + (await getParsed(RuleGroupRevisionList, `/rules/${id}/revisions`, undefined, signal)).items, + }); +} + +export function useRuleKeywords() { + return useQuery({ + queryKey: ruleKeys.keywords, + queryFn: async ({ signal }) => (await getParsed(RuleKeywordList, "/rules/keywords", undefined, signal)).items, + staleTime: 60_000, + }); +} + +export function useRuleAtoms() { + return useQuery({ + queryKey: ruleKeys.atoms, + queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal), + }); +} + +export function useRuleDefaults() { + return useQuery({ + queryKey: ruleKeys.defaults, + queryFn: ({ signal }) => getParsed(RuleDefaults, "/rules/defaults", undefined, signal), + }); +} + +export function useRuleSuppressions() { + return useQuery({ + queryKey: ruleKeys.suppressions, + queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal), + }); +} + +export type RuleRefs = { + /** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */ + subscriptions: Map; + /** 规则组 id → 引用它的版本目标 */ + slots: Map; +}; + +const SubPage = cursorPage(Subscription); +const SlotPage = cursorPage(AcquisitionSlot); + +/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */ +export function useRuleRefs() { + return useQuery({ + queryKey: ruleKeys.refs, + queryFn: async ({ signal }): Promise => { + const [subs, slots] = await Promise.all([ + getParsed(SubPage, "/subscriptions", { limit: 200 }, signal), + getParsed(SlotPage, "/acquisition-slots", { limit: 200 }, signal), + ]); + const subscriptions = new Map(); + for (const s of subs.items) { + const ids = Array.isArray(s.params?.ruleGroupIds) ? (s.params.ruleGroupIds as string[]) : []; + const title = + (s as { mediaEntityTitle?: string | null }).mediaEntityTitle ?? s.titleQuery ?? `订阅 ${s.id.slice(0, 8)}`; + for (const id of ids) subscriptions.set(id, [...(subscriptions.get(id) ?? []), { id: s.id, title }]); + } + const slotRefs = new Map(); + for (const sl of slots.items) + for (const id of sl.ruleGroupIds ?? []) + slotRefs.set(id, [...(slotRefs.get(id) ?? []), { id: sl.id, name: sl.name }]); + return { subscriptions, slots: slotRefs }; + }, + staleTime: 30_000, + }); +} + +/** 规则串 → 层(实时解析;错误带位置与相近名) */ +export function parseRuleString(ruleString: string) { + return api + .post("/rules/parse", { ruleString }) + .then((d) => parseOrThrow(RuleParseResult, d, "/rules/parse")); +} + +export function usePreviewRuleGroup() { + return useMutation({ + mutationFn: ({ id, ...body }: RulePreviewRequest & { id: string }) => + api.post(`/rules/${id}/preview`, body).then((d) => parseOrThrow(RulePreviewResult, d, "/rules/preview")), + }); +} + +function useInvalidate() { + const qc = useQueryClient(); + return () => { + void qc.invalidateQueries({ queryKey: ruleKeys.all }); + // 旧偏好标签页与订阅/版本目标的规则组显示同源 + void qc.invalidateQueries({ queryKey: ["preferences"] }); + }; +} + +const parseGroup = (d: unknown) => parseOrThrow(RuleGroup, d, "/rules"); + +export function useCreateRuleGroup() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleGroupCreate) => api.post("/rules", body).then(parseGroup), + onSuccess: done, + }); +} + +/** 编辑 / 停用(enabled=false 写抑制记录)/ 重新启用;带 expectedRevision 防覆盖 */ +export function useUpdateRuleGroup() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: RuleGroupUpdate & { id: string }) => + api.patch(`/rules/${id}`, body).then(parseGroup), + onSettled: done, + }); +} + +/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */ +export function useDeleteRuleGroup() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.del(`/rules/${id}`), + onSuccess: done, + }); +} + +export function useSaveRuleDefaults() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleDefaults) => + api.put("/rules/defaults", body).then((d) => parseOrThrow(RuleDefaults, d, "/rules/defaults")), + onSuccess: done, + }); +} + +const parseAtom = (d: unknown) => parseOrThrow(RuleAtom, d, "/rules/atoms"); + +export function useCreateRuleAtom() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleAtomCreate) => api.post("/rules/atoms", body).then(parseAtom), + onSuccess: done, + }); +} + +export function useUpdateRuleAtom() { + const done = useInvalidate(); + return useMutation({ + mutationFn: ({ id, ...body }: RuleAtomUpdate & { id: string }) => + api.patch(`/rules/atoms/${id}`, body).then(parseAtom), + onSuccess: done, + }); +} + +export function useDeleteRuleAtom() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.del(`/rules/atoms/${id}`), + onSuccess: done, + }); +} + +/** 解除抑制:之后 Agent 可以再次提出同一规则(仍需审批) */ +export function useLiftRuleSuppression() { + const done = useInvalidate(); + return useMutation({ + mutationFn: (v: { id: string; reason?: string }) => + api + .patch(`/rules/suppressions/${v.id}`, { active: false, reason: v.reason }) + .then((d) => parseOrThrow(PreferenceSuppression, d, "/rules/suppressions")), + onSuccess: done, + }); +} + +export type { RuleLayer }; diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts new file mode 100644 index 00000000..392947a2 --- /dev/null +++ b/apps/web/app/api/search.ts @@ -0,0 +1,93 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ManualAcquisitionResult, + ReleaseSearchPage, + type ReleaseSearchSort, + type SearchReleasePlanRequest, + SearchResponse, + type SearchScope, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 全局搜索(W9-G):命令面板查 media/actions(快);资源结果页走 /search/releases(筛选/排序/游标,与 /search 共用 30s 缓存)。 + +export const searchKeys = { + all: ["search"] as const, + aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const, + releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const, +}; + +/** 聚合搜索;q 为空不发请求 */ +export function useSearch(q: string, scope: SearchScope, limit = 8, enabled = true) { + const term = q.trim(); + return useQuery({ + queryKey: searchKeys.aggregate(term, scope, limit), + enabled: enabled && term.length > 0, + queryFn: ({ signal }) => getParsed(SearchResponse, "/search", { q: term, scope, limit }, signal), + placeholderData: keepPreviousData, + staleTime: 15_000, + }); +} + +export type ReleaseSearchFilters = { + q: string; + /** 逗号分隔 */ + sites?: string; + resolution?: string; + source?: string; + /** 字节 */ + minSize?: number; + maxSize?: number; + free?: "true" | "false"; + hr?: "true" | "false"; + sort?: ReleaseSearchSort; + order?: "asc" | "desc"; +}; + +/** 资源结果页:游标翻页;refresh() 带 refresh=true 跳过缓存重查第一页 */ +export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) { + return useInfiniteQuery({ + queryKey: searchKeys.releases(filters), + enabled: enabled && filters.q.trim().length > 0, + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + getParsed(ReleaseSearchPage, "/search/releases", { ...filters, cursor: pageParam, limit: 50 }, signal), + getNextPageParam: (last) => last.nextCursor ?? undefined, + placeholderData: keepPreviousData, + staleTime: 30_000, + }); +} + +/** 跳过缓存重查站点(仍参与并发合并),结果写回当前筛选的第一页 */ +export function useRefreshReleaseSearch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (filters: ReleaseSearchFilters) => + getParsed(ReleaseSearchPage, "/search/releases", { ...filters, refresh: "true", limit: 50 }), + onSuccess: (page, filters) => { + qc.setQueryData(searchKeys.releases(filters), { pages: [page], pageParams: [undefined] }); + void qc.invalidateQueries({ queryKey: ["search", "aggregate"] }); + }, + }); +} + +/** 从搜索结果下载:与 POST /acquisition-plans 同一流程 */ +export function usePlanFromSearch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + siteId, + siteReleaseId, + ...body + }: SearchReleasePlanRequest & { siteId: string; siteReleaseId: string }) => + api + .post(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body) + .then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["plans"] }); + void qc.invalidateQueries({ queryKey: ["slots"] }); + void qc.invalidateQueries({ queryKey: searchKeys.all }); + }, + }); +} 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} + +
+
+ +
+