Sfoglia il codice sorgente

✨ feat(components): 创建广告类型选择器组件

- 添加 AdvertisementTypeSelector 组件,统一处理广告类型选择逻辑
- 组件内部集成数据获取和加载状态管理
- 支持自定义占位符、禁用状态和样式类

📝 docs(commands): 添加实体选择器组件创建指令文档

- 记录基于实体名称的选择器组件创建规范
- 提供广告类型、用户、角色等实体选择器命名示例

♻️ refactor(api): 重构广告类型API调用路径

- 将 advertisementTypeClient 路径从 api.v1.advertisementTypes 调整为 api.v1['advertisement-types']
- 优化API调用路径的一致性和可读性

♻️ refactor(advertisements): 优化广告列表页面代码

- 移除页面内广告类型数据获取逻辑,使用新的选择器组件
- 调整表格中广告类型显示方式,直接使用关联数据
- 优化分页组件属性名,提升代码可读性

✨ feat(utils): 添加axios-fetch适配器工具函数

- 创建通用的axios请求适配器,统一处理请求和响应
- 支持Headers对象转换和错误处理
- 适配不同Content-Type响应处理

✨ feat(advertisement): 添加广告类型关联关系

- 在Advertisement实体中添加advertisementType关联
- 列表查询时包含广告类型关联数据,减少前端查询次数
yourname 3 mesi fa
parent
commit
4a4340d919

+ 19 - 0
.roo/commands/shadcn-entity-selector.md

@@ -0,0 +1,19 @@
+---
+description: "基于实体名称的选择器组件创建指令"
+---
+
+# 基于实体名称的选择器组件抽取指令
+
+## 指令描述
+根据实际实体名称创建专门的选择器组件,每个实体对应一个独立的选择器,保持命名一致性和可预测性。
+
+## 使用方式
+执行此指令将根据项目中实际的实体名称自动创建对应的选择器组件,格式为 `[EntityName]Selector`。
+
+## 实体选择器命名规范
+- 广告类型 → `AdvertisementTypeSelector`
+- 用户 → `UserSelector`
+- 角色 → `RoleSelector`
+- 文件 → `FileSelector`
+- 产品 → `ProductSelector`
+- 通用格式: `[实体名]Selector`

+ 78 - 0
src/client/admin-shadcn/components/AdvertisementTypeSelector.tsx

@@ -0,0 +1,78 @@
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/client/components/ui/select';
+import { Skeleton } from '@/client/components/ui/skeleton';
+import { advertisementTypeClient } from '@/client/api';
+import type { InferResponseType } from 'hono/client';
+
+type AdvertisementTypeResponse = InferResponseType<typeof advertisementTypeClient.$get, 200>['data'][0];
+
+interface AdvertisementTypeSelectorProps {
+  value?: number;
+  onChange?: (value: number) => void;
+  placeholder?: string;
+  disabled?: boolean;
+  className?: string;
+}
+
+export const AdvertisementTypeSelector: React.FC<AdvertisementTypeSelectorProps> = ({
+  value,
+  onChange,
+  placeholder = "请选择广告类型",
+  disabled = false,
+  className,
+}) => {
+  const {
+    data: advertisementTypes,
+    isLoading,
+    isError,
+  } = useQuery({
+    queryKey: ['advertisement-types'],
+    queryFn: async () => {
+      const res = await advertisementTypeClient.$get();
+      if (res.status !== 200) throw new Error('获取广告类型失败');
+      return await res.json();
+    },
+  });
+
+  if (isLoading) {
+    return <Skeleton className="h-10 w-full" />;
+  }
+
+  if (isError) {
+    return (
+      <div className="text-sm text-destructive">
+        加载广告类型失败
+      </div>
+    );
+  }
+
+  const types = advertisementTypes?.data || [];
+
+  return (
+    <Select
+      value={value?.toString()}
+      onValueChange={(val) => onChange?.(parseInt(val))}
+      disabled={disabled || types.length === 0}
+    >
+      <SelectTrigger className={className}>
+        <SelectValue placeholder={placeholder} />
+      </SelectTrigger>
+      <SelectContent>
+        {types.map((type) => (
+          <SelectItem key={type.id} value={type.id.toString()}>
+            {type.name}
+          </SelectItem>
+        ))}
+      </SelectContent>
+    </Select>
+  );
+};
+
+export default AdvertisementTypeSelector;

