Merge branch 'fe-settings'
This commit is contained in:
commit
161e6e732a
13 files changed
+245
-102
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
type GlobalSettings,
|
type GlobalSettings,
|
||||||
type GlobalSettingsUpdate,
|
type GlobalSettingsUpdate,
|
||||||
mergeSettings,
|
mergeSettings,
|
||||||
@@ -11,15 +12,30 @@ import { qk } from "~/lib/query-keys.ts";
|
|||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.settings,
|
queryKey: qk.settings,
|
||||||
queryFn: ({ signal }) => apiData<GlobalSettings>("/settings", { signal }),
|
queryFn: ({ signal }) => apiData<GlobalSettings>("/settings", { signal }).then(withSettingsDefaults),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端比前端旧时(滚动升级、回滚)响应里会缺新加的 key;按 key 用默认值补齐,
|
||||||
|
* 避免设置页读 `settings.<新key>.x` 直接崩掉。只补缺的,不覆盖已有值。
|
||||||
|
*/
|
||||||
|
export function withSettingsDefaults(s: GlobalSettings): GlobalSettings {
|
||||||
|
const out: Record<string, unknown> = { ...s };
|
||||||
|
for (const [k, def] of Object.entries(DEFAULT_SETTINGS)) {
|
||||||
|
const cur = out[k];
|
||||||
|
if (cur === undefined || cur === null) out[k] = def;
|
||||||
|
else if (typeof def === "object" && !Array.isArray(def) && typeof cur === "object") out[k] = { ...def, ...cur };
|
||||||
|
}
|
||||||
|
return out as GlobalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
/** PATCH /settings,乐观更新(失败回滚) */
|
/** PATCH /settings,乐观更新(失败回滚) */
|
||||||
export function useUpdateSettings() {
|
export function useUpdateSettings() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (patch: GlobalSettingsUpdate) => apiData<GlobalSettings>("/settings", { method: "PATCH", body: patch }),
|
mutationFn: (patch: GlobalSettingsUpdate) =>
|
||||||
|
apiData<GlobalSettings>("/settings", { method: "PATCH", body: patch }).then(withSettingsDefaults),
|
||||||
onMutate: async (patch) => {
|
onMutate: async (patch) => {
|
||||||
await qc.cancelQueries({ queryKey: qk.settings });
|
await qc.cancelQueries({ queryKey: qk.settings });
|
||||||
const prev = qc.getQueryData<GlobalSettings>(qk.settings);
|
const prev = qc.getQueryData<GlobalSettings>(qk.settings);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function AccountSection() {
|
|||||||
const session = useSession();
|
const session = useSession();
|
||||||
const cookie = !!session.data?.expiresAt;
|
const cookie = !!session.data?.expiresAt;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<PasswordBlock disabled={!cookie} />
|
<PasswordBlock disabled={!cookie} />
|
||||||
<TotpBlock disabled={!cookie} />
|
<TotpBlock disabled={!cookie} />
|
||||||
<PasskeyBlock disabled={!cookie} />
|
<PasskeyBlock disabled={!cookie} />
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const percent = (n: number, d: number) => (d > 0 ? `${Math.round((n / d) * 100)}
|
|||||||
|
|
||||||
export function AgentToolsBlock() {
|
export function AgentToolsBlock() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<McpServerBlock />
|
<McpServerBlock />
|
||||||
<CapabilitiesBlock />
|
<CapabilitiesBlock />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ const ADVANCED_CARD: CardSpec = {
|
|||||||
|
|
||||||
export function AgentSection() {
|
export function AgentSection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<PiUpstreamsBlock />
|
<PiUpstreamsBlock />
|
||||||
<SettingsGate>
|
<SettingsGate>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ function Switches() {
|
|||||||
|
|
||||||
export function AutomationSection() {
|
export function AutomationSection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<Switches />
|
<Switches />
|
||||||
<SettingsGate>
|
<SettingsGate>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const ORGANIZE_CARD: CardSpec = {
|
|||||||
|
|
||||||
export function LibrarySection() {
|
export function LibrarySection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<SettingsBlock section="library" anchor="roots" title="库根" plain>
|
<SettingsBlock section="library" anchor="roots" title="库根" plain>
|
||||||
<LibraryRootsTable />
|
<LibraryRootsTable />
|
||||||
</SettingsBlock>
|
</SettingsBlock>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Button } from "~/components/ui/button.tsx";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { SettingsBlock } from "./settings-block.tsx";
|
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
|
||||||
|
|
||||||
// 设置 → 媒体库与目录 → 命名模板:全局模板 + 库根覆盖(作品覆盖在作品详情)。
|
// 设置 → 媒体库与目录 → 命名模板:全局模板 + 库根覆盖(作品覆盖在作品详情)。
|
||||||
|
|
||||||
@@ -61,19 +61,7 @@ function GlobalNaming() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsBlock
|
<SettingsBlock section="library" anchor="naming" title="命名模板" description={NAMING_NOTE} dirty={dirty}>
|
||||||
section="library"
|
|
||||||
anchor="naming"
|
|
||||||
title="命名模板"
|
|
||||||
description={NAMING_NOTE}
|
|
||||||
dirty={dirty}
|
|
||||||
actions={
|
|
||||||
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
|
|
||||||
<Save />
|
|
||||||
{save.isPending ? "保存中…" : "保存"}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!q.data || !draft ? (
|
{!q.data || !draft ? (
|
||||||
q.isError ? (
|
q.isError ? (
|
||||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||||
@@ -110,6 +98,12 @@ function GlobalNaming() {
|
|||||||
aria-label="已有目录的作品固定文件名"
|
aria-label="已有目录的作品固定文件名"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<SaveBar dirty={dirty}>
|
||||||
|
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
|
||||||
|
<Save />
|
||||||
|
{save.isPending ? "保存中…" : "保存"}
|
||||||
|
</Button>
|
||||||
|
</SaveBar>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsBlock>
|
</SettingsBlock>
|
||||||
@@ -199,22 +193,6 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
|
|||||||
{NAMING_KIND_LABEL[k]}:{saved[k] ? "库根覆盖" : "沿用全局"}
|
{NAMING_KIND_LABEL[k]}:{saved[k] ? "库根覆盖" : "沿用全局"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
))}
|
))}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="hover:text-destructive"
|
|
||||||
disabled={!hasOverride || save.isPending}
|
|
||||||
onClick={() => put(null)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
清除覆盖
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" disabled={!dirty || save.isPending} onClick={() => put(overrideBody(draft))}>
|
|
||||||
<Save />
|
|
||||||
{save.isPending ? "保存中…" : "保存覆盖"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<NamingTemplateEditor
|
<NamingTemplateEditor
|
||||||
kinds={KINDS}
|
kinds={KINDS}
|
||||||
@@ -229,6 +207,22 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
|
|||||||
saveErrors={errors}
|
saveErrors={errors}
|
||||||
dirtyKinds={dirtyKinds}
|
dirtyKinds={dirtyKinds}
|
||||||
/>
|
/>
|
||||||
|
<SaveBar dirty={dirty}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hover:text-destructive"
|
||||||
|
disabled={!hasOverride || save.isPending}
|
||||||
|
onClick={() => put(null)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
清除覆盖
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={!dirty || save.isPending} onClick={() => put(overrideBody(draft))}>
|
||||||
|
<Save />
|
||||||
|
{save.isPending ? "保存中…" : "保存覆盖"}
|
||||||
|
</Button>
|
||||||
|
</SaveBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Switch } from "~/components/ui/switch.tsx";
|
|||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { revealFirstError } from "~/lib/form-errors.ts";
|
import { revealFirstError } from "~/lib/form-errors.ts";
|
||||||
|
import { cn } from "~/lib/utils.ts";
|
||||||
|
|
||||||
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
|
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
|
||||||
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
|
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
|
||||||
@@ -119,7 +120,7 @@ export function ResourceSection<Row extends { id: string }>({
|
|||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
<Button size="sm" onClick={() => setEditing("new")}>
|
<Button size="sm" className="ml-auto" onClick={() => setEditing("new")}>
|
||||||
<Plus />
|
<Plus />
|
||||||
添加{config.noun}
|
添加{config.noun}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -275,11 +276,54 @@ export function FieldControl({
|
|||||||
</div>
|
</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") {
|
if (spec.kind === "select") {
|
||||||
control = (
|
return (
|
||||||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
<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 ?? "选择"} />
|
<SelectValue placeholder={spec.placeholder ?? "选择"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
@@ -291,8 +335,9 @@ export function FieldControl({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
} else if (spec.kind === "textarea") {
|
}
|
||||||
control = (
|
if (spec.kind === "textarea") {
|
||||||
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
id={id}
|
id={id}
|
||||||
value={String(value ?? "")}
|
value={String(value ?? "")}
|
||||||
@@ -301,42 +346,26 @@ export function FieldControl({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
control = (
|
return (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type={
|
type={
|
||||||
spec.kind === "password"
|
spec.kind === "password" ? "password" : spec.kind === "number" ? "number" : spec.kind === "url" ? "url" : "text"
|
||||||
? "password"
|
|
||||||
: spec.kind === "number"
|
|
||||||
? "number"
|
|
||||||
: spec.kind === "url"
|
|
||||||
? "url"
|
|
||||||
: "text"
|
|
||||||
}
|
}
|
||||||
|
inputMode={spec.kind === "number" ? "decimal" : undefined}
|
||||||
autoComplete={spec.kind === "password" ? "new-password" : "off"}
|
autoComplete={spec.kind === "password" ? "new-password" : "off"}
|
||||||
value={String(value ?? "")}
|
value={String(value ?? "")}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={spec.placeholder}
|
placeholder={spec.placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-invalid={!!error}
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表单值工具 */
|
/** 表单值工具 */
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { cn } from "~/lib/utils.ts";
|
|||||||
/** 分区内锚点 id:`<分区>-<节>`,URL hash 写作 `#<分区>/<节>` */
|
/** 分区内锚点 id:`<分区>-<节>`,URL hash 写作 `#<分区>/<节>` */
|
||||||
export const anchorId = (section: string, anchor: string) => `settings-${section}-${anchor}`;
|
export const anchorId = (section: string, anchor: string) => `settings-${section}-${anchor}`;
|
||||||
|
|
||||||
/** 设置分区里的一节:标题 + 一句说明 + 未保存提示 + 内容;带锚点 */
|
/** 节卡片内边距;SaveBar 用同一组负边距贴满卡片底边 */
|
||||||
|
const SLAB_PAD = "p-5 sm:p-6";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置分区里的一节:标题(h3)+ 一句说明 + 未保存提示 + 内容;带锚点。
|
||||||
|
* 层级:页面 h1「设置」→ 分区 h2 → 节 h3。节内的保存放在节尾的 SaveBar,不放标题行。
|
||||||
|
*/
|
||||||
export function SettingsBlock({
|
export function SettingsBlock({
|
||||||
section,
|
section,
|
||||||
anchor,
|
anchor,
|
||||||
@@ -21,6 +27,7 @@ export function SettingsBlock({
|
|||||||
title: string;
|
title: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
|
/** 标题行右侧:只放"添加 / 打开"这类入口,保存用 SaveBar */
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
/** 不包卡片(内容自带 DataTable 卡片时用) */
|
/** 不包卡片(内容自带 DataTable 卡片时用) */
|
||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
@@ -28,18 +35,33 @@ export function SettingsBlock({
|
|||||||
}) {
|
}) {
|
||||||
const id = anchorId(section, anchor);
|
const id = anchorId(section, anchor);
|
||||||
return (
|
return (
|
||||||
<section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-24">
|
<section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-20">
|
||||||
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
|
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 id={`${id}-title`} className="flex items-center gap-2 text-body font-semibold text-foreground">
|
<h3 id={`${id}-title`} className="flex items-center gap-2 text-lead font-semibold text-foreground">
|
||||||
{title}
|
{title}
|
||||||
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
|
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
|
||||||
</h2>
|
</h3>
|
||||||
{description ? <p className="mt-0.5 max-w-2xl text-caption text-muted-foreground">{description}</p> : null}
|
{description ? <p className="mt-1 max-w-2xl text-caption text-muted-foreground">{description}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(!plain && "pg-slab min-w-0 rounded-xl p-5")}>{children}</div>
|
<div className={cn(!plain && `pg-slab min-w-0 rounded-xl ${SLAB_PAD}`)}>{children}</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 节尾保存条:贴满卡片底边,左侧脏状态提示,右侧操作(主操作放最右) */
|
||||||
|
export function SaveBar({ dirty, note, children }: { dirty?: boolean; note?: ReactNode; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="-mx-5 mt-5 -mb-5 flex flex-wrap items-center justify-end gap-2 rounded-b-xl border-t border-border/60 px-5 py-3 sm:-mx-6 sm:-mb-6 sm:px-6">
|
||||||
|
<p
|
||||||
|
className={cn("mr-auto min-w-0 text-meta", dirty ? "text-warning" : "text-muted-foreground")}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{dirty ? "有未保存的修改" : (note ?? "")}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,12 @@ import { useSettings, useUpdateSettings } from "~/api/settings.ts";
|
|||||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { zodFieldErrors } from "~/components/console/form-field.tsx";
|
import { zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { Label } from "~/components/ui/label.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { revealFirstError } from "~/lib/form-errors.ts";
|
import { revealFirstError } from "~/lib/form-errors.ts";
|
||||||
import { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { SettingsBlock } from "./settings-block.tsx";
|
import { FieldInput, type FieldSpec, type FieldValue, type FormValues } from "./resource-section.tsx";
|
||||||
|
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
|
||||||
|
|
||||||
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
|
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
|
||||||
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
|
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
|
||||||
@@ -79,27 +81,22 @@ export function SettingsCard({
|
|||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
>
|
>
|
||||||
<form onSubmit={save} noValidate>
|
<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
|
{spec.fields
|
||||||
.filter((f) => !f.visible || f.visible(values))
|
.filter((f) => !f.visible || f.visible(values))
|
||||||
.map((f) => (
|
.map((f) => (
|
||||||
<FieldControl
|
<SettingsField
|
||||||
key={f.name}
|
key={f.name}
|
||||||
spec={f}
|
spec={f}
|
||||||
value={values[f.name]}
|
value={values[f.name]}
|
||||||
error={errors[f.name]}
|
error={errors[f.name]}
|
||||||
hint={f.hint}
|
|
||||||
disabled={update.isPending}
|
disabled={update.isPending}
|
||||||
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
|
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{spec.extra?.(settings)}
|
{spec.extra ? <div className="mt-4">{spec.extra(settings)}</div> : null}
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<SaveBar dirty={dirty}>
|
||||||
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
|
|
||||||
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
{dirty ? (
|
{dirty ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,12 +110,78 @@ export function SettingsCard({
|
|||||||
还原
|
还原
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
|
||||||
|
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</SaveBar>
|
||||||
</form>
|
</form>
|
||||||
</SettingsBlock>
|
</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 }) {
|
export function SettingsGate({ children }: { children: (s: GlobalSettings) => ReactNode }) {
|
||||||
const q = useSettings();
|
const q = useSettings();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const DEFAULTS_CARD: CardSpec = {
|
|||||||
|
|
||||||
export function SitesSection() {
|
export function SitesSection() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
<div className="pg-slab flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3">
|
<div className="pg-slab flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3">
|
||||||
<p className="text-caption text-muted-foreground">
|
<p className="text-caption text-muted-foreground">
|
||||||
站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
|
站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
|||||||
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-hidden bg-background">
|
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
|
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
|
||||||
<PrismLeak className="-z-10" />
|
<PrismLeak className="-z-10" />
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
||||||
<main className="relative flex-1 overflow-x-hidden px-4 pt-6 pb-24 md:px-6 md:pt-8">
|
<main className="relative flex-1 overflow-x-clip px-4 pt-6 pb-24 md:px-6 md:pt-8">
|
||||||
<div key={location.pathname} className="mx-auto max-w-6xl motion-safe:animate-blur-in">
|
<div key={location.pathname} className="mx-auto max-w-6xl motion-safe:animate-blur-in">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,21 +25,37 @@ export default function SettingsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { section, anchor } = parseSettingsHash(location.hash);
|
const { section, anchor } = parseSettingsHash(location.hash);
|
||||||
|
|
||||||
// 切分区回到顶部;带节锚点时滚到该节(内容可能还在加载,稍后再试一次)
|
// 切分区回到顶部;带节锚点时对齐到该节。节内容多是异步加载的(上游列表、能力清单),
|
||||||
|
// 上方内容变高会把目标挤下去,所以在 2.5 秒内反复校正,直到用户自己滚动为止。
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: location.key 让重复点同一锚点也能再跳一次
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let tries = 0;
|
const started = performance.now();
|
||||||
const go = () => {
|
let stopped = false;
|
||||||
const el = document.getElementById(anchorId(section.id, anchor));
|
let timer = 0;
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
const stop = () => {
|
||||||
else if (tries++ < 10) timer = window.setTimeout(go, 150);
|
stopped = true;
|
||||||
};
|
};
|
||||||
let timer = window.setTimeout(go, 0);
|
const events = ["wheel", "touchstart", "keydown", "pointerdown"] as const;
|
||||||
return () => window.clearTimeout(timer);
|
for (const e of events) window.addEventListener(e, stop, { passive: true });
|
||||||
}, [section.id, anchor]);
|
const align = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
const el = document.getElementById(anchorId(section.id, anchor));
|
||||||
|
if (el) {
|
||||||
|
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
||||||
|
if (Math.abs(el.getBoundingClientRect().top - margin) > 2) el.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
if (performance.now() - started < 2500) timer = window.setTimeout(align, 120);
|
||||||
|
};
|
||||||
|
timer = window.setTimeout(align, 0);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
for (const e of events) window.removeEventListener(e, stop);
|
||||||
|
};
|
||||||
|
}, [section.id, anchor, location.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -65,7 +81,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* 宽屏:左侧分区导航 */}
|
{/* 宽屏:左侧分区导航 */}
|
||||||
<nav aria-label="设置分区" className="hidden lg:block">
|
<nav aria-label="设置分区" className="hidden lg:block">
|
||||||
<ul className="sticky top-20 grid gap-0.5">
|
<ul className="sticky top-20 grid max-h-[calc(100dvh-6rem)] gap-0.5 overflow-y-auto pb-2">
|
||||||
{SECTIONS.map((s) => {
|
{SECTIONS.map((s) => {
|
||||||
const active = s.id === section.id;
|
const active = s.id === section.id;
|
||||||
return (
|
return (
|
||||||
@@ -107,11 +123,14 @@ export default function SettingsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-6 hidden lg:block">
|
{/* 分区页头:标题 + 一句说明;窄屏由上方下拉承担,标题只留给读屏 */}
|
||||||
<h2 className="flex items-center gap-2 text-title font-semibold text-foreground">{section.label}</h2>
|
<div className="lg:mb-8">
|
||||||
<p className="mt-0.5 text-caption text-muted-foreground">{section.description}</p>
|
<h2 className="sr-only text-title font-semibold text-foreground lg:not-sr-only">{section.label}</h2>
|
||||||
|
<p className="mt-1 hidden text-caption text-muted-foreground lg:block">{section.description}</p>
|
||||||
|
</div>
|
||||||
|
<div key={section.id} className="grid grid-cols-1 gap-8">
|
||||||
|
{section.render()}
|
||||||
</div>
|
</div>
|
||||||
<div key={section.id}>{section.render()}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in new issue
Block a user