fix(web): settings page layout pass (FE-7)

- Section header (h2 + description) and section blocks as h3; unified
  block padding and title size
- Global settings forms use label-left / control-right rows, stacked
  below md; control width fixed by type (number short, text/select
  medium, wide/textarea full)
- Per-block SaveBar pinned at the block end with dirty hint; naming
  template saves moved from the header to the bar
- Console shell uses overflow-x-clip so the sticky header and sticky
  section nav actually stick; anchor jumps re-align while async content
  loads and land below the header
- Section content uses a single minmax(0,1fr) column so wide tables
  scroll inside their card instead of widening the page
- useSettings fills missing keys from DEFAULT_SETTINGS so an older
  backend no longer crashes the metadata section

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:16:02 +08:00
1 parent 04cffcd1f1
commit 639fdeacae
13 files changed
+254 -111

No files matched your search

@@ -11,10 +11,12 @@ 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 { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { cn } from "~/lib/utils.ts";
import { FieldInput, type FieldSpec, type FieldValue, type FormValues } from "./resource-section.tsx";
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
@@ -79,27 +81,22 @@ export function SettingsCard({
dirty={dirty}
>
<form onSubmit={save} noValidate>
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<div className="divide-y divide-border/60">
{spec.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
<SettingsField
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>
{spec.extra ? <div className="mt-4">{spec.extra(settings)}</div> : null}
<SaveBar dirty={dirty}>
{dirty ? (
<Button
type="button"
@@ -113,12 +110,78 @@ export function SettingsCard({
还原
</Button>
) : null}
</div>
<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") 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,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
}) {
const id = `f-${spec.name}`;
const isSwitch = spec.kind === "switch";
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>
{spec.hint ? <p className="mt-1 text-meta text-muted-foreground">{spec.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}
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();