- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
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<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={`删除${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;
|
||
}
|
||
}}
|
||
/>
|
||
</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);
|
||
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 (
|
||
<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)));
|