Files
UHD_Media_Tool/apps/web/app/components/settings/naming.tsx
T
rootandClaude Opus 5.5 8071ad33f9 feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊
- 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦
- 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置
- 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数)
- 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面
- 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示
- 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入
- 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」

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

229 lines
8.7 KiB
TypeScript

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>
);
}