GoodsCategories.tsx 21 KB

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