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(() => spec.fromSettings(settings)); const [errors, setErrors] = useState>({}); 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 (
{spec.fields .filter((f) => !f.visible || f.visible(values)) .map((f) => ( setValues((s) => ({ ...s, [f.name]: v }))} /> ))}
{spec.extra ?
{spec.extra(settings)}
: null} {dirty ? ( ) : null}
); } /** 控件宽度按类型固定:数字短、选择与单行文本中等、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 (
{hint ?

{hint}

: null}
{error ? (

{error}

) : null}
); } /** 读取全局设置后再渲染(加载 / 出错占位) */ export function SettingsGate({ children }: { children: (s: GlobalSettings) => ReactNode }) { const q = useSettings(); if (q.isPending) return ; if (q.isError) return q.refetch()} />; return <>{children(q.data)}; } /** 数字 → 表单字符串(null 为空) */ export const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v));