Browse Source

✨ feat(advertisement): 实现广告管理功能

- 新增广告管理模块,包括实体、Schema和服务
- 添加广告列表、创建、编辑和删除功能
- 实现广告图片上传和预览功能
- 添加广告状态管理和排序功能
- 在管理菜单中添加广告管理入口

✨ feat(backend): 完善广告管理后端接口

- 创建广告CRUD通用路由
- 实现广告状态筛选和关键词搜索
- 添加广告位ID筛选功能
- 完善广告数据验证规则
- 记录广告创建和更新用户信息

✨ feat(frontend): 实现广告管理前端页面

- 创建广告列表页面,支持搜索和分页
- 实现广告创建和编辑对话框
- 添加广告启用/禁用状态切换
- 实现广告图片选择器组件
- 添加广告排序功能
yourname 7 months ago
parent
commit
67f2dfe58b

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

@@ -13,7 +13,8 @@ import {
   Gift,
   Gift,
   QrCode,
   QrCode,
   CreditCard,
   CreditCard,
-  CreditCard as CreditCardIcon
+  CreditCard as CreditCardIcon,
+  Image
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 export interface MenuItem {
 export interface MenuItem {
@@ -145,7 +146,14 @@ export const useMenu = () => {
           icon: <CreditCard className="h-4 w-4" />,
           icon: <CreditCard className="h-4 w-4" />,
           path: '/admin/wechat-pay-config',
           path: '/admin/wechat-pay-config',
           permission: 'payment:manage'
           permission: 'payment:manage'
-        }
+        },
+        {
+          key: 'advertisements',
+          label: '广告管理',
+          icon: <Image className="h-4 w-4" />,
+          path: '/admin/advertisements',
+          permission: 'advertisement:manage'
+        },
       ]
       ]
     },
     },
     {
     {

+ 447 - 0
src/client/admin-shadcn/pages/Advertisements.tsx

@@ -0,0 +1,447 @@
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Plus, Edit, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+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 { Textarea } from '@/client/components/ui/textarea';
+import { Switch } from '@/client/components/ui/switch';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Badge } from '@/client/components/ui/badge';
+import { AvatarSelector } from '@/client/admin-shadcn/components/AvatarSelector';
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { advertisementClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import { CreateAdvertisementDto, UpdateAdvertisementDto } from '@/server/modules/advertisements/advertisement.schema';
+
+// 类型定义
+type AdvertisementResponse = InferResponseType<typeof advertisementClient.$get, 200>;
+type AdvertisementItem = AdvertisementResponse['data'][0];
+type CreateRequest = InferRequestType<typeof advertisementClient.$post>['json'];
+type UpdateRequest = InferRequestType<typeof advertisementClient[':id']['$put']>['json'];
+
+const Advertisements: React.FC = () => {
+  const queryClient = useQueryClient();
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  const [editingData, setEditingData] = useState<AdvertisementItem | null>(null);
+  const [searchKeyword, setSearchKeyword] = useState('');
+
+  // 查询广告列表
+  const { data: advertisementsData, isLoading } = useQuery({
+    queryKey: ['advertisements', searchKeyword],
+    queryFn: async () => {
+      const response = await advertisementClient.$get({
+        query: {
+          page: 1,
+          pageSize: 100,
+          ...(searchKeyword && { keyword: searchKeyword })
+        }
+      });
+      if (response.status !== 200) throw new Error('获取广告列表失败');
+      return response.json();
+    }
+  });
+
+  // 创建广告
+  const createMutation = useMutation({
+    mutationFn: async (data: CreateRequest) => {
+      const response = await advertisementClient.$post({ json: data });
+      if (response.status !== 200) throw new Error('创建广告失败');
+      return response.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['advertisements'] });
+      toast.success('广告创建成功');
+      setIsModalOpen(false);
+    },
+    onError: (error) => {
+      toast.error(`创建失败:${error.message}`);
+    }
+  });
+
+  // 更新广告
+  const updateMutation = useMutation({
+    mutationFn: async ({ id, data }: { id: number; data: UpdateRequest }) => {
+      const response = await advertisementClient[':id']['$put']({
+        param: { id: id.toString() },
+        json: data
+      });
+      if (response.status !== 200) throw new Error('更新广告失败');
+      return response.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['advertisements'] });
+      toast.success('广告更新成功');
+      setIsModalOpen(false);
+    },
+    onError: (error) => {
+      toast.error(`更新失败:${error.message}`);
+    }
+  });
+
+  // 删除广告
+  const deleteMutation = useMutation({
+    mutationFn: async (id: number) => {
+      const response = await advertisementClient[':id']['$delete']({
+        param: { id: id.toString() }
+      });
+      if (response.status !== 200) throw new Error('删除广告失败');
+      return response.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['advertisements'] });
+      toast.success('广告删除成功');
+    },
+    onError: (error) => {
+      toast.error(`删除失败:${error.message}`);
+    }
+  });
+
+  // 表单实例
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(CreateAdvertisementDto),
+    defaultValues: {
+      title: '',
+      linkUrl: '',
+      imageUrl: '',
+      positionId: 1,
+      sortOrder: 0,
+      isEnabled: 1,
+      description: '',
+      startTime: undefined,
+      endTime: undefined
+    }
+  });
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(UpdateAdvertisementDto),
+    defaultValues: {}
+  });
+
+  // 打开创建表单
+  const handleCreate = () => {
+    setEditingData(null);
+    setIsCreateForm(true);
+    createForm.reset();
+    setIsModalOpen(true);
+  };
+
+  // 打开编辑表单
+  const handleEdit = (data: AdvertisementItem) => {
+    setEditingData(data);
+    setIsCreateForm(false);
+    updateForm.reset({
+      title: data.title,
+      linkUrl: data.linkUrl,
+      imageUrl: data.imageUrl,
+      positionId: data.positionId,
+      sortOrder: data.sortOrder,
+      isEnabled: data.isEnabled,
+      description: data.description || undefined,
+      startTime: data.startTime || undefined,
+      endTime: data.endTime || undefined
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理表单提交
+  const handleSubmit = (data: CreateRequest | UpdateRequest) => {
+    if (isCreateForm) {
+      createMutation.mutate(data as CreateRequest);
+    } else if (editingData) {
+      updateMutation.mutate({ id: editingData.id, data: data as UpdateRequest });
+    }
+  };
+
+  // 处理删除
+  const handleDelete = (id: number) => {
+    if (window.confirm('确定要删除这个广告吗?')) {
+      deleteMutation.mutate(id);
+    }
+  };
+
+  // 格式化日期
+  const formatDate = (date: string | null | undefined) => {
+    if (!date) return '永久';
+    return new Date(date).toLocaleDateString('zh-CN');
+  };
+
+  // 获取状态徽章
+  const getStatusBadge = (isEnabled: number) => {
+    return isEnabled === 1 ? (
+      <Badge variant="default">启用</Badge>
+    ) : (
+      <Badge variant="secondary">禁用</Badge>
+    );
+  };
+
+  return (
+    <div className="container mx-auto py-6">
+      <Card>
+        <CardHeader>
+          <div className="flex justify-between items-center">
+            <div>
+              <CardTitle>广告管理</CardTitle>
+              <CardDescription>管理系统中的所有广告内容</CardDescription>
+            </div>
+            <Button onClick={handleCreate}>
+              <Plus className="w-4 h-4 mr-2" />
+              新建广告
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent>
+          {/* 搜索栏 */}
+          <div className="mb-4">
+            <Input
+              placeholder="搜索广告标题..."
+              value={searchKeyword}
+              onChange={(e) => setSearchKeyword(e.target.value)}
+              className="max-w-sm"
+            />
+          </div>
+
+          {/* 广告列表 */}
+          <Table>
+            <TableHeader>
+              <TableRow>
+                <TableHead className="w-[80px]">ID</TableHead>
+                <TableHead>标题</TableHead>
+                <TableHead>广告位</TableHead>
+                <TableHead>状态</TableHead>
+                <TableHead>排序</TableHead>
+                <TableHead>开始时间</TableHead>
+                <TableHead>结束时间</TableHead>
+                <TableHead>创建时间</TableHead>
+                <TableHead className="text-right">操作</TableHead>
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {isLoading ? (
+                <TableRow>
+                  <TableCell colSpan={9} className="text-center">
+                    加载中...
+                  </TableCell>
+                </TableRow>
+              ) : !advertisementsData?.data?.length ? (
+                <TableRow>
+                  <TableCell colSpan={9} className="text-center">
+                    暂无数据
+                  </TableCell>
+                </TableRow>
+              ) : (
+                advertisementsData.data.map((ad) => (
+                  <TableRow key={ad.id}>
+                    <TableCell>{ad.id}</TableCell>
+                    <TableCell>{ad.title}</TableCell>
+                    <TableCell>广告位 {ad.positionId}</TableCell>
+                    <TableCell>{getStatusBadge(ad.isEnabled)}</TableCell>
+                    <TableCell>{ad.sortOrder}</TableCell>
+                    <TableCell>{formatDate(ad.startTime)}</TableCell>
+                    <TableCell>{formatDate(ad.endTime)}</TableCell>
+                    <TableCell>{formatDate(ad.createdAt)}</TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => handleEdit(ad)}
+                        >
+                          <Edit className="w-4 h-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => handleDelete(ad.id)}
+                          className="text-red-600 hover:text-red-700"
+                        >
+                          <Trash2 className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))
+              )}
+            </TableBody>
+          </Table>
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑对话框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[600px] 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="title"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      广告标题
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入广告标题" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="linkUrl"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      链接地址
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入链接地址" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="imageUrl"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      图片地址
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <AvatarSelector
+                        value={field.value || undefined}
+                        onChange={(value) => field.onChange(value)}
+                        maxSize={2}
+                        uploadPath="/advertisements"
+                        uploadButtonText="上传图片"
+                        previewSize="medium"
+                        placeholder="选择广告图片"
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <div className="grid grid-cols-2 gap-4">
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="positionId"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        广告位ID
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入广告位ID"
+                          {...field}
+                          onChange={(e) => field.onChange(Number(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="sortOrder"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>排序值</FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="排序值"
+                          {...field}
+                          onChange={(e) => field.onChange(Number(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </div>
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="isEnabled"
+                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={(checked) => field.onChange(checked ? 1 : 0)}
+                      />
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="description"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>广告描述</FormLabel>
+                    <FormControl>
+                      <Textarea
+                        placeholder="请输入广告描述"
+                        className="resize-none"
+                        {...field}
+                        value={field.value || ''}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                  取消
+                </Button>
+                <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
+                  {isCreateForm ? '创建' : '更新'}
+                </Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};
+
+export default Advertisements;

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

@@ -74,6 +74,11 @@ export const router = createBrowserRouter([
         path: 'wechat-pay-config',
         path: 'wechat-pay-config',
         element: <WechatPayConfigPage />,
         element: <WechatPayConfigPage />,
         errorElement: <ErrorPage />
         errorElement: <ErrorPage />
+      {
+        path: 'advertisements',
+        element: <Advertisements />,
+        errorElement: <ErrorPage />
+      },
       },
       },
       {
       {
         path: '*',
         path: '*',

+ 20 - 0
src/server/api/advertisements/index.ts

@@ -0,0 +1,20 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { Advertisement } from '@/server/modules/advertisements/advertisement.entity';
+import { AdvertisementSchema, CreateAdvertisementDto, UpdateAdvertisementDto } from '@/server/modules/advertisements/advertisement.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const advertisementRoutes = createCrudRoutes({
+  entity: Advertisement,
+  createSchema: CreateAdvertisementDto,
+  updateSchema: UpdateAdvertisementDto,
+  getSchema: AdvertisementSchema,
+  listSchema: AdvertisementSchema,
+  searchFields: ['title', 'description'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+export default advertisementRoutes;

+ 2 - 1
src/server/data-source.ts

@@ -11,6 +11,7 @@ import { WechatCouponStock } from "./modules/wechat-pay/wechat-coupon-stock.enti
 import { WechatCoupon } from "./modules/wechat-pay/wechat-coupon.entity"
 import { WechatCoupon } from "./modules/wechat-pay/wechat-coupon.entity"
 import { CouponLog } from "./modules/coupon-logs/coupon-log.entity"
 import { CouponLog } from "./modules/coupon-logs/coupon-log.entity"
 import { RedemptionCode } from "./modules/redemption-codes/redemption-code.entity"
 import { RedemptionCode } from "./modules/redemption-codes/redemption-code.entity"
+import { Advertisement } from "./modules/advertisements/advertisement.entity"
 
 
 export const AppDataSource = new DataSource({
 export const AppDataSource = new DataSource({
   type: "mysql",
   type: "mysql",
@@ -20,7 +21,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
   entities: [
-    User, Role, File, WechatPayConfig, WechatCouponStock, WechatCoupon, CouponLog, RedemptionCode,
+    User, Role, File, WechatPayConfig, WechatCouponStock, WechatCoupon, CouponLog, RedemptionCode, Advertisement,
   ],
   ],
   migrations: [],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 49 - 0
src/server/modules/advertisements/advertisement.entity.ts

@@ -0,0 +1,49 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+@Entity('advertisements')
+export class Advertisement {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'position_id', type: 'int', unsigned: true, comment: '广告位ID' })
+  positionId!: number;
+
+  @Column({ name: 'link_url', type: 'text', comment: '链接地址' })
+  linkUrl!: string;
+
+  @Column({ name: 'image_url', type: 'text', comment: '图片地址' })
+  imageUrl!: string;
+
+  @Column({ name: 'title', type: 'varchar', length: 255, comment: '广告标题' })
+  title!: string;
+
+  @Column({ name: 'sort_order', type: 'int', default: 0, comment: '排序值' })
+  sortOrder!: number;
+
+  @Column({ name: 'is_enabled', type: 'tinyint', default: 1, comment: '是否启用:0-禁用,1-启用' })
+  isEnabled!: number;
+
+  @Column({ name: 'is_deleted', type: 'tinyint', default: 0, comment: '是否删除:0-未删除,1-已删除' })
+  isDeleted!: number;
+
+  @Column({ name: 'start_time', type: 'timestamp', nullable: true, comment: '开始时间' })
+  startTime!: Date | null;
+
+  @Column({ name: 'end_time', type: 'timestamp', nullable: true, comment: '结束时间' })
+  endTime!: Date | null;
+
+  @Column({ name: 'description', type: 'text', nullable: true, comment: '广告描述' })
+  description!: string | null;
+
+  @Column({ name: 'created_by', type: 'int', unsigned: true, nullable: true, comment: '创建用户ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', unsigned: true, nullable: true, comment: '更新用户ID' })
+  updatedBy!: number | null;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+}

+ 179 - 0
src/server/modules/advertisements/advertisement.schema.ts

@@ -0,0 +1,179 @@
+import { z } from '@hono/zod-openapi';
+
+// 基础广告Schema
+export const AdvertisementSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '广告ID',
+    example: 1
+  }),
+  positionId: z.coerce.number().int().positive().openapi({
+    description: '广告位ID',
+    example: 1
+  }),
+  linkUrl: z.string().url().openapi({
+    description: '链接地址',
+    example: 'https://example.com'
+  }),
+  imageUrl: z.string().url().openapi({
+    description: '图片地址',
+    example: 'https://example.com/image.jpg'
+  }),
+  title: z.string().min(1).max(255).openapi({
+    description: '广告标题',
+    example: '夏季促销活动'
+  }),
+  sortOrder: z.coerce.number().int().min(0).default(0).openapi({
+    description: '排序值,数值越大越靠前',
+    example: 100
+  }),
+  isEnabled: z.coerce.number().int().min(0).max(1).default(1).openapi({
+    description: '是否启用:0-禁用,1-启用',
+    example: 1
+  }),
+  isDeleted: z.coerce.number().int().min(0).max(1).default(0).openapi({
+    description: '是否删除:0-未删除,1-已删除',
+    example: 0
+  }),
+  startTime: z.coerce.date().nullable().optional().openapi({
+    description: '开始时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  endTime: z.coerce.date().nullable().optional().openapi({
+    description: '结束时间',
+    example: '2024-12-31T23:59:59Z'
+  }),
+  description: z.string().max(1000).nullable().optional().openapi({
+    description: '广告描述',
+    example: '这是一个夏季促销活动的广告'
+  }),
+  createdBy: z.number().int().positive().nullable().optional().openapi({
+    description: '创建用户ID',
+    example: 1
+  }),
+  updatedBy: z.number().int().positive().nullable().optional().openapi({
+    description: '更新用户ID',
+    example: 1
+  }),
+  createdAt: z.coerce.date().openapi({
+    description: '创建时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  updatedAt: z.coerce.date().openapi({
+    description: '更新时间',
+    example: '2024-01-01T00:00:00Z'
+  })
+});
+
+// 创建广告DTO
+export const CreateAdvertisementDto = z.object({
+  positionId: z.coerce.number().int().positive().openapi({
+    description: '广告位ID',
+    example: 1
+  }),
+  linkUrl: z.string().url().openapi({
+    description: '链接地址',
+    example: 'https://example.com'
+  }),
+  imageUrl: z.string().url().openapi({
+    description: '图片地址',
+    example: 'https://example.com/image.jpg'
+  }),
+  title: z.string().min(1).max(255).openapi({
+    description: '广告标题',
+    example: '夏季促销活动'
+  }),
+  sortOrder: z.coerce.number().int().min(0).default(0).optional().openapi({
+    description: '排序值',
+    example: 100
+  }),
+  isEnabled: z.coerce.number().int().min(0).max(1).default(1).optional().openapi({
+    description: '是否启用',
+    example: 1
+  }),
+  startTime: z.coerce.date().nullable().optional().openapi({
+    description: '开始时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  endTime: z.coerce.date().nullable().optional().openapi({
+    description: '结束时间',
+    example: '2024-12-31T23:59:59Z'
+  }),
+  description: z.string().max(1000).nullable().optional().openapi({
+    description: '广告描述',
+    example: '这是一个夏季促销活动的广告'
+  })
+});
+
+// 更新广告DTO
+export const UpdateAdvertisementDto = z.object({
+  positionId: z.coerce.number().int().positive().optional().openapi({
+    description: '广告位ID',
+    example: 1
+  }),
+  linkUrl: z.string().url().optional().openapi({
+    description: '链接地址',
+    example: 'https://example.com'
+  }),
+  imageUrl: z.string().url().optional().openapi({
+    description: '图片地址',
+    example: 'https://example.com/image.jpg'
+  }),
+  title: z.string().min(1).max(255).optional().openapi({
+    description: '广告标题',
+    example: '夏季促销活动'
+  }),
+  sortOrder: z.coerce.number().int().min(0).optional().openapi({
+    description: '排序值',
+    example: 100
+  }),
+  isEnabled: z.coerce.number().int().min(0).max(1).optional().openapi({
+    description: '是否启用',
+    example: 1
+  }),
+  startTime: z.coerce.date().nullable().optional().openapi({
+    description: '开始时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  endTime: z.coerce.date().nullable().optional().openapi({
+    description: '结束时间',
+    example: '2024-12-31T23:59:59Z'
+  }),
+  description: z.string().max(1000).nullable().optional().openapi({
+    description: '广告描述',
+    example: '这是一个夏季促销活动的广告'
+  })
+});
+
+// 广告列表查询参数
+export const AdvertisementQuery = z.object({
+  page: z.coerce.number().int().positive().default(1).openapi({
+    description: '页码',
+    example: 1
+  }),
+  pageSize: z.coerce.number().int().positive().default(10).openapi({
+    description: '每页条数',
+    example: 10
+  }),
+  positionId: z.coerce.number().int().positive().optional().openapi({
+    description: '广告位ID筛选',
+    example: 1
+  }),
+  isEnabled: z.coerce.number().int().min(0).max(1).optional().openapi({
+    description: '是否启用筛选',
+    example: 1
+  }),
+  keyword: z.string().optional().openapi({
+    description: '关键词搜索(标题)',
+    example: '夏季'
+  })
+});
+
+// 广告列表响应
+export const AdvertisementListResponse = z.object({
+  data: z.array(AdvertisementSchema),
+  pagination: z.object({
+    total: z.number().openapi({ example: 100, description: '总记录数' }),
+    current: z.number().openapi({ example: 1, description: '当前页码' }),
+    pageSize: z.number().openapi({ example: 10, description: '每页数量' })
+  })
+});

+ 53 - 0
src/server/modules/advertisements/advertisement.service.ts

@@ -0,0 +1,53 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { Advertisement } from './advertisement.entity';
+
+export class AdvertisementService extends GenericCrudService<Advertisement> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, Advertisement);
+  }
+
+  /**
+   * 获取启用的广告列表,按排序值降序排列
+   */
+  async getEnabledAdvertisements(positionId?: number) {
+    const where: any = {
+      isEnabled: 1,
+      isDeleted: 0
+    };
+    
+    if (positionId) {
+      where.positionId = positionId;
+    }
+
+    return this.repository.find({
+      where,
+      order: {
+        sortOrder: 'DESC',
+        id: 'DESC'
+      }
+    });
+  }
+
+  /**
+   * 重写获取列表方法,添加时间筛选
+   */
+  async getList(
+    page: number = 1,
+    pageSize: number = 10,
+    keyword?: string,
+    searchFields?: string[],
+    where: Partial<Advertisement> = {},
+    relations: string[] = [],
+    order: any = {}
+  ) {
+    // 默认只获取未删除的广告
+    where.isDeleted = 0;
+    
+    return super.getList(page, pageSize, keyword, ['title', 'description'], where, relations, {
+      sortOrder: 'DESC',
+      id: 'DESC',
+      ...order
+    });
+  }
+}