merge: fe3-web (FE-3 rules page, naming form, global search) into main
Conflicts resolved: routes.ts keeps both /console/status (FE-1) and /console/search (FE-3); smoke list keeps FE-1/FE-4/FE-5 routes plus the rules tabs, search results and preferences→rules redirects. Top bar mounts SearchTrigger and CommandPalette next to the FE-1 inbox bell. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
04401309fb
31 files changed
+5049
-429
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { LibraryRootsTable } from "./instances.tsx";
|
||||
import { NamingSection } from "./naming.tsx";
|
||||
import { str } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式
|
||||
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 命名模板
|
||||
|
||||
const ORGANIZE_CARD: CardSpec = {
|
||||
anchor: "organize",
|
||||
@@ -45,6 +46,7 @@ export function LibrarySection() {
|
||||
<LibraryRootsTable />
|
||||
</SettingsBlock>
|
||||
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
|
||||
<NamingSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
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 { 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}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
|
||||
<Save />
|
||||
{save.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!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>
|
||||
</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 className="ml-auto flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user