GoodsCategories.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import { useState } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useForm } from 'react-hook-form';
  4. import { zodResolver } from '@hookform/resolvers/zod';
  5. import { z } from 'zod';
  6. import { Plus, Search, Edit, Trash2, Folder } from 'lucide-react';
  7. import { toast } from 'sonner';
  8. import { Button } from '@/client/components/ui/button';
  9. import { Input } from '@/client/components/ui/input';
  10. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
  11. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
  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 { Badge } from '@/client/components/ui/badge';
  15. import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
  16. import { ImageSelector } from '@/client/admin-shadcn/components/ImageSelector';
  17. import { goodsCategoryClient } from '@/client/api';
  18. import { CreateGoodsCategoryDto, UpdateGoodsCategoryDto } from '@/server/modules/goods/goods-category.schema';
  19. import type { InferRequestType, InferResponseType } from 'hono/client';
  20. // 类型定义
  21. type CreateRequest = InferRequestType<typeof goodsCategoryClient.$post>['json'];
  22. type UpdateRequest = InferRequestType<typeof goodsCategoryClient[':id']['$put']>['json'];
  23. type GoodsCategoryResponse = InferResponseType<typeof goodsCategoryClient.$get, 200>['data'][0];
  24. // 表单Schema直接使用后端定义
  25. const createFormSchema = CreateGoodsCategoryDto;
  26. const updateFormSchema = UpdateGoodsCategoryDto;
  27. export const GoodsCategories = () => {
  28. // 状态管理
  29. const [searchParams, setSearchParams] = useState({ page: 1, limit: 10, search: '' });
  30. const [isModalOpen, setIsModalOpen] = useState(false);
  31. const [editingCategory, setEditingCategory] = useState<GoodsCategoryResponse | null>(null);
  32. const [isCreateForm, setIsCreateForm] = useState(true);
  33. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  34. const [categoryToDelete, setCategoryToDelete] = useState<number | null>(null);
  35. // 表单实例
  36. const createForm = useForm<CreateRequest>({
  37. resolver: zodResolver(createFormSchema),
  38. defaultValues: {
  39. name: '',
  40. parentId: 0,
  41. imageFileId: null,
  42. level: 0,
  43. state: 1,
  44. },
  45. });
  46. const updateForm = useForm<UpdateRequest>({
  47. resolver: zodResolver(updateFormSchema),
  48. });
  49. // 数据查询
  50. const { data, isLoading, refetch } = useQuery({
  51. queryKey: ['goods-categories', searchParams],
  52. queryFn: async () => {
  53. const res = await goodsCategoryClient.$get({
  54. query: {
  55. page: searchParams.page,
  56. pageSize: searchParams.limit,
  57. keyword: searchParams.search,
  58. filters: JSON.stringify({state:[1,2]})
  59. },
  60. });
  61. if (res.status !== 200) throw new Error('获取商品分类列表失败');
  62. return await res.json();
  63. },
  64. });
  65. // 处理搜索
  66. const handleSearch = (e: React.FormEvent) => {
  67. e.preventDefault();
  68. setSearchParams(prev => ({ ...prev, page: 1 }));
  69. };
  70. // 处理创建商品分类
  71. const handleCreateCategory = () => {
  72. setIsCreateForm(true);
  73. setEditingCategory(null);
  74. createForm.reset({
  75. name: '',
  76. parentId: 0,
  77. imageFileId: null,
  78. level: 0,
  79. state: 1,
  80. });
  81. setIsModalOpen(true);
  82. };
  83. // 处理编辑商品分类
  84. const handleEditCategory = (category: GoodsCategoryResponse) => {
  85. setIsCreateForm(false);
  86. setEditingCategory(category);
  87. updateForm.reset({
  88. name: category.name,
  89. parentId: category.parentId,
  90. imageFileId: category.imageFileId,
  91. level: category.level,
  92. state: category.state,
  93. });
  94. setIsModalOpen(true);
  95. };
  96. // 处理删除商品分类
  97. const handleDeleteCategory = (id: number) => {
  98. setCategoryToDelete(id);
  99. setDeleteDialogOpen(true);
  100. };
  101. // 确认删除
  102. const confirmDelete = async () => {
  103. if (!categoryToDelete) return;
  104. try {
  105. const res = await goodsCategoryClient[':id']['$delete']({
  106. param: { id: categoryToDelete.toString() },
  107. });
  108. if (res.status === 204) {
  109. toast.success('删除成功');
  110. setDeleteDialogOpen(false);
  111. refetch();
  112. } else {
  113. throw new Error('删除失败');
  114. }
  115. } catch (error) {
  116. toast.error('删除失败,请重试');
  117. }
  118. };
  119. // 处理表单提交
  120. const handleCreateSubmit = async (data: CreateRequest) => {
  121. try {
  122. const res = await goodsCategoryClient.$post({ json: data });
  123. if (res.status !== 201) throw new Error('创建失败');
  124. toast.success('创建成功');
  125. setIsModalOpen(false);
  126. refetch();
  127. } catch (error) {
  128. toast.error('创建失败,请重试');
  129. }
  130. };
  131. const handleUpdateSubmit = async (data: UpdateRequest) => {
  132. if (!editingCategory) return;
  133. try {
  134. const res = await goodsCategoryClient[':id']['$put']({
  135. param: { id: editingCategory.id.toString() },
  136. json: data,
  137. });
  138. if (res.status !== 200) throw new Error('更新失败');
  139. toast.success('更新成功');
  140. setIsModalOpen(false);
  141. refetch();
  142. } catch (error) {
  143. toast.error('更新失败,请重试');
  144. }
  145. };
  146. // 获取状态显示文本
  147. const getStateText = (state: number) => {
  148. return state === 1 ? '可用' : '不可用';
  149. };
  150. const getStateBadgeVariant = (state: number) => {
  151. return state === 1 ? 'default' : 'secondary';
  152. };
  153. // 格式化日期
  154. const formatDate = (dateString: string) => {
  155. return new Date(dateString).toLocaleDateString('zh-CN');
  156. };
  157. // 渲染骨架屏
  158. if (isLoading) {
  159. return (
  160. <div className="space-y-4">
  161. <div className="flex justify-between items-center">
  162. <h1 className="text-2xl font-bold">商品分类管理</h1>
  163. <Button disabled>
  164. <Plus className="mr-2 h-4 w-4" />
  165. 创建分类
  166. </Button>
  167. </div>
  168. <Card>
  169. <CardHeader>
  170. <div className="h-6 w-1/4 bg-gray-200 rounded animate-pulse" />
  171. </CardHeader>
  172. <CardContent>
  173. <div className="space-y-2">
  174. <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
  175. <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
  176. <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
  177. </div>
  178. </CardContent>
  179. </Card>
  180. </div>
  181. );
  182. }
  183. return (
  184. <div className="space-y-4">
  185. {/* 页面标题区域 */}
  186. <div className="flex justify-between items-center">
  187. <div>
  188. <h1 className="text-2xl font-bold">商品分类管理</h1>
  189. <p className="text-muted-foreground">管理商品分类信息</p>
  190. </div>
  191. <Button onClick={handleCreateCategory}>
  192. <Plus className="mr-2 h-4 w-4" />
  193. 创建分类
  194. </Button>
  195. </div>
  196. {/* 搜索区域 */}
  197. <Card>
  198. <CardHeader>
  199. <CardTitle>商品分类列表</CardTitle>
  200. <CardDescription>查看和管理所有商品分类</CardDescription>
  201. </CardHeader>
  202. <CardContent>
  203. <form onSubmit={handleSearch} className="flex gap-2 mb-4">
  204. <div className="relative flex-1 max-w-sm">
  205. <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
  206. <Input
  207. placeholder="搜索分类名称..."
  208. value={searchParams.search}
  209. onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
  210. className="pl-8"
  211. />
  212. </div>
  213. <Button type="submit" variant="outline">
  214. 搜索
  215. </Button>
  216. </form>
  217. {/* 数据表格 */}
  218. <div className="rounded-md border">
  219. <Table>
  220. <TableHeader>
  221. <TableRow>
  222. <TableHead>ID</TableHead>
  223. <TableHead>分类名称</TableHead>
  224. <TableHead>上级ID</TableHead>
  225. <TableHead>层级</TableHead>
  226. <TableHead>状态</TableHead>
  227. <TableHead>图片</TableHead>
  228. <TableHead>创建时间</TableHead>
  229. <TableHead className="text-right">操作</TableHead>
  230. </TableRow>
  231. </TableHeader>
  232. <TableBody>
  233. {data?.data.map((category) => (
  234. <TableRow key={category.id}>
  235. <TableCell className="font-medium">{category.id}</TableCell>
  236. <TableCell>
  237. <div className="flex items-center gap-2">
  238. <Folder className="h-4 w-4 text-muted-foreground" />
  239. <span>{category.name}</span>
  240. </div>
  241. </TableCell>
  242. <TableCell>{category.parentId}</TableCell>
  243. <TableCell>{category.level}</TableCell>
  244. <TableCell>
  245. <Badge variant={getStateBadgeVariant(category.state)}>
  246. {getStateText(category.state)}
  247. </Badge>
  248. </TableCell>
  249. <TableCell>
  250. {category.imageFile?.fullUrl ? (
  251. <img
  252. src={category.imageFile.fullUrl}
  253. alt={category.name}
  254. className="w-10 h-10 object-cover rounded"
  255. onError={(e) => {
  256. e.currentTarget.src = '/placeholder.png';
  257. }}
  258. />
  259. ) : (
  260. <span className="text-muted-foreground text-xs">无图片</span>
  261. )}
  262. </TableCell>
  263. <TableCell>{formatDate(category.createdAt)}</TableCell>
  264. <TableCell className="text-right">
  265. <div className="flex justify-end gap-2">
  266. <Button
  267. variant="ghost"
  268. size="icon"
  269. onClick={() => handleEditCategory(category)}
  270. >
  271. <Edit className="h-4 w-4" />
  272. </Button>
  273. <Button
  274. variant="ghost"
  275. size="icon"
  276. onClick={() => handleDeleteCategory(category.id)}
  277. >
  278. <Trash2 className="h-4 w-4" />
  279. </Button>
  280. </div>
  281. </TableCell>
  282. </TableRow>
  283. ))}
  284. </TableBody>
  285. </Table>
  286. </div>
  287. {data?.data.length === 0 && !isLoading && (
  288. <div className="text-center py-8">
  289. <p className="text-muted-foreground">暂无数据</p>
  290. </div>
  291. )}
  292. {/* 分页 */}
  293. <DataTablePagination
  294. currentPage={searchParams.page}
  295. pageSize={searchParams.limit}
  296. totalCount={data?.pagination.total || 0}
  297. onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
  298. />
  299. </CardContent>
  300. </Card>
  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(handleCreateSubmit)} className="space-y-4">
  313. <FormField
  314. control={createForm.control}
  315. name="name"
  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. <Input placeholder="请输入分类名称" {...field} />
  323. </FormControl>
  324. <FormMessage />
  325. </FormItem>
  326. )}
  327. />
  328. <FormField
  329. control={createForm.control}
  330. name="parentId"
  331. render={({ field }) => (
  332. <FormItem>
  333. <FormLabel>上级分类ID</FormLabel>
  334. <FormControl>
  335. <Input
  336. type="number"
  337. placeholder="请输入上级分类ID,0表示顶级分类"
  338. {...field}
  339. onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
  340. />
  341. </FormControl>
  342. <FormDescription>顶级分类请填0</FormDescription>
  343. <FormMessage />
  344. </FormItem>
  345. )}
  346. />
  347. <FormField
  348. control={createForm.control}
  349. name="level"
  350. render={({ field }) => (
  351. <FormItem>
  352. <FormLabel>层级</FormLabel>
  353. <FormControl>
  354. <Input
  355. type="number"
  356. placeholder="请输入层级"
  357. {...field}
  358. onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
  359. />
  360. </FormControl>
  361. <FormDescription>顶级分类为0,依次递增</FormDescription>
  362. <FormMessage />
  363. </FormItem>
  364. )}
  365. />
  366. <FormField
  367. control={createForm.control}
  368. name="state"
  369. render={({ field }) => (
  370. <FormItem>
  371. <FormLabel>状态</FormLabel>
  372. <FormControl>
  373. <select
  374. className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
  375. {...field}
  376. onChange={(e) => field.onChange(parseInt(e.target.value))}
  377. >
  378. <option value={1}>可用</option>
  379. <option value={2}>不可用</option>
  380. </select>
  381. </FormControl>
  382. <FormMessage />
  383. </FormItem>
  384. )}
  385. />
  386. <FormField
  387. control={createForm.control}
  388. name="imageFileId"
  389. render={({ field }) => (
  390. <FormItem>
  391. <FormLabel>分类图片</FormLabel>
  392. <FormControl>
  393. <ImageSelector
  394. value={field.value || undefined}
  395. onChange={(value) => field.onChange(value)}
  396. maxSize={2}
  397. uploadPath="/goods-categories"
  398. uploadButtonText="上传分类图片"
  399. previewSize="medium"
  400. placeholder="选择分类图片"
  401. />
  402. </FormControl>
  403. <FormMessage />
  404. </FormItem>
  405. )}
  406. />
  407. <DialogFooter>
  408. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  409. 取消
  410. </Button>
  411. <Button type="submit">创建</Button>
  412. </DialogFooter>
  413. </form>
  414. </Form>
  415. ) : (
  416. <Form {...updateForm}>
  417. <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
  418. <FormField
  419. control={updateForm.control}
  420. name="name"
  421. render={({ field }) => (
  422. <FormItem>
  423. <FormLabel className="flex items-center">
  424. 分类名称 <span className="text-red-500 ml-1">*</span>
  425. </FormLabel>
  426. <FormControl>
  427. <Input placeholder="请输入分类名称" {...field} />
  428. </FormControl>
  429. <FormMessage />
  430. </FormItem>
  431. )}
  432. />
  433. <FormField
  434. control={updateForm.control}
  435. name="parentId"
  436. render={({ field }) => (
  437. <FormItem>
  438. <FormLabel>上级分类ID</FormLabel>
  439. <FormControl>
  440. <Input
  441. type="number"
  442. placeholder="请输入上级分类ID,0表示顶级分类"
  443. {...field}
  444. onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
  445. />
  446. </FormControl>
  447. <FormDescription>顶级分类请填0</FormDescription>
  448. <FormMessage />
  449. </FormItem>
  450. )}
  451. />
  452. <FormField
  453. control={updateForm.control}
  454. name="level"
  455. render={({ field }) => (
  456. <FormItem>
  457. <FormLabel>层级</FormLabel>
  458. <FormControl>
  459. <Input
  460. type="number"
  461. placeholder="请输入层级"
  462. {...field}
  463. onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
  464. value={field.value ?? ''}
  465. />
  466. </FormControl>
  467. <FormDescription>顶级分类为0,依次递增</FormDescription>
  468. <FormMessage />
  469. </FormItem>
  470. )}
  471. />
  472. <FormField
  473. control={updateForm.control}
  474. name="state"
  475. render={({ field }) => (
  476. <FormItem>
  477. <FormLabel>状态</FormLabel>
  478. <FormControl>
  479. <select
  480. className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
  481. value={field.value ?? 1}
  482. onChange={(e) => field.onChange(parseInt(e.target.value))}
  483. >
  484. <option value={1}>可用</option>
  485. <option value={2}>不可用</option>
  486. </select>
  487. </FormControl>
  488. <FormMessage />
  489. </FormItem>
  490. )}
  491. />
  492. <FormField
  493. control={updateForm.control}
  494. name="imageFileId"
  495. render={({ field }) => (
  496. <FormItem>
  497. <FormLabel>分类图片</FormLabel>
  498. <FormControl>
  499. <ImageSelector
  500. value={field.value || undefined}
  501. onChange={(value) => field.onChange(value)}
  502. maxSize={2}
  503. uploadPath="/goods-categories"
  504. uploadButtonText="上传分类图片"
  505. previewSize="medium"
  506. placeholder="选择分类图片"
  507. />
  508. </FormControl>
  509. <FormMessage />
  510. </FormItem>
  511. )}
  512. />
  513. <DialogFooter>
  514. <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
  515. 取消
  516. </Button>
  517. <Button type="submit">更新</Button>
  518. </DialogFooter>
  519. </form>
  520. </Form>
  521. )}
  522. </DialogContent>
  523. </Dialog>
  524. {/* 删除确认对话框 */}
  525. <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
  526. <DialogContent>
  527. <DialogHeader>
  528. <DialogTitle>确认删除</DialogTitle>
  529. <DialogDescription>
  530. 确定要删除这个商品分类吗?此操作无法撤销。
  531. </DialogDescription>
  532. </DialogHeader>
  533. <DialogFooter>
  534. <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
  535. 取消
  536. </Button>
  537. <Button variant="destructive" onClick={confirmDelete}>
  538. 删除
  539. </Button>
  540. </DialogFooter>
  541. </DialogContent>
  542. </Dialog>
  543. </div>
  544. );
  545. };