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,344 @@
|
||||
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { CursorPage } from "@umt/contracts";
|
||||
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
|
||||
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
|
||||
|
||||
export type FieldValue = string | boolean;
|
||||
export type FormValues = Record<string, FieldValue>;
|
||||
|
||||
export type FieldSpec = {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
/** 编辑时的说明(如"留空表示不修改") */
|
||||
editHint?: string;
|
||||
required?: boolean;
|
||||
/** 编辑时不可改 */
|
||||
createOnly?: boolean;
|
||||
/** 条件显示 */
|
||||
visible?: (v: FormValues) => boolean;
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
export type ResourceConfig<Row extends { id: string }> = {
|
||||
/** 单数名,用于标题与提示:"下载器实例" */
|
||||
noun: string;
|
||||
description?: ReactNode;
|
||||
columns: DataColumn<Row>[];
|
||||
fields: FieldSpec[];
|
||||
/** 新建时的初始值 */
|
||||
initial: FormValues;
|
||||
/** 编辑时从行取表单值(凭证字段给空串) */
|
||||
fromRow: (row: Row) => FormValues;
|
||||
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
|
||||
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
|
||||
createSchema: z.ZodType;
|
||||
updateSchema: z.ZodType;
|
||||
/** 行内额外动作(测试连接等) */
|
||||
rowActions?: (row: Row) => ReactNode;
|
||||
emptyHint?: string;
|
||||
/** 删除确认的额外说明 */
|
||||
deleteHint?: string;
|
||||
/** 编辑抽屉里表单下方的附加内容(如该实例的路径映射) */
|
||||
drawerExtra?: (row: Row) => ReactNode;
|
||||
};
|
||||
|
||||
type Hooks<Row> = {
|
||||
list: UseQueryResult<CursorPage<Row>>;
|
||||
create: UseMutationResult<Row, Error, never>;
|
||||
update: UseMutationResult<Row, Error, { id: string; body: never }>;
|
||||
remove: UseMutationResult<unknown, Error, string>;
|
||||
};
|
||||
|
||||
export function ResourceSection<Row extends { id: string }>({
|
||||
config,
|
||||
hooks,
|
||||
}: {
|
||||
config: ResourceConfig<Row>;
|
||||
hooks: Hooks<Row>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<Row | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Row | null>(null);
|
||||
const { list, remove } = hooks;
|
||||
|
||||
const columns: DataColumn<Row>[] = [
|
||||
...config.columns,
|
||||
{
|
||||
key: "__actions",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (row) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,避免行内按钮触发整行打开抽屉
|
||||
<div
|
||||
className="flex items-center justify-end gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{config.rowActions?.(row)}
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`删除${config.noun}`}
|
||||
className="hover:text-destructive"
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
{config.description ? (
|
||||
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
添加{config.noun}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={list.data?.items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={list.isPending}
|
||||
error={list.error}
|
||||
onRetry={() => list.refetch()}
|
||||
onRowClick={(r) => setEditing(r)}
|
||||
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
|
||||
/>
|
||||
<ResourceFormDrawer
|
||||
key={editing === "new" ? "new" : (editing?.id ?? "none")}
|
||||
config={config}
|
||||
hooks={hooks}
|
||||
row={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title={`删除${config.noun}?`}
|
||||
description={config.deleteHint ?? "删除后无法恢复。"}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await remove.mutateAsync(deleting.id);
|
||||
toast.success(`已删除${config.noun}`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "删除失败"));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceFormDrawer<Row extends { id: string }>({
|
||||
config,
|
||||
hooks,
|
||||
row,
|
||||
onClose,
|
||||
}: {
|
||||
config: ResourceConfig<Row>;
|
||||
hooks: Hooks<Row>;
|
||||
row: Row | "new" | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const mode = row === "new" ? "create" : "edit";
|
||||
const [values, setValues] = useState<FormValues>(() =>
|
||||
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const pending = hooks.create.isPending || hooks.update.isPending;
|
||||
|
||||
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const body = config.toBody(values, mode);
|
||||
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const fe = zodFieldErrors(parsed.error.issues);
|
||||
// 顶层 refine 错误(path 为空)挂到第一个字段下
|
||||
if (fe[""]) {
|
||||
fe[config.fields[0]?.name ?? ""] ??= fe[""];
|
||||
}
|
||||
setErrors(fe);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
|
||||
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
|
||||
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const formId = `resource-form-${config.noun}`;
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!row}
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`}
|
||||
description="凭证只写入,保存后不回显。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={pending}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{config.fields
|
||||
.filter((f) => !f.visible || f.visible(values))
|
||||
.map((f) => (
|
||||
<FieldControl
|
||||
key={f.name}
|
||||
spec={f}
|
||||
value={values[f.name]}
|
||||
error={errors[f.name]}
|
||||
onChange={(v) => set(f.name, v)}
|
||||
disabled={pending || (mode === "edit" && !!f.createOnly)}
|
||||
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
{row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldControl({
|
||||
spec,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled,
|
||||
hint,
|
||||
}: {
|
||||
spec: FieldSpec;
|
||||
value: FieldValue | undefined;
|
||||
error?: string;
|
||||
onChange: (v: FieldValue) => void;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
}) {
|
||||
const id = `f-${spec.name}`;
|
||||
const wide = spec.wide || spec.kind === "textarea";
|
||||
if (spec.kind === "switch") {
|
||||
return (
|
||||
<div className={wide ? "sm:col-span-2" : undefined}>
|
||||
<label htmlFor={id} className="flex min-h-9 items-center justify-between gap-3 text-caption font-medium">
|
||||
{spec.label}
|
||||
<Switch id={id} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
|
||||
</label>
|
||||
<p className="min-h-4 text-meta text-muted-foreground">{hint ?? ""}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let control: ReactNode;
|
||||
if (spec.kind === "select") {
|
||||
control = (
|
||||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
|
||||
<SelectValue placeholder={spec.placeholder ?? "选择"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(spec.options ?? []).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (spec.kind === "textarea") {
|
||||
control = (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={spec.placeholder}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormField
|
||||
label={spec.label}
|
||||
htmlFor={id}
|
||||
error={error}
|
||||
hint={hint}
|
||||
required={spec.required}
|
||||
className={wide ? "sm:col-span-2" : undefined}
|
||||
>
|
||||
{control}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表单值工具 */
|
||||
export const str = (v: FieldValue | undefined) => (typeof v === "string" ? v.trim() : "");
|
||||
export const optStr = (v: FieldValue | undefined) => str(v) || undefined;
|
||||
export const nullStr = (v: FieldValue | undefined) => str(v) || null;
|
||||
export const num = (v: FieldValue | undefined) => (str(v) === "" ? undefined : Number(str(v)));
|
||||
Reference in new issue
Block a user