Users.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. import React, { useState } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { format } from 'date-fns';
  4. import { Plus, Search, Edit, Trash2 } from 'lucide-react';
  5. import { userClient } from '@/client/api';
  6. import type { InferRequestType, InferResponseType } from 'hono/client';
  7. import { Button } from '@/client/components/ui/button';
  8. import { Input } from '@/client/components/ui/input';
  9. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  10. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
  11. import { Badge } from '@/client/components/ui/badge';
  12. import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  13. import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
  14. import { useForm } from 'react-hook-form';
  15. import { zodResolver } from '@hookform/resolvers/zod';
  16. import { z } from 'zod';
  17. import { toast } from 'sonner';
  18. import { Skeleton } from '@/client/components/ui/skeleton';
  19. import { Switch } from '@/client/components/ui/switch';
  20. import { DisabledStatus } from '@/share/types';
  21. // 使用RPC方式提取类型
  22. type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
  23. type UpdateUserRequest = InferRequestType<typeof userClient[':id']['$put']>['json'];
  24. type UserResponse = InferResponseType<typeof userClient.$get, 200>['data'][0];
  25. // 创建用户表单Schema - 与后端CreateUserSchema保持一致
  26. const createUserFormSchema = z.object({
  27. username: z.string().min(3, '用户名至少3个字符'),
  28. nickname: z.string().nullable().optional(),
  29. email: z.string().email('请输入有效的邮箱地址').nullable().optional(),
  30. phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号').nullable().optional(),
  31. name: z.string().nullable().optional(),
  32. password: z.string().min(6, '密码至少6个字符'),
  33. isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED),
  34. });
  35. // 更新用户表单Schema - 与后端UpdateUserSchema保持一致
  36. const updateUserFormSchema = z.object({
  37. username: z.string().min(3, '用户名至少3个字符').optional(),
  38. nickname: z.string().nullable().optional(),
  39. email: z.string().email('请输入有效的邮箱地址').nullable().optional(),
  40. phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号').nullable().optional(),
  41. name: z.string().nullable().optional(),
  42. password: z.string().min(6, '密码至少6个字符').optional(),
  43. isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED),
  44. });
  45. type CreateUserFormData = CreateUserRequest;
  46. type UpdateUserFormData = UpdateUserRequest;
  47. export const UsersPage = () => {
  48. const [searchParams, setSearchParams] = useState({
  49. page: 1,
  50. limit: 10,
  51. search: ''
  52. });
  53. const [isModalOpen, setIsModalOpen] = useState(false);
  54. const [editingUser, setEditingUser] = useState<any>(null);
  55. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  56. const [userToDelete, setUserToDelete] = useState<number | null>(null);
  57. const [isCreateForm, setIsCreateForm] = useState(true);
  58. const createForm = useForm<CreateUserFormData>({
  59. resolver: zodResolver(createUserFormSchema),
  60. defaultValues: {
  61. username: '',
  62. nickname: undefined,
  63. email: null,
  64. phone: null,
  65. name: null,
  66. password: '',
  67. isDisabled: DisabledStatus.ENABLED,
  68. },
  69. });
  70. const updateForm = useForm<UpdateUserFormData>({
  71. resolver: zodResolver(updateUserFormSchema),
  72. defaultValues: {
  73. username: undefined,
  74. nickname: undefined,
  75. email: null,
  76. phone: null,
  77. name: null,
  78. password: undefined,
  79. isDisabled: undefined,
  80. },
  81. });
  82. const { data: usersData, isLoading, refetch } = useQuery({
  83. queryKey: ['users', searchParams],
  84. queryFn: async () => {
  85. const res = await userClient.$get({
  86. query: {
  87. page: searchParams.page,
  88. pageSize: searchParams.limit,
  89. keyword: searchParams.search
  90. }
  91. });
  92. if (res.status !== 200) {
  93. throw new Error('获取用户列表失败');
  94. }
  95. return await res.json();
  96. }
  97. });
  98. const users = usersData?.data || [];
  99. const totalCount = usersData?.pagination?.total || 0;
  100. // 处理搜索
  101. const handleSearch = (e: React.FormEvent) => {
  102. e.preventDefault();
  103. setSearchParams(prev => ({ ...prev, page: 1 }));
  104. };
  105. // 处理分页
  106. const handlePageChange = (page: number, limit: number) => {
  107. setSearchParams(prev => ({ ...prev, page, limit }));
  108. };
  109. // 打开创建用户对话框
  110. const handleCreateUser = () => {
  111. setEditingUser(null);
  112. setIsCreateForm(true);
  113. createForm.reset({
  114. username: '',
  115. nickname: undefined,
  116. email: null,
  117. phone: null,
  118. name: null,
  119. password: '',
  120. isDisabled: DisabledStatus.ENABLED,
  121. });
  122. setIsModalOpen(true);
  123. };
  124. // 打开编辑用户对话框
  125. const handleEditUser = (user: UserResponse) => {
  126. setEditingUser(user);
  127. setIsCreateForm(false);
  128. updateForm.reset({
  129. username: user.username,
  130. nickname: user.nickname || undefined,
  131. email: user.email || null,
  132. phone: user.phone || null,
  133. name: user.name || null,
  134. isDisabled: user.isDisabled,
  135. });
  136. setIsModalOpen(true);
  137. };
  138. // 处理创建表单提交
  139. const handleCreateSubmit = async (data: CreateUserFormData) => {
  140. try {
  141. const submitData: CreateUserRequest = {
  142. ...data,
  143. isDisabled: data.isDisabled ? 1 : 0,
  144. };
  145. const res = await userClient.$post({
  146. json: submitData
  147. });
  148. if (res.status !== 201) {
  149. throw new Error('创建用户失败');
  150. }
  151. toast.success('用户创建成功');
  152. setIsModalOpen(false);
  153. refetch();
  154. } catch (error) {
  155. console.error('创建用户失败:', error);
  156. toast.error('创建失败,请重试');
  157. }
  158. };
  159. // 处理更新表单提交
  160. const handleUpdateSubmit = async (data: UpdateUserFormData) => {
  161. if (!editingUser) return;
  162. try {
  163. const submitData: UpdateUserRequest = {
  164. ...data,
  165. isDisabled: data.isDisabled !== undefined ? (data.isDisabled ? 1 : 0) : undefined,
  166. };
  167. const res = await userClient[':id']['$put']({
  168. param: { id: editingUser.id },
  169. json: submitData
  170. });
  171. if (res.status !== 200) {
  172. throw new Error('更新用户失败');
  173. }
  174. toast.success('用户更新成功');
  175. setIsModalOpen(false);
  176. refetch();
  177. } catch (error) {
  178. console.error('更新用户失败:', error);
  179. toast.error('更新失败,请重试');
  180. }
  181. };
  182. // 处理删除用户
  183. const handleDeleteUser = (id: number) => {
  184. setUserToDelete(id);
  185. setDeleteDialogOpen(true);
  186. };
  187. const confirmDelete = async () => {
  188. if (!userToDelete) return;
  189. try {
  190. const res = await userClient[':id']['$delete']({
  191. param: { id: userToDelete }
  192. });
  193. if (res.status !== 204) {
  194. throw new Error('删除用户失败');
  195. }
  196. toast.success('用户删除成功');
  197. refetch();
  198. } catch (error) {
  199. console.error('删除用户失败:', error);
  200. toast.error('删除失败,请重试');
  201. } finally {
  202. setDeleteDialogOpen(false);
  203. setUserToDelete(null);
  204. }
  205. };
  206. // 渲染加载骨架
  207. if (isLoading) {
  208. return (
  209. <div className="space-y-4">
  210. <div className="flex justify-between items-center">
  211. <h1 className="text-2xl font-bold">用户管理</h1>
  212. <Button disabled>
  213. <Plus className="mr-2 h-4 w-4" />
  214. 创建用户
  215. </Button>
  216. </div>
  217. <Card>
  218. <CardHeader>
  219. <Skeleton className="h-6 w-1/4" />
  220. </CardHeader>
  221. <CardContent>
  222. <div className="space-y-2">
  223. <Skeleton className="h-4 w-full" />
  224. <Skeleton className="h-4 w-full" />
  225. <Skeleton className="h-4 w-full" />
  226. </div>
  227. </CardContent>
  228. </Card>
  229. </div>
  230. );
  231. }
  232. return (
  233. <div className="space-y-4">
  234. <div className="flex justify-between items-center">
  235. <h1 className="text-2xl font-bold">用户管理</h1>
  236. <Button onClick={handleCreateUser}>
  237. <Plus className="mr-2 h-4 w-4" />
  238. 创建用户
  239. </Button>
  240. </div>
  241. <Card>
  242. <CardHeader>
  243. <CardTitle>用户列表</CardTitle>
  244. <CardDescription>
  245. 管理系统中的所有用户,共 {totalCount} 位用户
  246. </CardDescription>
  247. </CardHeader>
  248. <CardContent>
  249. <div className="mb-4">
  250. <form onSubmit={handleSearch} className="flex gap-2">
  251. <div className="relative flex-1 max-w-sm">
  252. <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  253. <Input
  254. placeholder="搜索用户名、昵称或邮箱..."
  255. value={searchParams.search}
  256. onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
  257. className="pl-8"
  258. />
  259. </div>
  260. <Button type="submit" variant="outline">
  261. 搜索
  262. </Button>
  263. </form>
  264. </div>
  265. <div className="rounded-md border">
  266. <Table>
  267. <TableHeader>
  268. <TableRow>
  269. <TableHead>用户名</TableHead>
  270. <TableHead>昵称</TableHead>
  271. <TableHead>邮箱</TableHead>
  272. <TableHead>真实姓名</TableHead>
  273. <TableHead>角色</TableHead>
  274. <TableHead>状态</TableHead>
  275. <TableHead>创建时间</TableHead>
  276. <TableHead className="text-right">操作</TableHead>
  277. </TableRow>
  278. </TableHeader>
  279. <TableBody>
  280. {users.map((user) => (
  281. <TableRow key={user.id}>
  282. <TableCell className="font-medium">{user.username}</TableCell>
  283. <TableCell>{user.nickname || '-'}</TableCell>
  284. <TableCell>{user.email || '-'}</TableCell>
  285. <TableCell>{user.name || '-'}</TableCell>
  286. <TableCell>
  287. <Badge
  288. variant={user.roles?.some((role: any) => role.name === 'admin') ? 'destructive' : 'default'}
  289. className="capitalize"
  290. >
  291. {user.roles?.some((role: any) => role.name === 'admin') ? '管理员' : '普通用户'}
  292. </Badge>
  293. </TableCell>
  294. <TableCell>
  295. <Badge
  296. variant={user.isDisabled === 1 ? 'secondary' : 'default'}
  297. >
  298. {user.isDisabled === 1 ? '禁用' : '启用'}
  299. </Badge>
  300. </TableCell>
  301. <TableCell>
  302. {format(new Date(user.createdAt), 'yyyy-MM-dd HH:mm')}
  303. </TableCell>
  304. <TableCell className="text-right">
  305. <div className="flex justify-end gap-2">
  306. <Button
  307. variant="ghost"
  308. size="icon"
  309. onClick={() => handleEditUser(user)}
  310. >
  311. <Edit className="h-4 w-4" />
  312. </Button>
  313. <Button
  314. variant="ghost"
  315. size="icon"
  316. onClick={() => handleDeleteUser(user.id)}
  317. >
  318. <Trash2 className="h-4 w-4" />
  319. </Button>
  320. </div>
  321. </TableCell>
  322. </TableRow>
  323. ))}
  324. </TableBody>
  325. </Table>
  326. </div>
  327. <div className="flex justify-between items-center mt-4">
  328. <div className="text-sm text-muted-foreground">
  329. 第 {searchParams.page} 页,共 {Math.ceil(totalCount / searchParams.limit)} 页
  330. </div>
  331. <div className="flex gap-2">
  332. <Button
  333. variant="outline"
  334. size="sm"
  335. disabled={searchParams.page <= 1}
  336. onClick={() => handlePageChange(searchParams.page - 1, searchParams.limit)}
  337. >
  338. 上一页
  339. </Button>
  340. <Button
  341. variant="outline"
  342. size="sm"
  343. disabled={searchParams.page >= Math.ceil(totalCount / searchParams.limit)}
  344. onClick={() => handlePageChange(searchParams.page + 1, searchParams.limit)}
  345. >
  346. 下一页
  347. </Button>
  348. </div>
  349. </div>
  350. </CardContent>
  351. </Card>
  352. {/* 创建/编辑用户对话框 */}
  353. <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
  354. <DialogContent className="sm:max-w-[500px]">
  355. <DialogHeader>
  356. <DialogTitle>
  357. {editingUser ? '编辑用户' : '创建用户'}
  358. </DialogTitle>
  359. <DialogDescription>
  360. {editingUser ? '编辑现有用户信息' : '创建一个新的用户账户'}
  361. </DialogDescription>
  362. </DialogHeader>
  363. {isCreateForm ? (
  364. <Form {...createForm}>
  365. <form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
  366. <FormField
  367. control={createForm.control}
  368. name="username"
  369. render={({ field }) => (
  370. <FormItem>
  371. <FormLabel>用户名</FormLabel>
  372. <FormControl>
  373. <Input placeholder="请输入用户名" {...field} />
  374. </FormControl>
  375. <FormMessage />
  376. </FormItem>
  377. )}
  378. />
  379. <FormField
  380. control={createForm.control}
  381. name="nickname"
  382. render={({ field }) => (
  383. <FormItem>
  384. <FormLabel>昵称</FormLabel>
  385. <FormControl>
  386. <Input placeholder="请输入昵称" {...field} />
  387. </FormControl>
  388. <FormMessage />
  389. </FormItem>
  390. )}
  391. />
  392. <FormField
  393. control={createForm.control}
  394. name="email"
  395. render={({ field }) => (
  396. <FormItem>
  397. <FormLabel>邮箱</FormLabel>
  398. <FormControl>
  399. <Input type="email" placeholder="请输入邮箱" {...field} />
  400. </FormControl>
  401. <FormMessage />
  402. </FormItem>
  403. )}
  404. />
  405. <FormField
  406. control={createForm.control}
  407. name="phone"
  408. render={({ field }) => (
  409. <FormItem>
  410. <FormLabel>手机号</FormLabel>
  411. <FormControl>
  412. <Input placeholder="请输入手机号" {...field} />
  413. </FormControl>
  414. <FormMessage />
  415. </FormItem>
  416. )}
  417. />
  418. <FormField
  419. control={createForm.control}
  420. name="name"
  421. render={({ field }) => (
  422. <FormItem>
  423. <FormLabel>真实姓名</FormLabel>
  424. <FormControl>
  425. <Input placeholder="请输入真实姓名" {...field} />
  426. </FormControl>
  427. <FormMessage />
  428. </FormItem>
  429. )}
  430. />
  431. <FormField
  432. control={createForm.control}
  433. name="password"
  434. render={({ field }) => (
  435. <FormItem>
  436. <FormLabel>密码</FormLabel>
  437. <FormControl>
  438. <Input type="password" placeholder="请输入密码" {...field} />
  439. </FormControl>
  440. <FormMessage />
  441. </FormItem>
  442. )}
  443. />
  444. <FormField
  445. control={createForm.control}
  446. name="isDisabled"
  447. render={({ field }) => (
  448. <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
  449. <div className="space-y-0.5">
  450. <FormLabel className="text-base">用户状态</FormLabel>
  451. <FormDescription>
  452. 禁用后用户将无法登录系统
  453. </FormDescription>
  454. </div>
  455. <FormControl>
  456. <Switch
  457. checked={field.value}
  458. onCheckedChange={field.onChange}
  459. />
  460. </FormControl>
  461. </FormItem>
  462. )}
  463. />
  464. <DialogFooter>
  465. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  466. 取消
  467. </Button>
  468. <Button type="submit">
  469. 创建用户
  470. </Button>
  471. </DialogFooter>
  472. </form>
  473. </Form>
  474. ) : (
  475. <Form {...updateForm}>
  476. <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
  477. <FormField
  478. control={updateForm.control}
  479. name="username"
  480. render={({ field }) => (
  481. <FormItem>
  482. <FormLabel>用户名</FormLabel>
  483. <FormControl>
  484. <Input placeholder="请输入用户名" {...field} />
  485. </FormControl>
  486. <FormMessage />
  487. </FormItem>
  488. )}
  489. />
  490. <FormField
  491. control={updateForm.control}
  492. name="nickname"
  493. render={({ field }) => (
  494. <FormItem>
  495. <FormLabel>昵称</FormLabel>
  496. <FormControl>
  497. <Input placeholder="请输入昵称" {...field} />
  498. </FormControl>
  499. <FormMessage />
  500. </FormItem>
  501. )}
  502. />
  503. <FormField
  504. control={updateForm.control}
  505. name="email"
  506. render={({ field }) => (
  507. <FormItem>
  508. <FormLabel>邮箱</FormLabel>
  509. <FormControl>
  510. <Input type="email" placeholder="请输入邮箱" {...field} />
  511. </FormControl>
  512. <FormMessage />
  513. </FormItem>
  514. )}
  515. />
  516. <FormField
  517. control={updateForm.control}
  518. name="phone"
  519. render={({ field }) => (
  520. <FormItem>
  521. <FormLabel>手机号</FormLabel>
  522. <FormControl>
  523. <Input placeholder="请输入手机号" {...field} />
  524. </FormControl>
  525. <FormMessage />
  526. </FormItem>
  527. )}
  528. />
  529. <FormField
  530. control={updateForm.control}
  531. name="name"
  532. render={({ field }) => (
  533. <FormItem>
  534. <FormLabel>真实姓名</FormLabel>
  535. <FormControl>
  536. <Input placeholder="请输入真实姓名" {...field} />
  537. </FormControl>
  538. <FormMessage />
  539. </FormItem>
  540. )}
  541. />
  542. <FormField
  543. control={updateForm.control}
  544. name="password"
  545. render={({ field }) => (
  546. <FormItem>
  547. <FormLabel>新密码</FormLabel>
  548. <FormControl>
  549. <Input type="password" placeholder="留空则不修改密码" {...field} />
  550. </FormControl>
  551. <FormMessage />
  552. </FormItem>
  553. )}
  554. />
  555. <FormField
  556. control={updateForm.control}
  557. name="isDisabled"
  558. render={({ field }) => (
  559. <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
  560. <div className="space-y-0.5">
  561. <FormLabel className="text-base">用户状态</FormLabel>
  562. <FormDescription>
  563. 禁用后用户将无法登录系统
  564. </FormDescription>
  565. </div>
  566. <FormControl>
  567. <Switch
  568. checked={field.value}
  569. onCheckedChange={field.onChange}
  570. />
  571. </FormControl>
  572. </FormItem>
  573. )}
  574. />
  575. <DialogFooter>
  576. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  577. 取消
  578. </Button>
  579. <Button type="submit">
  580. 更新用户
  581. </Button>
  582. </DialogFooter>
  583. </form>
  584. </Form>
  585. )}
  586. </DialogContent>
  587. </Dialog>
  588. {/* 删除确认对话框 */}
  589. <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
  590. <DialogContent>
  591. <DialogHeader>
  592. <DialogTitle>确认删除</DialogTitle>
  593. <DialogDescription>
  594. 确定要删除这个用户吗?此操作无法撤销。
  595. </DialogDescription>
  596. </DialogHeader>
  597. <DialogFooter>
  598. <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
  599. 取消
  600. </Button>
  601. <Button variant="destructive" onClick={confirmDelete}>
  602. 删除
  603. </Button>
  604. </DialogFooter>
  605. </DialogContent>
  606. </Dialog>
  607. </div>
  608. );
  609. };