Files
UHD_Media_Tool/apps/web/app/components/settings/naming.tsx
T
rootandClaude Opus 5.5 ea42018d3f docs(copy): 设置页接入、媒体库、整理、系统分区文案按 human-copy 重写
- 下载器说明去掉「接管」,选下载器顺序写成一句;缓存盘统一为下载盘
- 「凭据」统一为「凭证」;去掉说明里的「同 MP」「待处理」等旧称
- 整理来源的基线、存量说法改成用户能懂的话,确认按钮写动作
- 数据保留里「站点快照」「待识别条目」按术语表改名

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:45 +08:00

229 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { SaveBar, 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}>
{!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>
<SaveBar dirty={dirty}>
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
<Save />
{save.isPending ? "保存中…" : "保存"}
</Button>
</SaveBar>
</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>
<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}
/>
<SaveBar dirty={dirty}>
<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>
</SaveBar>
</div>
);
}