DeliveryAddressManagement.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { format } from 'date-fns';
  4. import { zhCN } from 'date-fns/locale';
  5. import { toast } from 'sonner';
  6. import { Plus, Search, Edit, Trash2, MapPin } from 'lucide-react';
  7. import { useForm } from 'react-hook-form';
  8. import { zodResolver } from '@hookform/resolvers/zod';
  9. import { deliveryAddressClient } from '../api/deliveryAddressClient';
  10. import { CreateDeliveryAddressDto, UpdateDeliveryAddressDto } from '@d8d/delivery-address-module/schemas';
  11. import { Button } from '@d8d/shared-ui-components/components/ui/button';
  12. import { Input } from '@d8d/shared-ui-components/components/ui/input';
  13. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@d8d/shared-ui-components/components/ui/card';
  14. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@d8d/shared-ui-components/components/ui/table';
  15. import { Badge } from '@d8d/shared-ui-components/components/ui/badge';
  16. import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@d8d/shared-ui-components/components/ui/dialog';
  17. import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@d8d/shared-ui-components/components/ui/form';
  18. import { Switch } from '@d8d/shared-ui-components/components/ui/switch';
  19. import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
  20. import { DataTablePagination } from '@d8d/shared-ui-components/components/admin/DataTablePagination';
  21. import { UserSelector } from '@d8d/user-management-ui/components';
  22. import { AreaSelect4Level } from '@d8d/area-management-ui/components';
  23. import type { DeliveryAddress, DeliveryAddressQueryParams } from '../types/delivery-address';
  24. import { DeliveryAddressState, DefaultAddressState } from '../types/delivery-address';
  25. // 表单schema
  26. const createFormSchema = CreateDeliveryAddressDto;
  27. const updateFormSchema = UpdateDeliveryAddressDto;
  28. export const DeliveryAddressManagement: React.FC = () => {
  29. const queryClient = useQueryClient();
  30. // 状态管理
  31. const [searchParams, setSearchParams] = useState<DeliveryAddressQueryParams>({
  32. page: 1,
  33. limit: 10,
  34. search: '',
  35. userId: undefined,
  36. });
  37. const [isModalOpen, setIsModalOpen] = useState(false);
  38. const [editingAddress, setEditingAddress] = useState<DeliveryAddress | null>(null);
  39. const [isCreateForm, setIsCreateForm] = useState(true);
  40. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  41. const [addressToDelete, setAddressToDelete] = useState<number | null>(null);
  42. // 表单实例
  43. const createForm = useForm({
  44. resolver: zodResolver(createFormSchema),
  45. defaultValues: {
  46. userId: 0,
  47. name: '',
  48. phone: '',
  49. address: '',
  50. receiverProvince: 0,
  51. receiverCity: 0,
  52. receiverDistrict: 0,
  53. receiverTown: 0,
  54. isDefault: 0,
  55. },
  56. });
  57. const updateForm = useForm({
  58. resolver: zodResolver(updateFormSchema),
  59. });
  60. // 数据查询
  61. const { data, isLoading, refetch } = useQuery({
  62. queryKey: ['delivery-addresses', searchParams],
  63. queryFn: async () => {
  64. const res = await deliveryAddressClient.$get({
  65. query: {
  66. page: searchParams.page,
  67. pageSize: searchParams.limit,
  68. keyword: searchParams.search,
  69. ...(searchParams.userId && { userId: searchParams.userId }),
  70. }
  71. });
  72. if (res.status !== 200) throw new Error('获取收货地址列表失败');
  73. return await res.json();
  74. }
  75. });
  76. // 创建地址
  77. const createMutation = useMutation({
  78. mutationFn: async (data: any) => {
  79. const res = await deliveryAddressClient.$post({ json: data });
  80. if (res.status !== 201) throw new Error('创建失败');
  81. return await res.json();
  82. },
  83. onSuccess: () => {
  84. toast.success('收货地址创建成功');
  85. setIsModalOpen(false);
  86. refetch();
  87. createForm.reset();
  88. },
  89. onError: (error) => {
  90. toast.error(error.message || '创建失败');
  91. }
  92. });
  93. // 更新地址
  94. const updateMutation = useMutation({
  95. mutationFn: async ({ id, data }: { id: number; data: any }) => {
  96. const res = await deliveryAddressClient[':id']['$put']({
  97. param: { id },
  98. json: data,
  99. });
  100. if (res.status !== 200) throw new Error('更新失败');
  101. return await res.json();
  102. },
  103. onSuccess: () => {
  104. toast.success('收货地址更新成功');
  105. setIsModalOpen(false);
  106. refetch();
  107. },
  108. onError: (error) => {
  109. toast.error(error.message || '更新失败');
  110. }
  111. });
  112. // 删除地址
  113. const deleteMutation = useMutation({
  114. mutationFn: async (id: number) => {
  115. const res = await deliveryAddressClient[':id']['$delete']({
  116. param: { id },
  117. });
  118. if (res.status !== 204) throw new Error('删除失败');
  119. },
  120. onSuccess: () => {
  121. toast.success('收货地址删除成功');
  122. setDeleteDialogOpen(false);
  123. refetch();
  124. },
  125. onError: (error) => {
  126. toast.error(error.message || '删除失败');
  127. }
  128. });
  129. // 业务逻辑函数
  130. const handleSearch = () => {
  131. setSearchParams(prev => ({ ...prev, page: 1 }));
  132. };
  133. const handleCreateAddress = () => {
  134. setIsCreateForm(true);
  135. setEditingAddress(null);
  136. createForm.reset();
  137. setIsModalOpen(true);
  138. };
  139. const handleEditAddress = (address: DeliveryAddress) => {
  140. setIsCreateForm(false);
  141. setEditingAddress(address);
  142. updateForm.reset({
  143. name: address.name,
  144. phone: address.phone,
  145. address: address.address,
  146. receiverProvince: address.receiverProvince,
  147. receiverCity: address.receiverCity,
  148. receiverDistrict: address.receiverDistrict,
  149. receiverTown: address.receiverTown,
  150. isDefault: address.isDefault,
  151. });
  152. setIsModalOpen(true);
  153. };
  154. const handleDeleteAddress = (id: number) => {
  155. setAddressToDelete(id);
  156. setDeleteDialogOpen(true);
  157. };
  158. const confirmDelete = () => {
  159. if (addressToDelete) {
  160. deleteMutation.mutate(addressToDelete);
  161. }
  162. };
  163. const handleCreateSubmit = (data: any) => {
  164. createMutation.mutate(data);
  165. };
  166. const handleUpdateSubmit = (data: any) => {
  167. if (editingAddress) {
  168. updateMutation.mutate({ id: editingAddress.id, data });
  169. }
  170. };
  171. // 状态显示
  172. const getStatusBadge = (status: DeliveryAddressState) => {
  173. switch (status) {
  174. case DeliveryAddressState.ACTIVE:
  175. return <Badge variant="default" data-testid="status-active">正常</Badge>;
  176. case DeliveryAddressState.DISABLED:
  177. return <Badge variant="secondary" data-testid="status-disabled">禁用</Badge>;
  178. case DeliveryAddressState.DELETED:
  179. return <Badge variant="destructive" data-testid="status-deleted">删除</Badge>;
  180. default:
  181. return <Badge variant="outline" data-testid="status-unknown">未知</Badge>;
  182. }
  183. };
  184. const getIsDefaultBadge = (isDefault: DefaultAddressState) => {
  185. return isDefault === DefaultAddressState.IS_DEFAULT ? (
  186. <Badge variant="default" data-testid="is-default-true">默认</Badge>
  187. ) : (
  188. <Badge variant="outline" data-testid="is-default-false">非默认</Badge>
  189. );
  190. };
  191. // 格式化地址显示
  192. const formatAddressDisplay = (address: DeliveryAddress) => {
  193. const parts = [
  194. address.province?.name,
  195. address.city?.name,
  196. address.district?.name,
  197. address.town?.name,
  198. address.address
  199. ].filter(Boolean);
  200. return parts.join(' ');
  201. };
  202. // 表格加载状态
  203. const tableContent = isLoading ? (
  204. <Card>
  205. <CardHeader>
  206. <CardTitle>收货地址列表</CardTitle>
  207. <CardDescription>
  208. 加载中...
  209. </CardDescription>
  210. </CardHeader>
  211. <CardContent>
  212. <div className="space-y-3">
  213. {[...Array(5)].map((_, i) => (
  214. <div key={i} className="flex gap-4">
  215. <Skeleton className="h-10 flex-1" />
  216. <Skeleton className="h-10 flex-1" />
  217. <Skeleton className="h-10 flex-1" />
  218. <Skeleton className="h-10 w-20" />
  219. </div>
  220. ))}
  221. </div>
  222. </CardContent>
  223. </Card>
  224. ) : (
  225. <Card>
  226. <CardHeader>
  227. <CardTitle>收货地址列表</CardTitle>
  228. <CardDescription>
  229. 共 {data?.data?.total || 0} 条收货地址记录
  230. </CardDescription>
  231. </CardHeader>
  232. <CardContent>
  233. <Table>
  234. <TableHeader>
  235. <TableRow>
  236. <TableHead>用户</TableHead>
  237. <TableHead>收货人</TableHead>
  238. <TableHead>手机号</TableHead>
  239. <TableHead>地址</TableHead>
  240. <TableHead>状态</TableHead>
  241. <TableHead>默认地址</TableHead>
  242. <TableHead>创建时间</TableHead>
  243. <TableHead>操作</TableHead>
  244. </TableRow>
  245. </TableHeader>
  246. <TableBody>
  247. {data?.data?.list?.map((address: DeliveryAddress) => (
  248. <TableRow key={address.id} data-testid={`address-row-${address.id}`}>
  249. <TableCell data-testid={`address-user-${address.id}`}>
  250. <div className="flex items-center gap-2">
  251. <MapPin className="h-4 w-4 text-muted-foreground" />
  252. <span>{address.user?.name || '未知用户'}</span>
  253. </div>
  254. </TableCell>
  255. <TableCell data-testid={`address-name-${address.id}`}>{address.name}</TableCell>
  256. <TableCell data-testid={`address-phone-${address.id}`}>{address.phone}</TableCell>
  257. <TableCell>
  258. <div className="max-w-xs truncate">
  259. {formatAddressDisplay(address)}
  260. </div>
  261. </TableCell>
  262. <TableCell>{getStatusBadge(address.state)}</TableCell>
  263. <TableCell>{getIsDefaultBadge(address.isDefault)}</TableCell>
  264. <TableCell>
  265. {address.createdAt ? format(new Date(address.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN }) : '-'}
  266. </TableCell>
  267. <TableCell>
  268. <div className="flex gap-2">
  269. <Button
  270. variant="outline"
  271. size="sm"
  272. onClick={() => handleEditAddress(address)}
  273. data-testid="edit-address-button"
  274. >
  275. <Edit className="h-4 w-4" />
  276. </Button>
  277. <Button
  278. variant="outline"
  279. size="sm"
  280. onClick={() => handleDeleteAddress(address.id)}
  281. data-testid="delete-address-button"
  282. >
  283. <Trash2 className="h-4 w-4" />
  284. </Button>
  285. </div>
  286. </TableCell>
  287. </TableRow>
  288. ))}
  289. </TableBody>
  290. </Table>
  291. {data?.data?.total && data.data.total > 0 && (
  292. <div className="mt-4">
  293. <DataTablePagination
  294. currentPage={searchParams.page}
  295. pageSize={searchParams.limit}
  296. totalCount={data.data.total}
  297. onPageChange={(page) => setSearchParams(prev => ({ ...prev, page }))}
  298. onPageSizeChange={(limit) => setSearchParams(prev => ({ ...prev, limit, page: 1 }))}
  299. />
  300. </div>
  301. )}
  302. </CardContent>
  303. </Card>
  304. );
  305. return (
  306. <div className="space-y-4">
  307. {/* 页面标题 */}
  308. <div className="flex justify-between items-center">
  309. <div>
  310. <h1 className="text-2xl font-bold">用户收货地址</h1>
  311. <p className="text-sm text-muted-foreground">管理用户的收货地址信息</p>
  312. </div>
  313. <Button onClick={handleCreateAddress}>
  314. <Plus className="mr-2 h-4 w-4" />
  315. 创建收货地址
  316. </Button>
  317. </div>
  318. {/* 搜索区域 */}
  319. <Card>
  320. <CardHeader>
  321. <CardTitle>搜索筛选</CardTitle>
  322. <CardDescription>根据条件筛选收货地址</CardDescription>
  323. </CardHeader>
  324. <CardContent>
  325. <div className="flex gap-4">
  326. <div className="flex-1">
  327. <div className="relative">
  328. <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  329. <Input
  330. placeholder="搜索姓名、手机号、地址..."
  331. value={searchParams.search}
  332. onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
  333. className="pl-8"
  334. />
  335. </div>
  336. </div>
  337. <div className="w-64">
  338. <UserSelector
  339. value={searchParams.userId}
  340. onChange={(value) => setSearchParams(prev => ({ ...prev, userId: value }))}
  341. placeholder="选择用户"
  342. data-testid="search-user-selector"
  343. />
  344. </div>
  345. <Button onClick={handleSearch}>搜索</Button>
  346. </div>
  347. </CardContent>
  348. </Card>
  349. {/* 数据表格 */}
  350. {tableContent}
  351. {/* 创建/编辑模态框 */}
  352. <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
  353. <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
  354. <DialogHeader>
  355. <DialogTitle>
  356. {isCreateForm ? '创建收货地址' : '编辑收货地址'}
  357. </DialogTitle>
  358. <DialogDescription>
  359. {isCreateForm ? '创建一个新的收货地址' : '编辑现有收货地址信息'}
  360. </DialogDescription>
  361. </DialogHeader>
  362. {isCreateForm ? (
  363. <Form {...createForm}>
  364. <form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
  365. <FormField
  366. control={createForm.control}
  367. name="userId"
  368. render={({ field }) => (
  369. <FormItem>
  370. <FormLabel>用户<span className="text-red-500 ml-1">*</span></FormLabel>
  371. <FormControl>
  372. <UserSelector
  373. value={field.value}
  374. onChange={field.onChange}
  375. placeholder="选择用户"
  376. data-testid="create-user-selector"
  377. />
  378. </FormControl>
  379. <FormMessage />
  380. </FormItem>
  381. )}
  382. />
  383. <div className="grid grid-cols-2 gap-4">
  384. <FormField
  385. control={createForm.control}
  386. name="name"
  387. render={({ field }) => (
  388. <FormItem>
  389. <FormLabel>收货人姓名<span className="text-red-500 ml-1">*</span></FormLabel>
  390. <FormControl>
  391. <Input placeholder="请输入收货人姓名" {...field} />
  392. </FormControl>
  393. <FormMessage />
  394. </FormItem>
  395. )}
  396. />
  397. <FormField
  398. control={createForm.control}
  399. name="phone"
  400. render={({ field }) => (
  401. <FormItem>
  402. <FormLabel>手机号<span className="text-red-500 ml-1">*</span></FormLabel>
  403. <FormControl>
  404. <Input placeholder="请输入手机号" {...field} />
  405. </FormControl>
  406. <FormMessage />
  407. </FormItem>
  408. )}
  409. />
  410. </div>
  411. <FormField
  412. control={createForm.control}
  413. name="address"
  414. render={({ field }) => (
  415. <FormItem>
  416. <FormLabel>详细地址<span className="text-red-500 ml-1">*</span></FormLabel>
  417. <FormControl>
  418. <Input placeholder="请输入详细地址" {...field} />
  419. </FormControl>
  420. <FormMessage />
  421. </FormItem>
  422. )}
  423. />
  424. <div className="space-y-2">
  425. <FormLabel>四级地址选择<span className="text-red-500 ml-1">*</span></FormLabel>
  426. <AreaSelect4Level
  427. provinceValue={createForm.watch('receiverProvince') || 0}
  428. cityValue={createForm.watch('receiverCity') || 0}
  429. districtValue={createForm.watch('receiverDistrict') || 0}
  430. townValue={createForm.watch('receiverTown') || 0}
  431. onProvinceChange={(value) => createForm.setValue('receiverProvince', value)}
  432. onCityChange={(value) => createForm.setValue('receiverCity', value)}
  433. onDistrictChange={(value) => createForm.setValue('receiverDistrict', value)}
  434. onTownChange={(value) => createForm.setValue('receiverTown', value)}
  435. showLabels={false}
  436. />
  437. </div>
  438. <FormField
  439. control={createForm.control}
  440. name="isDefault"
  441. render={({ field }) => (
  442. <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
  443. <div className="space-y-0.5">
  444. <FormLabel className="text-base">设为默认地址</FormLabel>
  445. <FormDescription>将此地址设为用户的默认收货地址</FormDescription>
  446. </div>
  447. <FormControl>
  448. <Switch
  449. checked={field.value === 1}
  450. onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
  451. />
  452. </FormControl>
  453. </FormItem>
  454. )}
  455. />
  456. <DialogFooter>
  457. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  458. 取消
  459. </Button>
  460. <Button type="submit" disabled={createMutation.isPending}>
  461. 创建
  462. </Button>
  463. </DialogFooter>
  464. </form>
  465. </Form>
  466. ) : (
  467. <Form {...updateForm}>
  468. <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
  469. <FormField
  470. control={updateForm.control}
  471. name="name"
  472. render={({ field }) => (
  473. <FormItem>
  474. <FormLabel>收货人姓名<span className="text-red-500 ml-1">*</span></FormLabel>
  475. <FormControl>
  476. <Input placeholder="请输入收货人姓名" {...field} />
  477. </FormControl>
  478. <FormMessage />
  479. </FormItem>
  480. )}
  481. />
  482. <FormField
  483. control={updateForm.control}
  484. name="phone"
  485. render={({ field }) => (
  486. <FormItem>
  487. <FormLabel>手机号<span className="text-red-500 ml-1">*</span></FormLabel>
  488. <FormControl>
  489. <Input placeholder="请输入手机号" {...field} />
  490. </FormControl>
  491. <FormMessage />
  492. </FormItem>
  493. )}
  494. />
  495. <FormField
  496. control={updateForm.control}
  497. name="address"
  498. render={({ field }) => (
  499. <FormItem>
  500. <FormLabel>详细地址<span className="text-red-500 ml-1">*</span></FormLabel>
  501. <FormControl>
  502. <Input placeholder="请输入详细地址" {...field} />
  503. </FormControl>
  504. <FormMessage />
  505. </FormItem>
  506. )}
  507. />
  508. <div className="space-y-2">
  509. <FormLabel>四级地址选择<span className="text-red-500 ml-1">*</span></FormLabel>
  510. <AreaSelect4Level
  511. provinceValue={updateForm.watch('receiverProvince') || 0}
  512. cityValue={updateForm.watch('receiverCity') || 0}
  513. districtValue={updateForm.watch('receiverDistrict') || 0}
  514. townValue={updateForm.watch('receiverTown') || 0}
  515. onProvinceChange={(value) => updateForm.setValue('receiverProvince', value)}
  516. onCityChange={(value) => updateForm.setValue('receiverCity', value)}
  517. onDistrictChange={(value) => updateForm.setValue('receiverDistrict', value)}
  518. onTownChange={(value) => updateForm.setValue('receiverTown', value)}
  519. showLabels={false}
  520. />
  521. </div>
  522. <FormField
  523. control={updateForm.control}
  524. name="isDefault"
  525. render={({ field }) => (
  526. <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
  527. <div className="space-y-0.5">
  528. <FormLabel className="text-base">设为默认地址</FormLabel>
  529. <FormDescription>将此地址设为用户的默认收货地址</FormDescription>
  530. </div>
  531. <FormControl>
  532. <Switch
  533. checked={field.value === 1}
  534. onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
  535. />
  536. </FormControl>
  537. </FormItem>
  538. )}
  539. />
  540. <DialogFooter>
  541. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  542. 取消
  543. </Button>
  544. <Button type="submit" disabled={updateMutation.isPending}>
  545. 更新
  546. </Button>
  547. </DialogFooter>
  548. </form>
  549. </Form>
  550. )}
  551. </DialogContent>
  552. </Dialog>
  553. {/* 删除确认对话框 */}
  554. <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
  555. <DialogContent>
  556. <DialogHeader>
  557. <DialogTitle>确认删除</DialogTitle>
  558. <DialogDescription>
  559. 确定要删除这个收货地址吗?此操作无法撤销。
  560. </DialogDescription>
  561. </DialogHeader>
  562. <DialogFooter>
  563. <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
  564. 取消
  565. </Button>
  566. <Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isPending}>
  567. 删除
  568. </Button>
  569. </DialogFooter>
  570. </DialogContent>
  571. </Dialog>
  572. </div>
  573. );
  574. };