184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
/**
|
||
* 用户 CRUD 演示岛:tRPC + React Query。
|
||
* Provider 包在组件内部,便于 Astro 以 client:visible 挂载而不污染全局。
|
||
*/
|
||
|
||
import type { RouterOutputs } from "@app/trpc";
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
Input,
|
||
Label,
|
||
Separator,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
toast,
|
||
} from "@app/ui";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { useState } from "react";
|
||
import { createTRPCReactClient, trpc } from "../lib/trpc";
|
||
|
||
const queryClient = new QueryClient();
|
||
const trpcClient = createTRPCReactClient();
|
||
|
||
type User = RouterOutputs["user"]["list"]["users"][number];
|
||
|
||
export default function UserList() {
|
||
return (
|
||
<QueryClientProvider client={queryClient}>
|
||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||
<UserListInner />
|
||
</trpc.Provider>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
function UserListInner() {
|
||
const users = trpc.user.list.useQuery({ limit: 20, offset: 0 });
|
||
const createUser = trpc.user.create.useMutation({
|
||
onSuccess: () => {
|
||
users.refetch();
|
||
toast.success("用户创建成功");
|
||
},
|
||
onError: (err) => {
|
||
toast.error(`创建失败: ${err.message}`);
|
||
},
|
||
});
|
||
|
||
const [name, setName] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!name || !email || !password) return;
|
||
await createUser.mutateAsync({ name, email, password });
|
||
setName("");
|
||
setEmail("");
|
||
setPassword("");
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>用户列表</CardTitle>
|
||
<CardDescription>当前共 {users.data?.total ?? "—"} 位用户</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
{users.isPending ? (
|
||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-muted-foreground)]">
|
||
加载中…
|
||
</div>
|
||
) : users.isError ? (
|
||
<div className="p-6 text-sm text-[var(--color-destructive)]">
|
||
加载失败: {users.error.message}
|
||
</div>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[120px]">ID</TableHead>
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>邮箱</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{users.data?.users.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={4}
|
||
className="text-center text-[var(--color-muted-foreground)]"
|
||
>
|
||
暂无数据
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
users.data?.users.map((u: User) => (
|
||
<TableRow key={u.id}>
|
||
<TableCell className="font-mono text-xs">
|
||
{u.id.slice(0, 8)}…
|
||
</TableCell>
|
||
<TableCell>{u.name}</TableCell>
|
||
<TableCell>{u.email}</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary">活跃</Badge>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">新建用户</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">名称</Label>
|
||
<Input
|
||
id="name"
|
||
placeholder="输入名称"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="email">邮箱</Label>
|
||
<Input
|
||
id="email"
|
||
type="email"
|
||
placeholder="input@example.com"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="password">密码</Label>
|
||
<Input
|
||
id="password"
|
||
type="password"
|
||
placeholder="至少 8 位"
|
||
minLength={8}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Separator />
|
||
<div className="flex gap-2">
|
||
<Button type="submit" disabled={createUser.isPending}>
|
||
{createUser.isPending ? "创建中…" : "创建用户"}
|
||
</Button>
|
||
{createUser.isError && (
|
||
<span className="text-sm text-[var(--color-destructive)] self-center">
|
||
{createUser.error.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|