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 { useRecordSheet } from "~/components/console/record-sheet.tsx"; import { SecretInput } from "~/components/console/secret-input.tsx"; import { UnitInput } from "~/components/console/unit-input.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"; import { secretHint } from "~/lib/secret.ts"; import { type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts"; import { cn } from "~/lib/utils.ts"; // 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。 // 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写(kind=secret,值约定见 ~/lib/secret.ts): // 留空不变、SECRET_CLEAR 清除;token 数字段(kind=tokens)以 K 输入,toBody 里用 kToTokens 换回原始值。 export type FieldValue = string | boolean; export type FormValues = Record; export type FieldSpec = { name: string; label: string; kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea" | "tokens" | "secret"; options?: { value: string; label: string }[]; placeholder?: string; hint?: string; /** 编辑时的说明(如"留空表示不修改") */ editHint?: string; required?: boolean; /** 编辑时不可改 */ createOnly?: boolean; /** 条件显示 */ visible?: (v: FormValues) => boolean; wide?: boolean; /** kind=tokens:以 K 为单位输入(表单值是 K 字符串),范围按原始 token 数 */ tokens?: TokenRange; /** kind=secret:只写凭证。set/mask 从表单值(fromRow / fromSettings 放进去的隐藏键)或闭包取 */ secret?: { set: (v: FormValues) => boolean; mask?: (v: FormValues) => string | null | undefined; clearable?: boolean; multiline?: boolean; }; }; /** kind=tokens 字段先在本地校验(错误用 K 表述),通过后再走 zod */ export function localFieldErrors(fields: FieldSpec[], values: FormValues): Record { const out: Record = {}; for (const f of fields) { if (f.visible && !f.visible(values)) continue; if (f.kind === "tokens") { const e = tokenInputError(str(values[f.name]), { ...f.tokens, required: f.tokens?.required ?? f.required }); if (e) out[f.name] = e; } } return out; } /** 字段说明:凭证字段已设置时补"留空不修改",token 字段缺省给范围 */ export function fieldHint(spec: FieldSpec, value: FieldValue | undefined, values: FormValues, hint?: string) { if (spec.kind === "secret") return secretHint(hint, value, spec.secret?.set(values) ?? false); if (spec.kind === "tokens" && hint === undefined && spec.tokens) return tokenRangeText(spec.tokens) ?? undefined; return hint; } 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 [deleting, setDeleting] = useState(null); const { list, remove } = hooks; // 记录抽屉(console-ia.md §9):编辑哪一个写在 ?open=(同页几个列表各认自己的 id);新建只在本页状态里 const sheet = useRecordSheet(); const [creating, setCreating] = useState(false); const found = sheet.id ? (list.data?.items.find((r) => r.id === sheet.id) ?? null) : null; const editing: Row | "new" | null = creating ? "new" : found; const setEditing = (row: Row | "new") => (row === "new" ? setCreating(true) : sheet.open(row.id)); 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 }} /> (creating ? setCreating(false) : sheet.close())} /> !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 local = localFieldErrors(config.fields, values); if (Object.keys(local).length) { setErrors(local); revealFirstError(); return; } 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} values={values} /> ))} {row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
); } export function FieldControl({ spec, value, error, onChange, disabled, hint, values, }: { spec: FieldSpec; value: FieldValue | undefined; error?: string; onChange: (v: FieldValue) => void; disabled?: boolean; hint?: string; /** 整个表单的值(凭证字段据此判断是否已设置);单独使用时可省略 */ values?: FormValues; }) { const id = `f-${spec.name}`; const all = values ?? {}; const wide = spec.wide || spec.kind === "textarea"; if (spec.kind === "switch") { return (

{hint ?? ""}

); } return ( ); } /** 字段的控件本体(不含标签与说明);开关也在这里 */ export function FieldInput({ id, spec, value, error, onChange, disabled, className, values, }: { id: string; spec: FieldSpec; value: FieldValue | undefined; error?: string; onChange: (v: FieldValue) => void; disabled?: boolean; className?: string; values?: FormValues; }) { if (spec.kind === "secret") { const all = values ?? {}; return ( ); } if (spec.kind === "tokens") { return ( onChange(e.target.value)} placeholder={spec.placeholder} disabled={disabled} aria-invalid={!!error} className={className} /> ); } if (spec.kind === "switch") { return ( ); } if (spec.kind === "select") { return ( ); } if (spec.kind === "textarea") { return (