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

@@ -15,6 +15,7 @@ import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { cn } from "~/lib/utils.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
@@ -119,7 +120,7 @@ export function ResourceSection<Row extends { id: string }>({
) : (
<span />
)}
<Button size="sm" onClick={() => setEditing("new")}>
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
<Plus />
添加{config.noun}
</Button>
@@ -275,11 +276,54 @@ export function FieldControl({
</div>
);
}
let control: ReactNode;
return (
<FormField
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
<FieldInput id={id} spec={spec} value={value} error={error} onChange={onChange} disabled={disabled} />
</FormField>
);
}
/** 字段的控件本体(不含标签与说明);开关也在这里 */
export function FieldInput({
id,
spec,
value,
error,
onChange,
disabled,
className,
}: {
id: string;
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
className?: string;
}) {
if (spec.kind === "switch") {
return (
<Switch
id={id}
checked={value === true}
onCheckedChange={onChange}
disabled={disabled}
className={className}
aria-invalid={!!error}
/>
);
}
if (spec.kind === "select") {
control = (
return (
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
<SelectTrigger id={id} className={cn("w-full", className)} aria-invalid={!!error}>
<SelectValue placeholder={spec.placeholder ?? "选择"} />
</SelectTrigger>
<SelectContent position="popper">
@@ -291,8 +335,9 @@ export function FieldControl({
</SelectContent>
</Select>
);
} else if (spec.kind === "textarea") {
control = (
}
if (spec.kind === "textarea") {
return (
<Textarea
id={id}
value={String(value ?? "")}
@@ -301,41 +346,25 @@ export function FieldControl({
disabled={disabled}
aria-invalid={!!error}
rows={3}
/>
);
} else {
control = (
<Input
id={id}
type={
spec.kind === "password"
? "password"
: spec.kind === "number"
? "number"
: spec.kind === "url"
? "url"
: "text"
}
autoComplete={spec.kind === "password" ? "new-password" : "off"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
className={className}
/>
);
}
return (
<FormField
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
{control}
</FormField>
<Input
id={id}
type={
spec.kind === "password" ? "password" : spec.kind === "number" ? "number" : spec.kind === "url" ? "url" : "text"
}
inputMode={spec.kind === "number" ? "decimal" : undefined}
autoComplete={spec.kind === "password" ? "new-password" : "off"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
className={className}
/>
);
}