- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|