+ 15 - 35
src/client/admin-shadcn/pages/Advertisements.tsx

@@ -13,6 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
 import { toast } from 'sonner';
 import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
 import AvatarSelector from '@/client/admin-shadcn/components/AvatarSelector';
+import AdvertisementTypeSelector from '@/client/admin-shadcn/components/AdvertisementTypeSelector';
 import { advertisementClient } from '@/client/api';
 import type { InferRequestType, InferResponseType } from 'hono/client';
 import { CreateAdvertisementDto, UpdateAdvertisementDto } from '@/server/modules/advertisements/advertisement.schema';
@@ -53,15 +54,7 @@ export const AdvertisementsPage = () => {
     defaultValues: {}
   });
 
-  // 获取广告类型列表
-  const { data: advertisementTypes } = useQuery({
-    queryKey: ['advertisement-types'],
-    queryFn: async () => {
-      const res = await advertisementClient.types.$get();
-      if (res.status !== 200) throw new Error('获取广告类型失败');
-      return await res.json();
-    }
-  });
+  // 获取广告类型列表 - 现在由 AdvertisementTypeSelector 内部处理
 
   // 数据查询
   const { data, isLoading, refetch } = useQuery({
@@ -284,7 +277,7 @@ export const AdvertisementsPage = () => {
                     <TableCell>{advertisement.id}</TableCell>
                     <TableCell>{advertisement.title || '-'}</TableCell>
                     <TableCell>
-                      {advertisementTypes?.data.find(t => t.id === advertisement.typeId)?.name || '-'}
+                      {advertisement.advertisementType?.name || '-'}
                     </TableCell>
                     <TableCell>
                       <code className="text-xs bg-muted px-1 rounded">{advertisement.code || '-'}</code>
@@ -343,10 +336,10 @@ export const AdvertisementsPage = () => {
           )}
 
           <DataTablePagination
-            current={searchParams.page}
+            currentPage={searchParams.page}
             pageSize={searchParams.limit}
-            total={data?.pagination.total || 0}
-            onChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
+            totalCount={data?.pagination.total || 0}
+            onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
           />
         </CardContent>
       </Card>
@@ -391,17 +384,11 @@ export const AdvertisementsPage = () => {
                         广告类型 <span className="text-red-500 ml-1">*</span>
                       </FormLabel>
                       <FormControl>
-                        <select
-                          {...field}
-                          className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
-                          value={field.value || ''}
-                          onChange={(e) => field.onChange(parseInt(e.target.value))}
-                        >
-                          <option value="">请选择广告类型</option>
-                          {advertisementTypes?.data.map(type => (
-                            <option key={type.id} value={type.id}>{type.name}</option>
-                          ))}
-                        </select>
+                        <AdvertisementTypeSelector
+                          value={field.value}
+                          onChange={field.onChange}
+                          placeholder="请选择广告类型"
+                        />
                       </FormControl>
                       <FormMessage />
                     </FormItem>
@@ -570,17 +557,10 @@ export const AdvertisementsPage = () => {
                         广告类型 <span className="text-red-500 ml-1">*</span>
                       </FormLabel>
                       <FormControl>
-                        <select
-                          {...field}
-                          className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
-                          value={field.value || ''}
-                          onChange={(e) => field.onChange(parseInt(e.target.value))}
-                        >
-                          <option value="">请选择广告类型</option>
-                          {advertisementTypes?.data.map(type => (
-                            <option key={type.id} value={type.id}>{type.name}</option>
-                          ))}
-                        </select>
+                        <AdvertisementTypeSelector
+                          value={field.value}
+                          onChange={field.onChange}
+                        />
                       </FormControl>
                       <FormMessage />
                     </FormItem>

+ 2 - 20
src/client/api.ts

@@ -1,29 +1,11 @@
 import { hc } from 'hono/client'
-import axios from 'axios'
 import type { AuthRoutes } from '@/server/api'
 import type { UserRoutes } from '@/server/api'
 import type { RoleRoutes } from '@/server/api'
 import type { FileRoutes } from '@/server/api'
 import type { AdvertisementRoutes } from '@/server/api'
 import type { AdvertisementTypeRoutes } from '@/server/api'
-
-// 创建 axios fetch 适配器
-const axiosFetch = async (url: string, options?: RequestInit) => {
-  const response = await axios({
-    url,
-    method: options?.method || 'GET',
-    data: options?.body,
-    headers: {
-      'Content-Type': 'application/json',
-      ...options?.headers,
-    },
-    withCredentials: true,
-  })
-  return new Response(JSON.stringify(response.data), {
-    status: response.status,
-    headers: response.headers as any,
-  })
-}
+import { axiosFetch } from './utils/axios-fetch'
 
 // 创建客户端
 export const authClient = hc<AuthRoutes>('/', {
@@ -48,4 +30,4 @@ export const advertisementClient = hc<AdvertisementRoutes>('/', {
 
 export const advertisementTypeClient = hc<AdvertisementTypeRoutes>('/', {
   fetch: axiosFetch,
-}).api.v1.advertisementTypes
+}).api.v1['advertisement-types']

+ 54 - 0
src/client/utils/axios-fetch.ts

@@ -0,0 +1,54 @@
+import axios, { isAxiosError } from 'axios';
+// 创建 axios 适配器
+export const axiosFetch = async (url: RequestInfo | URL, init?: RequestInit) => {
+    const requestHeaders: Record<string, string> = {};
+  
+    if (init?.headers instanceof Headers) {
+      init.headers.forEach((value, key) => {
+        requestHeaders[key] = value;
+      })
+    }
+  
+    const response = await axios.request({
+      url: url.toString(),
+      method: init?.method || 'GET',
+      headers: requestHeaders,
+      data: init?.body,
+    }).catch((error) => {
+      console.log('axiosFetch error', error)
+  
+      if (isAxiosError(error)) {
+        return {
+          status: error.response?.status,
+          statusText: error.response?.statusText,
+          data: error.response?.data,
+          headers: error.response?.headers
+        }
+      }
+      throw error;
+    })
+  
+    const responseHeaders = new Headers();
+    if (response.headers) {
+      for (const [key, value] of Object.entries(response.headers)) {
+        responseHeaders.set(key, value);
+      }
+    }
+  
+  
+    // 处理204 No Content响应,不设置body
+    const body = response.status === 204
+      ? null
+      : responseHeaders.get('content-type')?.includes('application/json')
+        ? JSON.stringify(response.data)
+        : response.data;
+  
+    return new Response(
+      body,
+      {
+        status: response.status,
+        statusText: response.statusText,
+        headers: responseHeaders
+      }
+    )
+  }

+ 1 - 1
src/server/api/advertisements/index.ts

@@ -10,7 +10,7 @@ const advertisementRoutes = createCrudRoutes({
   getSchema: AdvertisementSchema,
   listSchema: AdvertisementSchema,
   searchFields: ['title', 'code'],
-  relations: ['imageFile'],
+  relations: ['imageFile', 'advertisementType'],
   middleware: [authMiddleware]
 });
 

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

@@ -1,5 +1,6 @@
 import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
 import { File } from '@/server/modules/files/file.entity';
+import { AdvertisementType } from './advertisement-type.entity';
 
 @Entity('ad')
 export class Advertisement {
@@ -19,6 +20,7 @@ export class Advertisement {
     name: 'type_id', 
     type: 'int',
     nullable: true,
+    unsigned: true,
     comment: '广告类型'
   })
   typeId!: number | null;
@@ -57,6 +59,13 @@ export class Advertisement {
   })
   imageFile!: File | null;
 
+  @ManyToOne(() => AdvertisementType, { nullable: true })
+  @JoinColumn({
+    name: 'type_id',
+    referencedColumnName: 'id'
+  })
+  advertisementType!: AdvertisementType | null;
+
   @Column({ 
     name: 'sort', 
     type: 'int',