Files
UHD_Media_Tool/apps/web/app/components/settings/settings-card.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

214 lines
6.9 KiB
TypeScript

import {
type GlobalSettings,
GlobalSettings as GlobalSettingsSchema,
type GlobalSettingsUpdate,
mergeSettings,
} from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { useSettings, useUpdateSettings } from "~/api/settings.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Label } from "~/components/ui/label.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { cn } from "~/lib/utils.ts";
import {
FieldInput,
type FieldSpec,
type FieldValue,
type FormValues,
fieldHint,
localFieldErrors,
} from "./resource-section.tsx";
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
export type CardSpec = {
/** 锚点 id(分区内唯一) */
anchor: string;
title: string;
description?: ReactNode;
fields: FieldSpec[];
fromSettings: (s: GlobalSettings) => FormValues;
toPatch: (v: FormValues, s: GlobalSettings) => GlobalSettingsUpdate;
extra?: (s: GlobalSettings) => ReactNode;
};
export function SettingsCard({
spec,
settings,
section,
}: {
spec: CardSpec;
settings: GlobalSettings;
section: string;
}) {
const update = useUpdateSettings();
const [values, setValues] = useState<FormValues>(() => spec.fromSettings(settings));
const [errors, setErrors] = useState<Record<string, string>>({});
const initial = spec.fromSettings(settings);
const dirty = Object.keys(values).some((k) => values[k] !== initial[k]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
const local = localFieldErrors(spec.fields, values);
if (Object.keys(local).length) {
setErrors(local);
revealFirstError();
return;
}
const patch = spec.toPatch(values, settings);
const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch));
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
// 交叉校验(refine)落在分组上:挂到本节同组的第一个字段
for (const [k, m] of Object.entries(fe)) {
if (!spec.fields.some((f) => f.name === k))
fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m;
}
setErrors(fe);
revealFirstError();
return;
}
setErrors({});
try {
const next = await update.mutateAsync(patch);
setValues(spec.fromSettings(next));
toast.success(`已保存「${spec.title}」`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<SettingsBlock
section={section}
anchor={spec.anchor}
title={spec.title}
description={spec.description}
dirty={dirty}
>
<form onSubmit={save} noValidate>
<div className="divide-y divide-border/60">
{spec.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<SettingsField
key={f.name}
spec={f}
value={values[f.name]}
values={values}
error={errors[f.name]}
disabled={update.isPending}
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
/>
))}
</div>
{spec.extra ? <div className="mt-4">{spec.extra(settings)}</div> : null}
<SaveBar dirty={dirty}>
{dirty ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setValues(spec.fromSettings(settings));
setErrors({});
}}
>
还原
</Button>
) : null}
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</SaveBar>
</form>
</SettingsBlock>
);
}
/** 控件宽度按类型固定:数字短、选择与单行文本中等、wide/多行占满 */
function controlWidth(spec: FieldSpec): string {
if (spec.kind === "switch") return "";
if (spec.wide || spec.kind === "textarea") return "w-full";
if (spec.kind === "number" || spec.kind === "tokens") return "w-full sm:w-40";
if (spec.kind === "select") return "w-full sm:max-w-xs";
return "w-full sm:max-w-md";
}
/**
* 设置表单的一行:标签与说明在左,控件在右;md 以下上下堆叠(开关仍与标签同行)。
* 错误写在控件下方,说明留在标签下,行高不因出错跳动以外的原因变化。
*/
export function SettingsField({
spec,
value,
error,
onChange,
disabled,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
/** 整个表单的值(凭证字段据此判断是否已设置) */
values?: FormValues;
}) {
const id = `f-${spec.name}`;
const isSwitch = spec.kind === "switch";
const hint = fieldHint(spec, value, values ?? {}, spec.hint);
return (
<div
className={cn(
"grid gap-x-6 gap-y-2 py-4 first:pt-0 last:pb-0 md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)]",
isSwitch && "grid-cols-[minmax(0,1fr)_auto] items-start",
)}
>
<div className={cn("min-w-0", !isSwitch && "md:pt-2")}>
<Label htmlFor={id} className="text-caption font-medium text-foreground">
{spec.label}
{spec.required ? <span className="text-destructive">*</span> : null}
</Label>
{hint ? <p className="mt-1 text-meta text-muted-foreground">{hint}</p> : null}
</div>
<div className={cn("min-w-0", isSwitch && "pt-0.5 md:justify-self-start")}>
<FieldInput
id={id}
spec={spec}
value={value}
values={values}
error={error}
onChange={onChange}
disabled={disabled}
className={controlWidth(spec)}
/>
{error ? (
<p className="mt-1.5 text-meta text-destructive" role="alert">
{error}
</p>
) : null}
</div>
</div>
);
}
/** 读取全局设置后再渲染(加载 / 出错占位) */
export function SettingsGate({ children }: { children: (s: GlobalSettings) => ReactNode }) {
const q = useSettings();
if (q.isPending) return <LoadingState rows={6} />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
return <>{children(q.data)}</>;
}
/** 数字 → 表单字符串(null 为空) */
export const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v));