AreasTreePage.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. import React from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Button } from '@/client/components/ui/button';
  4. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  5. import { Plus } from 'lucide-react';
  6. import { useState } from 'react';
  7. import { areaClient } from '@/client/api';
  8. import type { InferResponseType, InferRequestType } from 'hono/client';
  9. import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  10. import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/client/components/ui/alert-dialog';
  11. import { AreaForm } from '../components/AreaForm';
  12. import { AreaTreeAsync } from '../components/AreaTreeAsync';
  13. import type { CreateAreaInput, UpdateAreaInput } from '@d8d/geo-areas/schemas';
  14. import { toast } from 'sonner';
  15. // 类型提取规范
  16. type AreaResponse = InferResponseType<typeof areaClient.$get, 200>['data'][0];
  17. type CreateAreaRequest = InferRequestType<typeof areaClient.$post>['json'];
  18. type UpdateAreaRequest = InferRequestType<typeof areaClient[':id']['$put']>['json'];
  19. // 树形节点类型
  20. interface AreaNode {
  21. id: number;
  22. name: string;
  23. code: string;
  24. level: number;
  25. parentId: number | null;
  26. isDisabled: number;
  27. children?: AreaNode[];
  28. }
  29. // 统一操作处理函数
  30. const handleOperation = async (operation: () => Promise<any>) => {
  31. try {
  32. await operation();
  33. // toast.success('操作成功');
  34. console.log('操作成功');
  35. } catch (error) {
  36. console.error('操作失败:', error);
  37. // toast.error('操作失败,请重试');
  38. throw error;
  39. }
  40. };
  41. export const AreasTreePage: React.FC = () => {
  42. const queryClient = useQueryClient();
  43. const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
  44. const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
  45. const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
  46. const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
  47. const [isStatusDialogOpen, setIsStatusDialogOpen] = useState(false);
  48. const [selectedArea, setSelectedArea] = useState<AreaResponse | null>(null);
  49. const [isAddChildDialogOpen, setIsAddChildDialogOpen] = useState(false);
  50. const [parentAreaForChild, setParentAreaForChild] = useState<AreaNode | null>(null);
  51. // 查询省级数据(异步加载)
  52. const { data: provinceData, isLoading: isProvinceLoading } = useQuery({
  53. queryKey: ['areas-tree-province'],
  54. queryFn: async () => {
  55. const res = await areaClient.$get({
  56. query: {
  57. page: 1,
  58. pageSize: 100 ,
  59. filters: JSON.stringify({ level: 1}),
  60. sortBy: 'id',
  61. sortOrder: 'ASC'
  62. }
  63. });
  64. if (res.status !== 200) throw new Error('获取省级数据失败');
  65. const response = await res.json();
  66. return response.data;
  67. },
  68. staleTime: 5 * 60 * 1000,
  69. gcTime: 10 * 60 * 1000,
  70. });
  71. // 创建省市区
  72. const createMutation = useMutation({
  73. mutationFn: async (data: CreateAreaRequest) => {
  74. await handleOperation(async () => {
  75. const res = await areaClient.$post({ json: data });
  76. if (res.status !== 201) throw new Error('创建省市区失败');
  77. });
  78. },
  79. onSuccess: (_, variables) => {
  80. // 更新根级缓存
  81. queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
  82. // 如果创建的是子节点,更新父节点的子树缓存
  83. if (variables.parentId) {
  84. queryClient.invalidateQueries({ queryKey: ['areas-subtree', variables.parentId] });
  85. }
  86. // 显示成功提示
  87. toast.success('省市区创建成功');
  88. // 关闭对话框
  89. setIsCreateDialogOpen(false);
  90. // 如果是创建子节点,还需要关闭子节点对话框
  91. if (variables.parentId) {
  92. setIsAddChildDialogOpen(false);
  93. setParentAreaForChild(null);
  94. }
  95. },
  96. onError: () => {
  97. toast.error('创建失败,请重试');
  98. }
  99. });
  100. // 更新省市区
  101. const updateMutation = useMutation({
  102. mutationFn: async ({ id, data }: { id: number; data: UpdateAreaRequest }) => {
  103. await handleOperation(async () => {
  104. const res = await areaClient[':id'].$put({
  105. param: { id },
  106. json: data
  107. });
  108. if (res.status !== 200) throw new Error('更新省市区失败');
  109. });
  110. },
  111. onSuccess: () => {
  112. // 更新根级缓存
  113. queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
  114. // 如果更新的节点有父节点,更新父节点的子树缓存
  115. if (selectedArea?.parentId) {
  116. queryClient.invalidateQueries({ queryKey: ['areas-subtree', selectedArea.parentId] });
  117. }
  118. // 显示成功提示
  119. toast.success('省市区更新成功');
  120. setIsEditDialogOpen(false);
  121. setSelectedArea(null);
  122. },
  123. onError: () => {
  124. toast.error('更新失败,请重试');
  125. }
  126. });
  127. // 删除省市区
  128. const deleteMutation = useMutation({
  129. mutationFn: async (id: number) => {
  130. await handleOperation(async () => {
  131. const res = await areaClient[':id'].$delete({
  132. param: { id }
  133. });
  134. if (res.status !== 204) throw new Error('删除省市区失败');
  135. });
  136. },
  137. onSuccess: () => {
  138. // 更新根级缓存
  139. queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
  140. // 如果删除的节点有父节点,更新父节点的子树缓存
  141. if (selectedArea?.parentId) {
  142. queryClient.invalidateQueries({ queryKey: ['areas-subtree', selectedArea.parentId] });
  143. }
  144. // 显示成功提示
  145. toast.success('省市区删除成功');
  146. setIsDeleteDialogOpen(false);
  147. setSelectedArea(null);
  148. },
  149. onError: () => {
  150. toast.error('删除失败,请重试');
  151. }
  152. });
  153. // 启用/禁用省市区
  154. const toggleStatusMutation = useMutation({
  155. mutationFn: async ({ id, isDisabled }: { id: number; isDisabled: number }) => {
  156. await handleOperation(async () => {
  157. const res = await areaClient[':id'].$put({
  158. param: { id },
  159. json: { isDisabled }
  160. });
  161. if (res.status !== 200) throw new Error('更新省市区状态失败');
  162. });
  163. },
  164. onSuccess: () => {
  165. // 更新根级缓存
  166. queryClient.invalidateQueries({ queryKey: ['areas-tree-province'] });
  167. // 如果状态切换的节点有父节点,更新父节点的子树缓存
  168. if (selectedArea?.parentId) {
  169. queryClient.invalidateQueries({ queryKey: ['areas-subtree', selectedArea.parentId] });
  170. }
  171. // 显示成功提示
  172. toast.success(`省市区${selectedArea?.isDisabled === 0 ? '禁用' : '启用'}成功`);
  173. setIsStatusDialogOpen(false);
  174. setSelectedArea(null);
  175. },
  176. onError: () => {
  177. toast.error('状态切换失败,请重试');
  178. }
  179. });
  180. // 处理创建省市区
  181. const handleCreateArea = async (data: CreateAreaInput | UpdateAreaInput) => {
  182. await createMutation.mutateAsync(data as CreateAreaInput);
  183. };
  184. // 处理更新省市区
  185. const handleUpdateArea = async (data: UpdateAreaInput) => {
  186. if (!selectedArea) return;
  187. await updateMutation.mutateAsync({ id: selectedArea.id, data });
  188. };
  189. // 处理删除省市区
  190. const handleDeleteArea = async () => {
  191. if (!selectedArea) return;
  192. await deleteMutation.mutateAsync(selectedArea.id);
  193. };
  194. // 处理启用/禁用省市区
  195. const handleToggleStatus = async (isDisabled: number) => {
  196. if (!selectedArea) return;
  197. await toggleStatusMutation.mutateAsync({ id: selectedArea.id, isDisabled });
  198. };
  199. // 处理新增子节点
  200. const handleAddChild = (area: AreaNode) => {
  201. setParentAreaForChild(area);
  202. setIsAddChildDialogOpen(true);
  203. };
  204. // 处理创建子节点
  205. const handleCreateChildArea = async (data: CreateAreaInput | UpdateAreaInput) => {
  206. await createMutation.mutateAsync(data as CreateAreaInput);
  207. };
  208. // 打开编辑对话框
  209. const handleEdit = (area: AreaNode) => {
  210. // 将 AreaNode 转换为 AreaResponse
  211. const areaResponse: AreaResponse = {
  212. ...area,
  213. isDeleted: 0,
  214. createdAt: new Date().toISOString(),
  215. updatedAt: new Date().toISOString(),
  216. createdBy: null,
  217. updatedBy: null
  218. };
  219. setSelectedArea(areaResponse);
  220. setIsEditDialogOpen(true);
  221. };
  222. // 打开删除对话框
  223. const handleDelete = (area: AreaNode) => {
  224. // 将 AreaNode 转换为 AreaResponse
  225. const areaResponse: AreaResponse = {
  226. ...area,
  227. isDeleted: 0,
  228. createdAt: new Date().toISOString(),
  229. updatedAt: new Date().toISOString(),
  230. createdBy: null,
  231. updatedBy: null
  232. };
  233. setSelectedArea(areaResponse);
  234. setIsDeleteDialogOpen(true);
  235. };
  236. // 打开状态切换对话框
  237. const handleToggleStatusDialog = (area: AreaNode) => {
  238. // 将 AreaNode 转换为 AreaResponse
  239. const areaResponse: AreaResponse = {
  240. ...area,
  241. isDeleted: 0,
  242. createdAt: new Date().toISOString(),
  243. updatedAt: new Date().toISOString(),
  244. createdBy: null,
  245. updatedBy: null
  246. };
  247. setSelectedArea(areaResponse);
  248. setIsStatusDialogOpen(true);
  249. };
  250. // 切换节点展开状态
  251. const handleToggleNode = (nodeId: number) => {
  252. setExpandedNodes(prev => {
  253. const newSet = new Set(prev);
  254. if (newSet.has(nodeId)) {
  255. newSet.delete(nodeId);
  256. } else {
  257. newSet.add(nodeId);
  258. }
  259. return newSet;
  260. });
  261. };
  262. return (
  263. <div className="space-y-6">
  264. <div className="flex items-center justify-between">
  265. <div>
  266. <h1 className="text-3xl font-bold tracking-tight">省市区树形管理</h1>
  267. <p className="text-muted-foreground">
  268. 异步加载树形结构,高效管理省市区数据
  269. </p>
  270. </div>
  271. <Button onClick={() => setIsCreateDialogOpen(true)}>
  272. <Plus className="mr-2 h-4 w-4" />
  273. 新增省
  274. </Button>
  275. </div>
  276. <Card>
  277. <CardHeader>
  278. <CardTitle>省市区树形结构</CardTitle>
  279. <CardDescription>
  280. 以树形结构查看和管理省市区层级关系,默认只加载省级数据
  281. </CardDescription>
  282. </CardHeader>
  283. <CardContent>
  284. {/* 树形视图 */}
  285. {isProvinceLoading ? (
  286. <div className="text-center py-8">
  287. 加载中...
  288. </div>
  289. ) : !provinceData || provinceData.length === 0 ? (
  290. <div className="text-center py-8">
  291. 暂无数据
  292. </div>
  293. ) : (
  294. <AreaTreeAsync
  295. areas={provinceData}
  296. expandedNodes={expandedNodes}
  297. onToggleNode={handleToggleNode}
  298. onEdit={handleEdit}
  299. onDelete={handleDelete}
  300. onToggleStatus={handleToggleStatusDialog}
  301. onAddChild={handleAddChild}
  302. />
  303. )}
  304. </CardContent>
  305. </Card>
  306. {/* 创建省市区对话框 */}
  307. <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
  308. <DialogContent className="max-w-2xl">
  309. <DialogHeader>
  310. <DialogTitle>新增省</DialogTitle>
  311. <DialogDescription>
  312. 填写省信息
  313. </DialogDescription>
  314. </DialogHeader>
  315. <AreaForm
  316. onSubmit={handleCreateArea}
  317. isLoading={createMutation.isPending}
  318. onCancel={() => setIsCreateDialogOpen(false)}
  319. smartLevel={1} // 默认设置为省级
  320. />
  321. </DialogContent>
  322. </Dialog>
  323. {/* 编辑省市区对话框 */}
  324. <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
  325. <DialogContent className="max-w-2xl">
  326. <DialogHeader>
  327. <DialogTitle>编辑省市区</DialogTitle>
  328. <DialogDescription>
  329. 修改省市区信息
  330. </DialogDescription>
  331. </DialogHeader>
  332. {selectedArea && (
  333. <AreaForm
  334. area={{
  335. id: selectedArea.id,
  336. parentId: selectedArea.parentId || 0,
  337. name: selectedArea.name,
  338. level: selectedArea.level,
  339. code: selectedArea.code,
  340. isDisabled: selectedArea.isDisabled
  341. }}
  342. onSubmit={handleUpdateArea}
  343. isLoading={updateMutation.isPending}
  344. onCancel={() => {
  345. setIsEditDialogOpen(false);
  346. setSelectedArea(null);
  347. }}
  348. />
  349. )}
  350. </DialogContent>
  351. </Dialog>
  352. {/* 新增子节点对话框 */}
  353. <Dialog open={isAddChildDialogOpen} onOpenChange={setIsAddChildDialogOpen}>
  354. <DialogContent className="max-w-2xl">
  355. <DialogHeader>
  356. <DialogTitle>
  357. {parentAreaForChild?.level === 1 ? '新增市' : '新增区'}
  358. </DialogTitle>
  359. <DialogDescription>
  360. {parentAreaForChild?.level === 1
  361. ? `在省份 "${parentAreaForChild?.name}" 下新增市`
  362. : `在城市 "${parentAreaForChild?.name}" 下新增区/县`}
  363. </DialogDescription>
  364. </DialogHeader>
  365. <AreaForm
  366. onSubmit={handleCreateChildArea}
  367. isLoading={createMutation.isPending}
  368. onCancel={() => {
  369. setIsAddChildDialogOpen(false);
  370. setParentAreaForChild(null);
  371. }}
  372. smartLevel={(parentAreaForChild?.level ?? 0) + 1}
  373. smartParentId={parentAreaForChild?.id}
  374. />
  375. </DialogContent>
  376. </Dialog>
  377. {/* 删除确认对话框 */}
  378. <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
  379. <AlertDialogContent>
  380. <AlertDialogHeader>
  381. <AlertDialogTitle>确认删除</AlertDialogTitle>
  382. <AlertDialogDescription>
  383. 确定要删除省市区 "{selectedArea?.name}" 吗?此操作不可恢复。
  384. </AlertDialogDescription>
  385. </AlertDialogHeader>
  386. <AlertDialogFooter>
  387. <AlertDialogCancel>取消</AlertDialogCancel>
  388. <AlertDialogAction
  389. onClick={handleDeleteArea}
  390. disabled={deleteMutation.isPending}
  391. >
  392. {deleteMutation.isPending ? '删除中...' : '确认删除'}
  393. </AlertDialogAction>
  394. </AlertDialogFooter>
  395. </AlertDialogContent>
  396. </AlertDialog>
  397. {/* 状态切换确认对话框 */}
  398. <AlertDialog open={isStatusDialogOpen} onOpenChange={setIsStatusDialogOpen}>
  399. <AlertDialogContent>
  400. <AlertDialogHeader>
  401. <AlertDialogTitle>
  402. {selectedArea?.isDisabled === 0 ? '禁用' : '启用'}确认
  403. </AlertDialogTitle>
  404. <AlertDialogDescription>
  405. 确定要{selectedArea?.isDisabled === 0 ? '禁用' : '启用'}省市区 "{selectedArea?.name}" 吗?
  406. </AlertDialogDescription>
  407. </AlertDialogHeader>
  408. <AlertDialogFooter>
  409. <AlertDialogCancel>取消</AlertDialogCancel>
  410. <AlertDialogAction
  411. onClick={() => handleToggleStatus(selectedArea?.isDisabled === 0 ? 1 : 0)}
  412. disabled={toggleStatusMutation.isPending}
  413. >
  414. {toggleStatusMutation.isPending ? '处理中...' : '确认'}
  415. </AlertDialogAction>
  416. </AlertDialogFooter>
  417. </AlertDialogContent>
  418. </AlertDialog>
  419. </div>
  420. );
  421. };