Bladeren bron

✨ feat(advertisement): add advertisement type management feature

- add advertisement type menu item with submenu structure
- create new advertisement types management page
- implement CRUD operations for advertisement types
- add form validation using backend schema
- add pagination and search functionality
- add create/edit/delete modal dialogs
- add status badge display for advertisement types
- add advertisement types route configuration
yourname 4 maanden geleden
bovenliggende
commit
effc96e11c

+ 15 - 2
src/client/admin-shadcn/menu.tsx

@@ -107,8 +107,21 @@ export const useMenu = () => {
       key: 'advertisements',
       label: '广告管理',
       icon: <Megaphone className="h-4 w-4" />,
-      path: '/admin/advertisements',
-      permission: 'advertisement:manage'
+      permission: 'advertisement:manage',
+      children: [
+        {
+          key: 'advertisements-list',
+          label: '广告列表',
+          path: '/admin/advertisements',
+          permission: 'advertisement:manage'
+        },
+        {
+          key: 'advertisement-types',
+          label: '广告类型',
+          path: '/admin/advertisement-types',
+          permission: 'advertisement:manage'
+        }
+      ]
     },
     {
       key: 'settings',

+ 451 - 0
src/client/admin-shadcn/pages/AdvertisementTypes.tsx

@@ -0,0 +1,451 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Plus, Search, Edit, Trash2, Eye } from 'lucide-react'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from 'sonner'
+
+import { Button } from '@/client/components/ui/button'
+import { Input } from '@/client/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table'
+import { Badge } from '@/client/components/ui/badge'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog'
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form'
+import { Switch } from '@/client/components/ui/switch'
+import { Textarea } from '@/client/components/ui/textarea'
+import { Skeleton } from '@/client/components/ui/skeleton'
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination'
+
+import { advertisementTypeClient } from '@/client/api'
+import type { InferRequestType, InferResponseType } from 'hono/client'
+import { CreateAdvertisementTypeDto, UpdateAdvertisementTypeDto } from '@/server/modules/advertisements/advertisement-type.schema'
+
+type AdvertisementTypeResponse = InferResponseType<typeof advertisementTypeClient.$get, 200>['data'][0]
+type CreateRequest = InferRequestType<typeof advertisementTypeClient.$post>['json']
+type UpdateRequest = InferRequestType<typeof advertisementTypeClient[':id']['$put']>['json']
+
+// 表单Schema直接使用后端定义
+const createFormSchema = CreateAdvertisementTypeDto
+const updateFormSchema = UpdateAdvertisementTypeDto
+
+export const AdvertisementTypesPage = () => {
+  const queryClient = useQueryClient()
+  
+  // 状态管理
+  const [searchParams, setSearchParams] = useState({ page: 1, limit: 10, search: '' })
+  const [isModalOpen, setIsModalOpen] = useState(false)
+  const [editingType, setEditingType] = useState<AdvertisementTypeResponse | null>(null)
+  const [isCreateForm, setIsCreateForm] = useState(true)
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+  const [typeToDelete, setTypeToDelete] = useState<number | null>(null)
+
+  // 表单实例
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(createFormSchema),
+    defaultValues: {
+      name: '',
+      code: '',
+      remark: '',
+      status: 1
+    }
+  })
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(updateFormSchema),
+    defaultValues: {
+      name: '',
+      code: '',
+      remark: '',
+      status: 1
+    }
+  })
+
+  // 数据查询
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['advertisement-types', searchParams],
+    queryFn: async () => {
+      const res = await advertisementTypeClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search
+        }
+      })
+      if (res.status !== 200) throw new Error('获取广告类型列表失败')
+      return await res.json()
+    }
+  })
+
+  // 创建mutation
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateRequest) => {
+      const res = await advertisementTypeClient.$post({ json: data })
+      if (res.status !== 201) throw new Error('创建失败')
+      return await res.json()
+    },
+    onSuccess: () => {
+      toast.success('广告类型创建成功')
+      setIsModalOpen(false)
+      createForm.reset()
+      refetch()
+    },
+    onError: (error) => {
+      toast.error(error.message || '创建失败')
+    }
+  })
+
+  // 更新mutation
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateRequest }) => {
+      const res = await advertisementTypeClient[':id']['$put']({
+        param: { id: id.toString() },
+        json: data
+      })
+      if (res.status !== 200) throw new Error('更新失败')
+      return await res.json()
+    },
+    onSuccess: () => {
+      toast.success('广告类型更新成功')
+      setIsModalOpen(false)
+      setEditingType(null)
+      refetch()
+    },
+    onError: (error) => {
+      toast.error(error.message || '更新失败')
+    }
+  })
+
+  // 删除mutation
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const res = await advertisementTypeClient[':id']['$delete']({
+        param: { id: id.toString() }
+      })
+      if (res.status !== 204) throw new Error('删除失败')
+      return await res.json()
+    },
+    onSuccess: () => {
+      toast.success('广告类型删除成功')
+      setDeleteDialogOpen(false)
+      setTypeToDelete(null)
+      refetch()
+    },
+    onError: (error) => {
+      toast.error(error.message || '删除失败')
+    }
+  })
+
+  // 业务逻辑函数
+  const handleSearch = () => {
+    setSearchParams(prev => ({ ...prev, page: 1 }))
+  }
+
+  const handleCreateType = () => {
+    setIsCreateForm(true)
+    setEditingType(null)
+    createForm.reset({
+      name: '',
+      code: '',
+      remark: '',
+      status: 1
+    })
+    setIsModalOpen(true)
+  }
+
+  const handleEditType = (type: AdvertisementTypeResponse) => {
+    setIsCreateForm(false)
+    setEditingType(type)
+    updateForm.reset({
+      name: type.name,
+      code: type.code,
+      remark: type.remark || '',
+      status: type.status
+    })
+    setIsModalOpen(true)
+  }
+
+  const handleDeleteType = (id: number) => {
+    setTypeToDelete(id)
+    setDeleteDialogOpen(true)
+  }
+
+  const confirmDelete = () => {
+    if (typeToDelete) {
+      deleteMutation.mutate(typeToDelete)
+    }
+  }
+
+  const handleSubmit = (data: CreateRequest | UpdateRequest) => {
+    if (isCreateForm) {
+      createMutation.mutate(data as CreateRequest)
+    } else if (editingType) {
+      updateMutation.mutate({ id: editingType.id, data: data as UpdateRequest })
+    }
+  }
+
+  // 格式化时间戳
+  const formatTimestamp = (timestamp: number | null) => {
+    if (!timestamp) return '-'
+    return new Date(timestamp * 1000).toLocaleString('zh-CN')
+  }
+
+  if (isLoading) {
+    return (
+      <div className="space-y-4">
+        <div className="flex justify-between items-center">
+          <h1 className="text-2xl font-bold">广告类型管理</h1>
+          <Button disabled>
+            <Plus className="mr-2 h-4 w-4" />
+            创建类型
+          </Button>
+        </div>
+
+        <Card>
+          <CardHeader>
+            <Skeleton className="h-6 w-1/4" />
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2">
+              <Skeleton className="h-4 w-full" />
+              <Skeleton className="h-4 w-full" />
+              <Skeleton className="h-4 w-full" />
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <div>
+          <h1 className="text-2xl font-bold">广告类型管理</h1>
+          <p className="text-muted-foreground">管理广告类型配置,用于广告位分类</p>
+        </div>
+        <Button onClick={handleCreateType}>
+          <Plus className="mr-2 h-4 w-4" />
+          创建类型
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>广告类型列表</CardTitle>
+          <CardDescription>管理所有广告类型配置</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="mb-4">
+            <form onSubmit={(e) => { e.preventDefault(); handleSearch() }} className="flex gap-2">
+              <div className="relative flex-1 max-w-sm">
+                <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+                <Input
+                  placeholder="搜索类型名称或调用别名..."
+                  value={searchParams.search}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                  className="pl-8"
+                />
+              </div>
+              <Button type="submit" variant="outline">
+                搜索
+              </Button>
+            </form>
+          </div>
+
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>ID</TableHead>
+                  <TableHead>类型名称</TableHead>
+                  <TableHead>调用别名</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data?.data.map((type) => (
+                  <TableRow key={type.id}>
+                    <TableCell className="font-medium">{type.id}</TableCell>
+                    <TableCell>{type.name}</TableCell>
+                    <TableCell>
+                      <code className="text-xs bg-muted px-2 py-1 rounded">{type.code}</code>
+                    </TableCell>
+                    <TableCell>
+                      <Badge variant={type.status === 1 ? 'default' : 'secondary'}>
+                        {type.status === 1 ? '启用' : '禁用'}
+                      </Badge>
+                    </TableCell>
+                    <TableCell className="text-sm">
+                      {formatTimestamp(type.createTime)}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditType(type)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeleteType(type.id)}
+                        >
+                          <Trash2 className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+          </div>
+
+          {data?.data.length === 0 && !isLoading && (
+            <div className="text-center py-8">
+              <p className="text-muted-foreground">暂无广告类型数据</p>
+            </div>
+          )}
+
+          <DataTablePagination
+            currentPage={searchParams.page}
+            pageSize={searchParams.limit}
+            totalCount={data?.pagination.total || 0}
+            onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
+          />
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑模态框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>{isCreateForm ? '创建广告类型' : '编辑广告类型'}</DialogTitle>
+            <DialogDescription>
+              {isCreateForm ? '创建一个新的广告类型配置' : '编辑现有广告类型信息'}
+            </DialogDescription>
+          </DialogHeader>
+
+          <Form {...(isCreateForm ? createForm : updateForm)}>
+            <form onSubmit={(isCreateForm ? createForm : updateForm).handleSubmit(handleSubmit)} className="space-y-4">
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="name"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      类型名称 <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入类型名称" {...field} />
+                    </FormControl>
+                    <FormDescription>例如:首页轮播、侧边广告等</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="code"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      调用别名 <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入调用别名" {...field} />
+                    </FormControl>
+                    <FormDescription>用于程序调用的唯一标识,建议使用英文小写和下划线</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="remark"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>备注</FormLabel>
+                    <FormControl>
+                      <Textarea 
+                        placeholder="请输入备注信息(可选)"
+                        className="resize-none"
+                        {...field} 
+                      />
+                    </FormControl>
+                    <FormDescription>对广告类型的详细描述</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="status"
+                render={({ field }) => (
+                  <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+                    <div className="space-y-0.5">
+                      <FormLabel className="text-base">启用状态</FormLabel>
+                      <FormDescription>
+                        禁用后该类型下的广告将无法正常展示
+                      </FormDescription>
+                    </div>
+                    <FormControl>
+                      <Switch
+                        checked={field.value === 1}
+                        onCheckedChange={field.onChange}
+                      />
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                  取消
+                </Button>
+                <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
+                  {createMutation.isPending || updateMutation.isPending ? '处理中...' : (isCreateForm ? '创建' : '更新')}
+                </Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+
+      {/* 删除确认对话框 */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>确认删除</DialogTitle>
+            <DialogDescription>
+              确定要删除这个广告类型吗?此操作无法撤销。
+              <br />
+              <span className="text-destructive">
+                注意:删除后,该类型下的所有广告将失去类型关联。
+              </span>
+            </DialogDescription>
+          </DialogHeader>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
+              取消
+            </Button>
+            <Button
+              variant="destructive"
+              onClick={confirmDelete}
+              disabled={deleteMutation.isPending}
+            >
+              {deleteMutation.isPending ? '删除中...' : '删除'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 6 - 0
src/client/admin-shadcn/routes.tsx

@@ -9,6 +9,7 @@ import { UsersPage } from './pages/Users';
 import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import { AdvertisementsPage } from './pages/Advertisements';
+import { AdvertisementTypesPage } from './pages/AdvertisementTypes';
 
 export const router = createBrowserRouter([
   {
@@ -51,6 +52,11 @@ export const router = createBrowserRouter([
         element: <AdvertisementsPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'advertisement-types',
+        element: <AdvertisementTypesPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,