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:
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();
|
||||
|
||||
Reference in new issue
Block a user