app-template/app/web/src/components/UserList.tsx

184 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 用户 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>
);
}