UserCards.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. import React, { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Plus, Search, Edit, Trash2, CreditCard } from 'lucide-react';
  4. import { format } from 'date-fns';
  5. import { zhCN } from 'date-fns/locale';
  6. import { useForm } from 'react-hook-form';
  7. import { zodResolver } from '@hookform/resolvers/zod';
  8. import { toast } from 'sonner';
  9. import { Button } from '@/client/components/ui/button';
  10. import { Input } from '@/client/components/ui/input';
  11. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  12. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
  13. import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  14. import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
  15. import { Badge } from '@/client/components/ui/badge';
  16. import { Skeleton } from '@/client/components/ui/skeleton';
  17. import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
  18. import { userCardClient } from '@/client/api';
  19. import type { InferRequestType, InferResponseType } from 'hono/client';
  20. import { CreateUserCardDto, UpdateUserCardDto } from '@d8d/server/modules/user-cards/user-card.schema';
  21. import { UserSelector } from '@/client/admin/components/UserSelector';
  22. import { AgentSelector } from '@/client/admin/components/AgentSelector';
  23. type CreateRequest = InferRequestType<typeof userCardClient.$post>['json'];
  24. type UpdateRequest = InferRequestType<typeof userCardClient[':id']['$put']>['json'];
  25. type UserCardResponse = InferResponseType<typeof userCardClient.$get, 200>['data'][0];
  26. const createFormSchema = CreateUserCardDto;
  27. const updateFormSchema = UpdateUserCardDto;
  28. export const UserCardsPage = () => {
  29. const queryClient = useQueryClient();
  30. const [searchParams, setSearchParams] = useState({ page: 1, limit: 10, search: '' });
  31. const [isModalOpen, setIsModalOpen] = useState(false);
  32. const [isCreateForm, setIsCreateForm] = useState(true);
  33. const [editingCard, setEditingCard] = useState<UserCardResponse | null>(null);
  34. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  35. const [cardToDelete, setCardToDelete] = useState<number | null>(null);
  36. // 表单实例
  37. const createForm = useForm<CreateRequest>({
  38. resolver: zodResolver(createFormSchema),
  39. defaultValues: {
  40. userId: 0,
  41. cardNo: '',
  42. password: '',
  43. balance: 0,
  44. state: 1,
  45. isDefault: 2
  46. }
  47. });
  48. const updateForm = useForm<UpdateRequest>({
  49. resolver: zodResolver(updateFormSchema),
  50. defaultValues: {}
  51. });
  52. // 数据查询
  53. const { data, isLoading, refetch } = useQuery({
  54. queryKey: ['userCards', searchParams],
  55. queryFn: async () => {
  56. const res = await userCardClient.$get({
  57. query: {
  58. page: searchParams.page,
  59. pageSize: searchParams.limit,
  60. keyword: searchParams.search
  61. }
  62. });
  63. if (res.status !== 200) throw new Error('获取用户卡列表失败');
  64. return await res.json();
  65. }
  66. });
  67. // 创建用户卡
  68. const createMutation = useMutation({
  69. mutationFn: async (data: CreateRequest) => {
  70. const res = await userCardClient.$post({ json: data });
  71. if (res.status !== 201) throw new Error('创建用户卡失败');
  72. return await res.json();
  73. },
  74. onSuccess: () => {
  75. toast.success('用户卡创建成功');
  76. setIsModalOpen(false);
  77. createForm.reset();
  78. refetch();
  79. },
  80. onError: (error) => {
  81. toast.error(error.message || '创建用户卡失败');
  82. }
  83. });
  84. // 更新用户卡
  85. const updateMutation = useMutation({
  86. mutationFn: async ({ id, data }: { id: number; data: UpdateRequest }) => {
  87. const res = await userCardClient[':id']['$put']({
  88. param: { id: id.toString() },
  89. json: data
  90. });
  91. if (res.status !== 200) throw new Error('更新用户卡失败');
  92. return await res.json();
  93. },
  94. onSuccess: () => {
  95. toast.success('用户卡更新成功');
  96. setIsModalOpen(false);
  97. updateForm.reset();
  98. refetch();
  99. },
  100. onError: (error) => {
  101. toast.error(error.message || '更新用户卡失败');
  102. }
  103. });
  104. // 删除用户卡
  105. const deleteMutation = useMutation({
  106. mutationFn: async (id: number) => {
  107. const res = await userCardClient[':id']['$delete']({
  108. param: { id: id.toString() }
  109. });
  110. if (res.status !== 204) throw new Error('删除用户卡失败');
  111. return res;
  112. },
  113. onSuccess: () => {
  114. toast.success('用户卡删除成功');
  115. setDeleteDialogOpen(false);
  116. refetch();
  117. },
  118. onError: (error) => {
  119. toast.error(error.message || '删除用户卡失败');
  120. }
  121. });
  122. // 处理搜索
  123. const handleSearch = (e: React.FormEvent) => {
  124. e.preventDefault();
  125. setSearchParams(prev => ({ ...prev, page: 1 }));
  126. };
  127. // 处理创建用户卡
  128. const handleCreateCard = () => {
  129. setIsCreateForm(true);
  130. setEditingCard(null);
  131. createForm.reset();
  132. setIsModalOpen(true);
  133. };
  134. // 处理编辑用户卡
  135. const handleEditCard = (card: UserCardResponse) => {
  136. setIsCreateForm(false);
  137. setEditingCard(card);
  138. updateForm.reset({
  139. userId: card.userId,
  140. agentId: card.agentId || undefined,
  141. cardNo: card.cardNo,
  142. sjtCardNo: card.sjtCardNo || undefined,
  143. password: card.password,
  144. authCode: card.authCode || undefined,
  145. state: card.state,
  146. balance: card.balance,
  147. isDefault: card.isDefault
  148. });
  149. setIsModalOpen(true);
  150. };
  151. // 处理删除用户卡
  152. const handleDeleteCard = (id: number) => {
  153. setCardToDelete(id);
  154. setDeleteDialogOpen(true);
  155. };
  156. // 确认删除
  157. const confirmDelete = () => {
  158. if (cardToDelete) {
  159. deleteMutation.mutate(cardToDelete);
  160. }
  161. };
  162. // 加载状态
  163. if (isLoading) {
  164. return (
  165. <div className="space-y-4">
  166. <div className="flex justify-between items-center">
  167. <Skeleton className="h-8 w-48" />
  168. <Skeleton className="h-10 w-32" />
  169. </div>
  170. <Card>
  171. <CardContent className="pt-6">
  172. <div className="space-y-3">
  173. {[...Array(5)].map((_, i) => (
  174. <div key={i} className="flex gap-4">
  175. <Skeleton className="h-10 flex-1" />
  176. <Skeleton className="h-10 flex-1" />
  177. <Skeleton className="h-10 flex-1" />
  178. <Skeleton className="h-10 w-20" />
  179. </div>
  180. ))}
  181. </div>
  182. </CardContent>
  183. </Card>
  184. </div>
  185. );
  186. }
  187. return (
  188. <div className="space-y-4">
  189. {/* 页面标题 */}
  190. <div className="flex justify-between items-center">
  191. <div>
  192. <h1 className="text-2xl font-bold">用户卡管理</h1>
  193. <p className="text-muted-foreground">管理用户卡和余额信息</p>
  194. </div>
  195. <Button onClick={handleCreateCard}>
  196. <Plus className="mr-2 h-4 w-4" />
  197. 创建用户卡
  198. </Button>
  199. </div>
  200. {/* 搜索区域 */}
  201. <Card>
  202. <CardHeader>
  203. <CardTitle>用户卡列表</CardTitle>
  204. <CardDescription>查看和管理所有用户卡</CardDescription>
  205. </CardHeader>
  206. <CardContent>
  207. <form onSubmit={handleSearch} className="flex gap-2">
  208. <div className="relative flex-1 max-w-sm">
  209. <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  210. <Input
  211. placeholder="搜索卡号、盛京通卡号..."
  212. value={searchParams.search}
  213. onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
  214. className="pl-8"
  215. />
  216. </div>
  217. <Button type="submit" variant="outline">
  218. 搜索
  219. </Button>
  220. </form>
  221. </CardContent>
  222. </Card>
  223. {/* 数据表格 */}
  224. <Card>
  225. <CardContent className="p-0">
  226. <Table>
  227. <TableHeader>
  228. <TableRow>
  229. <TableHead>ID</TableHead>
  230. <TableHead>用户</TableHead>
  231. <TableHead>卡号</TableHead>
  232. <TableHead>盛京通卡号</TableHead>
  233. <TableHead>代理商</TableHead>
  234. <TableHead>余额</TableHead>
  235. <TableHead>状态</TableHead>
  236. <TableHead>默认</TableHead>
  237. <TableHead>创建时间</TableHead>
  238. <TableHead className="text-right">操作</TableHead>
  239. </TableRow>
  240. </TableHeader>
  241. <TableBody>
  242. {data?.data.map((card) => (
  243. <TableRow key={card.id}>
  244. <TableCell>{card.id}</TableCell>
  245. <TableCell>{card.user?.name || card.user?.username || '-'}</TableCell>
  246. <TableCell>{card.cardNo}</TableCell>
  247. <TableCell>{card.sjtCardNo || '-'}</TableCell>
  248. <TableCell>{card.agent?.name || '-'}</TableCell>
  249. <TableCell>¥{card.balance.toFixed(2)}</TableCell>
  250. <TableCell>
  251. <Badge variant={card.state === 1 ? 'default' : 'secondary'}>
  252. {card.state === 1 ? '绑定' : '解绑'}
  253. </Badge>
  254. </TableCell>
  255. <TableCell>
  256. <Badge variant={card.isDefault === 1 ? 'default' : 'secondary'}>
  257. {card.isDefault === 1 ? '是' : '否'}
  258. </Badge>
  259. </TableCell>
  260. <TableCell>
  261. {format(new Date(card.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
  262. </TableCell>
  263. <TableCell className="text-right">
  264. <div className="flex justify-end gap-2">
  265. <Button
  266. variant="ghost"
  267. size="icon"
  268. onClick={() => handleEditCard(card)}
  269. >
  270. <Edit className="h-4 w-4" />
  271. </Button>
  272. <Button
  273. variant="ghost"
  274. size="icon"
  275. onClick={() => handleDeleteCard(card.id)}
  276. >
  277. <Trash2 className="h-4 w-4" />
  278. </Button>
  279. </div>
  280. </TableCell>
  281. </TableRow>
  282. ))}
  283. </TableBody>
  284. </Table>
  285. {data?.data.length === 0 && (
  286. <div className="text-center py-8">
  287. <p className="text-muted-foreground">暂无用户卡数据</p>
  288. </div>
  289. )}
  290. </CardContent>
  291. </Card>
  292. {/* 分页 */}
  293. {data && data.data.length > 0 && (
  294. <DataTablePagination
  295. currentPage={searchParams.page}
  296. pageSize={searchParams.limit}
  297. totalCount={data.pagination.total || 0}
  298. onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
  299. />
  300. )}
  301. {/* 创建/编辑模态框 */}
  302. <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
  303. <DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
  304. <DialogHeader>
  305. <DialogTitle>{isCreateForm ? '创建用户卡' : '编辑用户卡'}</DialogTitle>
  306. <DialogDescription>
  307. {isCreateForm ? '创建一个新的用户卡' : '编辑现有用户卡信息'}
  308. </DialogDescription>
  309. </DialogHeader>
  310. {isCreateForm ? (
  311. <Form {...createForm}>
  312. <form onSubmit={createForm.handleSubmit((data) => createMutation.mutate(data))} className="space-y-4">
  313. <FormField
  314. control={createForm.control}
  315. name="userId"
  316. render={({ field }) => (
  317. <FormItem>
  318. <FormLabel className="flex items-center">
  319. 用户 <span className="text-red-500 ml-1">*</span>
  320. </FormLabel>
  321. <FormControl>
  322. <UserSelector
  323. value={field.value}
  324. onChange={field.onChange}
  325. placeholder="选择用户"
  326. />
  327. </FormControl>
  328. <FormMessage />
  329. </FormItem>
  330. )}
  331. />
  332. <FormField
  333. control={createForm.control}
  334. name="cardNo"
  335. render={({ field }) => (
  336. <FormItem>
  337. <FormLabel className="flex items-center">
  338. 卡号 <span className="text-red-500 ml-1">*</span>
  339. </FormLabel>
  340. <FormControl>
  341. <Input placeholder="请输入卡号" {...field} />
  342. </FormControl>
  343. <FormMessage />
  344. </FormItem>
  345. )}
  346. />
  347. <FormField
  348. control={createForm.control}
  349. name="password"
  350. render={({ field }) => (
  351. <FormItem>
  352. <FormLabel className="flex items-center">
  353. 密码 <span className="text-red-500 ml-1">*</span>
  354. </FormLabel>
  355. <FormControl>
  356. <Input type="password" placeholder="请输入密码" {...field} />
  357. </FormControl>
  358. <FormMessage />
  359. </FormItem>
  360. )}
  361. />
  362. <FormField
  363. control={createForm.control}
  364. name="agentId"
  365. render={({ field }) => (
  366. <FormItem>
  367. <FormLabel>代理商</FormLabel>
  368. <FormControl>
  369. <AgentSelector
  370. value={field.value || undefined}
  371. onChange={field.onChange}
  372. placeholder="选择代理商"
  373. />
  374. </FormControl>
  375. <FormMessage />
  376. </FormItem>
  377. )}
  378. />
  379. <FormField
  380. control={createForm.control}
  381. name="sjtCardNo"
  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="balance"
  395. render={({ field }) => (
  396. <FormItem>
  397. <FormLabel>初始余额</FormLabel>
  398. <FormControl>
  399. <Input
  400. type="number"
  401. step="0.01"
  402. placeholder="0.00"
  403. {...field}
  404. onChange={(e) => field.onChange(parseFloat(e.target.value))}
  405. />
  406. </FormControl>
  407. <FormMessage />
  408. </FormItem>
  409. )}
  410. />
  411. <FormField
  412. control={createForm.control}
  413. name="state"
  414. render={({ field }) => (
  415. <FormItem>
  416. <FormLabel>状态</FormLabel>
  417. <FormControl>
  418. <select
  419. {...field}
  420. className="w-full px-3 py-2 border rounded-md"
  421. onChange={(e) => field.onChange(parseInt(e.target.value))}
  422. >
  423. <option value={1}>绑定</option>
  424. <option value={2}>解绑</option>
  425. </select>
  426. </FormControl>
  427. <FormMessage />
  428. </FormItem>
  429. )}
  430. />
  431. <FormField
  432. control={createForm.control}
  433. name="isDefault"
  434. render={({ field }) => (
  435. <FormItem>
  436. <FormLabel>是否默认</FormLabel>
  437. <FormControl>
  438. <select
  439. {...field}
  440. className="w-full px-3 py-2 border rounded-md"
  441. onChange={(e) => field.onChange(parseInt(e.target.value))}
  442. >
  443. <option value={1}>是</option>
  444. <option value={2}>否</option>
  445. </select>
  446. </FormControl>
  447. <FormMessage />
  448. </FormItem>
  449. )}
  450. />
  451. <DialogFooter>
  452. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  453. 取消
  454. </Button>
  455. <Button type="submit" disabled={createMutation.isPending}>
  456. {createMutation.isPending ? '创建中...' : '创建'}
  457. </Button>
  458. </DialogFooter>
  459. </form>
  460. </Form>
  461. ) : (
  462. <Form {...updateForm}>
  463. <form onSubmit={updateForm.handleSubmit((data) => updateMutation.mutate({ id: editingCard!.id, data }))} className="space-y-4">
  464. <FormField
  465. control={updateForm.control}
  466. name="userId"
  467. render={({ field }) => (
  468. <FormItem>
  469. <FormLabel className="flex items-center">
  470. 用户 <span className="text-red-500 ml-1">*</span>
  471. </FormLabel>
  472. <FormControl>
  473. <UserSelector
  474. value={field.value || editingCard?.userId || 0}
  475. onChange={field.onChange}
  476. placeholder="选择用户"
  477. />
  478. </FormControl>
  479. <FormMessage />
  480. </FormItem>
  481. )}
  482. />
  483. <FormField
  484. control={updateForm.control}
  485. name="cardNo"
  486. render={({ field }) => (
  487. <FormItem>
  488. <FormLabel className="flex items-center">
  489. 卡号 <span className="text-red-500 ml-1">*</span>
  490. </FormLabel>
  491. <FormControl>
  492. <Input placeholder="请输入卡号" {...field} />
  493. </FormControl>
  494. <FormMessage />
  495. </FormItem>
  496. )}
  497. />
  498. <FormField
  499. control={updateForm.control}
  500. name="password"
  501. render={({ field }) => (
  502. <FormItem>
  503. <FormLabel className="flex items-center">
  504. 密码 <span className="text-red-500 ml-1">*</span>
  505. </FormLabel>
  506. <FormControl>
  507. <Input type="password" placeholder="请输入密码" {...field} />
  508. </FormControl>
  509. <FormMessage />
  510. </FormItem>
  511. )}
  512. />
  513. <FormField
  514. control={updateForm.control}
  515. name="agentId"
  516. render={({ field }) => (
  517. <FormItem>
  518. <FormLabel>代理商</FormLabel>
  519. <FormControl>
  520. <AgentSelector
  521. value={field.value || editingCard?.agentId || undefined}
  522. onChange={field.onChange}
  523. placeholder="选择代理商"
  524. />
  525. </FormControl>
  526. <FormMessage />
  527. </FormItem>
  528. )}
  529. />
  530. <FormField
  531. control={updateForm.control}
  532. name="sjtCardNo"
  533. render={({ field }) => (
  534. <FormItem>
  535. <FormLabel>盛京通卡号</FormLabel>
  536. <FormControl>
  537. <Input placeholder="请输入盛京通卡号" {...field} />
  538. </FormControl>
  539. <FormMessage />
  540. </FormItem>
  541. )}
  542. />
  543. <FormField
  544. control={updateForm.control}
  545. name="balance"
  546. render={({ field }) => (
  547. <FormItem>
  548. <FormLabel>余额</FormLabel>
  549. <FormControl>
  550. <Input
  551. type="number"
  552. step="0.01"
  553. placeholder="0.00"
  554. {...field}
  555. onChange={(e) => field.onChange(parseFloat(e.target.value))}
  556. />
  557. </FormControl>
  558. <FormMessage />
  559. </FormItem>
  560. )}
  561. />
  562. <FormField
  563. control={updateForm.control}
  564. name="state"
  565. render={({ field }) => (
  566. <FormItem>
  567. <FormLabel>状态</FormLabel>
  568. <FormControl>
  569. <select
  570. {...field}
  571. className="w-full px-3 py-2 border rounded-md"
  572. onChange={(e) => field.onChange(parseInt(e.target.value))}
  573. >
  574. <option value={1}>绑定</option>
  575. <option value={2}>解绑</option>
  576. </select>
  577. </FormControl>
  578. <FormMessage />
  579. </FormItem>
  580. )}
  581. />
  582. <FormField
  583. control={updateForm.control}
  584. name="isDefault"
  585. render={({ field }) => (
  586. <FormItem>
  587. <FormLabel>是否默认</FormLabel>
  588. <FormControl>
  589. <select
  590. {...field}
  591. className="w-full px-3 py-2 border rounded-md"
  592. onChange={(e) => field.onChange(parseInt(e.target.value))}
  593. >
  594. <option value={1}>是</option>
  595. <option value={2}>否</option>
  596. </select>
  597. </FormControl>
  598. <FormMessage />
  599. </FormItem>
  600. )}
  601. />
  602. <DialogFooter>
  603. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  604. 取消
  605. </Button>
  606. <Button type="submit" disabled={updateMutation.isPending}>
  607. {updateMutation.isPending ? '更新中...' : '更新'}
  608. </Button>
  609. </DialogFooter>
  610. </form>
  611. </Form>
  612. )}
  613. </DialogContent>
  614. </Dialog>
  615. {/* 删除确认对话框 */}
  616. <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
  617. <DialogContent>
  618. <DialogHeader>
  619. <DialogTitle>确认删除</DialogTitle>
  620. <DialogDescription>
  621. 确定要删除这张用户卡吗?此操作将同时删除相关的余额记录。
  622. </DialogDescription>
  623. </DialogHeader>
  624. <DialogFooter>
  625. <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
  626. 取消
  627. </Button>
  628. <Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isPending}>
  629. {deleteMutation.isPending ? '删除中...' : '删除'}
  630. </Button>
  631. </DialogFooter>
  632. </DialogContent>
  633. </Dialog>
  634. </div>
  635. );
  636. };