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"; import { revealFirstError } from "~/lib/form-errors.ts"; // 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。 // 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。 export type FieldValue = string | boolean; export type FormValues = Record; 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 = { /** 单数名,用于标题与提示:"下载器实例" */ noun: string; description?: ReactNode; columns: DataColumn[]; 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 = { list: UseQueryResult>; create: UseMutationResult; update: UseMutationResult; remove: UseMutationResult; }; export function ResourceSection({ config, hooks, }: { config: ResourceConfig; hooks: Hooks; }) { const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const { list, remove } = hooks; const columns: DataColumn[] = [ ...config.columns, { key: "__actions", header: 操作, align: "right", cell: (row) => ( // biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,避免行内按钮触发整行打开抽屉
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > {config.rowActions?.(row)}
), }, ]; return (
{config.description ? (

{config.description}

) : ( )}
r.id} loading={list.isPending} error={list.error} onRetry={() => list.refetch()} onRowClick={(r) => setEditing(r)} empty={{ title: `还没有${config.noun}`, description: config.emptyHint }} /> setEditing(null)} /> !o && setDeleting(null)} title={`删除${config.noun}?`} description={config.deleteHint ?? "删除后无法恢复。"} confirmLabel={`删除${config.noun}`} destructive onConfirm={async () => { if (!deleting) return; try { await remove.mutateAsync(deleting.id); toast.success(`已删除${config.noun}`); } catch (err) { toast.error(errorMessage(err, "删除失败")); throw err; } }} />
); } function ResourceFormDrawer({ config, hooks, row, onClose, }: { config: ResourceConfig; hooks: Hooks; row: Row | "new" | null; onClose: () => void; }) { const mode = row === "new" ? "create" : "edit"; const [values, setValues] = useState(() => row && row !== "new" ? config.fromRow(row) : { ...config.initial }, ); const [errors, setErrors] = useState>({}); 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); revealFirstError(); 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 ( !o && !pending && onClose()} title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`} description="凭证只写入,保存后不回显。" footer={ <> } >
{config.fields .filter((f) => !f.visible || f.visible(values)) .map((f) => ( set(f.name, v)} disabled={pending || (mode === "edit" && !!f.createOnly)} hint={mode === "edit" && f.editHint ? f.editHint : f.hint} /> ))} {row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
); } 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 (

{hint ?? ""}

); } let control: ReactNode; if (spec.kind === "select") { control = ( ); } else if (spec.kind === "textarea") { control = (