feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -0,0 +1,129 @@
|
||||
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 { errorMessage } from "~/lib/api.ts";
|
||||
import { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx";
|
||||
import { 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 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);
|
||||
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="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{spec.fields
|
||||
.filter((f) => !f.visible || f.visible(values))
|
||||
.map((f) => (
|
||||
<FieldControl
|
||||
key={f.name}
|
||||
spec={f}
|
||||
value={values[f.name]}
|
||||
error={errors[f.name]}
|
||||
hint={f.hint}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{spec.extra?.(settings)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
|
||||
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setValues(spec.fromSettings(settings));
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
还原
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取全局设置后再渲染(加载 / 出错占位) */
|
||||
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));
|
||||
Reference in new issue
Block a user