- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens) - 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处 - 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA - contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写 - 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除 Co-Authored-By: Claude Opus 5.5 <[email protected]>
214 lines
6.9 KiB
TypeScript
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));
|