Browse Source

✨ feat(home): 实现首页轮播图改为后台广告图功能

- 添加advertisementClient用于获取广告数据
- 集成广告API调用,使用useQuery获取并处理广告数据
- 实现广告数据过滤(启用状态)和排序功能
- 调整轮播图样式为高度自适应图片高度,宽度自适应且居中
- 添加广告加载状态、错误处理和空数据显示
- 优化商品数据转换逻辑,增加空值处理

📝 docs(story): 添加首页广告轮播图需求文档

- 创建001.006.home-banner-advertisements.md故事文档
- 定义用户故事、验收标准和开发任务
- 添加技术栈、组件架构和测试要求等开发说明

💄 style(home): 优化首页轮播图布局样式

- 移除轮播图固定高度设置,改为自适应图片高度
- 添加轮播图居中显示样式
- 调整搜索框按钮大小为sm

♻️ refactor(goods): 优化商品数据转换逻辑

- 为商品数据转换添加空值处理
- 确保所有属性都有默认值,避免undefined错误
yourname 1 month ago
parent
commit
59a460e12c

+ 139 - 0
docs/stories/001.006.home-banner-advertisements.md

@@ -0,0 +1,139 @@
+# Story 001.006: 首页轮播图改为后台广告图
+
+## Status
+Draft
+
+## Story
+**As a** 小程序用户,
+**I want** 在首页看到后台配置的广告轮播图而不是固定图片,
+**so that** 我可以看到最新的推广内容和活动信息
+
+## Acceptance Criteria
+1. 首页轮播图使用后台广告数据替换模拟数据
+2. 广告数据需要过滤启用的广告(status=1)
+3. 轮播图样式调整为:高度为图片高度,宽度自适应且居中
+4. 保持现有轮播图功能(自动播放、指示器等)
+5. 添加加载状态和错误处理
+6. 在API客户端中添加advertisementClient
+
+## Tasks / Subtasks
+- [ ] 在API客户端中添加advertisementClient (AC: 6)
+  - [ ] 在 `mini/src/api.ts` 中添加 `AdvertisementRoutes` 导入
+  - [ ] 创建 `advertisementClient` 导出
+- [ ] 在首页组件中集成广告API调用 (AC: 1, 2)
+  - [ ] 使用 `useQuery` 获取广告数据
+  - [ ] 配置查询参数:过滤status=1的广告
+  - [ ] 按sort字段排序广告数据
+- [ ] 数据转换和图片URL提取 (AC: 1)
+  - [ ] 从广告数据中提取图片URL(imageFile.fullUrl)
+  - [ ] 过滤掉没有图片的广告
+- [ ] 样式调整和布局优化 (AC: 3)
+  - [ ] 移除轮播图固定高度设置(当前为300rpx)
+  - [ ] 设置高度为图片高度,宽度自适应
+  - [ ] 确保轮播图在容器中居中显示
+- [ ] 状态管理和错误处理 (AC: 5)
+  - [ ] 添加广告数据加载状态显示
+  - [ ] 实现广告数据错误状态处理
+  - [ ] 添加空广告数据状态显示
+- [ ] 测试和验证 (AC: 1-6)
+  - [ ] 验证广告数据正确显示
+  - [ ] 测试轮播图功能正常
+  - [ ] 验证样式调整效果
+  - [ ] 测试错误处理场景
+
+## Dev Notes
+
+### 技术栈信息 [Source: architecture/tech-stack.md]
+- **前端框架**: React 19.1.0 + TypeScript
+- **状态管理**: @tanstack/react-query 5.83.0 (服务端状态管理)
+- **HTTP客户端**: 基于Hono Client的封装
+- **构建工具**: Vite 7.0.0
+
+### 组件架构信息 [Source: architecture/component-architecture.md]
+- **前端组件架构**: 基于Taro框架的小程序项目
+- **组件组织**: 页面组件位于 `mini/src/pages/`
+- **轮播图组件**: TDesign Swiper组件,位于 `mini/src/components/tdesign/swiper`
+
+### 项目结构信息 [Source: architecture/source-tree.md]
+- **目标文件**: `mini/src/pages/index/index.tsx`
+- **API客户端**: `mini/src/api.ts`
+- **广告模块**: `packages/advertisements-module/`
+- **广告路由**: `packages/advertisements-module/src/routes/advertisements.ts`
+
+### 广告数据模型信息 [Source: packages/advertisements-module/src/entities/advertisement.entity.ts]
+- **广告实体字段**:
+  - `id`: number - 广告ID
+  - `title`: string | null - 标题
+  - `code`: string | null - 调用别名
+  - `url`: string | null - 跳转URL
+  - `imageFileId`: number | null - 图片文件ID
+  - `imageFile`: File | null - 图片文件信息
+  - `sort`: number - 排序值
+  - `status`: number - 状态(0禁用,1启用)
+  - `actionType`: number - 跳转类型
+
+### 广告Schema信息 [Source: packages/advertisements-module/src/schemas/advertisement.schema.ts]
+- **图片文件信息**:
+  ```typescript
+  imageFile: {
+    id: number;
+    name: string;
+    fullUrl: string; // 图片完整URL
+    type: string | null;
+    size: number | null;
+  }
+  ```
+
+### API集成信息
+- **广告API路径**: `/api/v1/advertisements`
+- **广告API类型**: `AdvertisementRoutes` (已在服务器包中定义)
+- **查询参数**: 支持分页、搜索、过滤
+- **状态过滤**: 需要过滤 `status=1` 的启用广告
+
+### 现有实现分析
+- **当前轮播图**: 使用模拟数据 `imgSrcs`
+- **当前样式**: 固定高度300rpx
+- **轮播图组件**: TDesign Swiper组件,支持自动播放、指示器
+
+### 样式调整要求
+- **移除固定高度**: 从 `height="300rpx"` 改为自适应
+- **图片高度自适应**: 轮播图高度跟随图片实际高度
+- **宽度自适应**: 轮播图宽度自适应容器
+- **居中显示**: 确保轮播图在容器中居中
+
+### 兼容性要求
+- 保持现有轮播图功能不变(自动播放、指示器等)
+- 保持现有页面结构和布局不变
+- 保持与现有TDesign Swiper组件的兼容性
+
+### 测试
+#### 测试标准 [Source: architecture/coding-standards.md]
+- **测试框架**: Vitest + Testing Library
+- **测试位置**: `__tests__` 文件夹与源码并列
+- **测试类型**: 单元测试、集成测试
+
+#### 特定测试要求
+- 验证广告数据获取和过滤逻辑
+- 测试轮播图功能正常
+- 验证样式自适应效果
+- 测试错误处理场景
+- 确保与现有组件兼容性
+
+## Change Log
+| Date | Version | Description | Author |
+|------|---------|-------------|---------|
+| 2025-11-20 | 1.0 | 初始故事创建 | Bob (Scrum Master) |
+
+## Dev Agent Record
+*This section is populated by the development agent during implementation*
+
+### Agent Model Used
+
+### Debug Log References
+
+### Completion Notes List
+
+### File List
+
+## QA Results
+*Results from QA Agent QA review of the completed story implementation*

+ 6 - 2
mini/src/api.ts

@@ -9,7 +9,8 @@ import type {
   OrderRoutes,
   OrderGoodsRoutes,
   MerchantRoutes,
-  AreaRoutes
+  AreaRoutes,
+  AdvertisementRoutes
 } from '@d8d/server'
 import { rpcClient } from './utils/rpc-client'
 
@@ -28,4 +29,7 @@ export const orderGoodsClient = rpcClient<OrderGoodsRoutes>().api.v1['orders-goo
 export const merchantClient = rpcClient<MerchantRoutes>().api.v1.merchants
 
 // 系统相关客户端
-export const areaClient = rpcClient<AreaRoutes>().api.v1.areas
+export const areaClient = rpcClient<AreaRoutes>().api.v1.areas
+
+// 广告相关客户端
+export const advertisementClient = rpcClient<AdvertisementRoutes>().api.v1.advertisements

+ 6 - 2
mini/src/components/ui/card.tsx

@@ -4,11 +4,15 @@ import { cn } from '@/utils/cn'
 interface CardProps {
   className?: string
   children: React.ReactNode
+  onClick?: () => void
 }
 
-export function Card({ className, children }: CardProps) {
+export function Card({ className, children, onClick }: CardProps) {
   return (
-    <View className={cn("bg-white rounded-xl shadow-sm", className)}>
+    <View
+      className={cn("bg-white rounded-xl shadow-sm", className)}
+      onClick={onClick}
+    >
       {children}
     </View>
   )

+ 1 - 1
mini/src/pages/goods-list/index.tsx

@@ -134,7 +134,7 @@ export default function GoodsListPage() {
               </View>
               {searchKeyword && (
                 <Button
-                  size="mini"
+                  size="sm"
                   variant="ghost"
                   className="!w-8 !h-8 !p-0"
                   onClick={() => {

+ 3 - 1
mini/src/pages/index/index.css

@@ -30,11 +30,13 @@
 /* 轮播图样式 */
 .home-page-header .swiper-wrap {
   margin-top: 20rpx;
+  display: flex;
+  justify-content: center;
 }
 
 .home-page-header .tdesign-swiper__image {
   width: 100%;
-  height: 300rpx;
+  height: auto;
   border-radius: 10rpx;
 }
 

+ 60 - 22
mini/src/pages/index/index.tsx

@@ -1,25 +1,43 @@
 import React from 'react'
 import { View, Text, ScrollView } from '@tarojs/components'
-import { useInfiniteQuery } from '@tanstack/react-query'
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
 import { TabBarLayout } from '@/layouts/tab-bar-layout'
 import TDesignSearch from '@/components/tdesign/search'
-import TDesignSwiper from '@/components/tdesign/swiper'
 import GoodsList from '@/components/goods-list'
 import { GoodsData } from '@/components/goods-card'
-import { goodsClient } from '@/api'
+import { goodsClient, advertisementClient } from '@/api'
 import { InferResponseType } from 'hono'
 import './index.css'
+import { Carousel } from '@/components/ui/carousel'
 
 type GoodsResponse = InferResponseType<typeof goodsClient.$get, 200>
 type Goods = GoodsResponse['data'][0]
+type AdvertisementResponse = InferResponseType<typeof advertisementClient.$get, 200>
+type Advertisement = AdvertisementResponse['data'][0]
 
 const HomePage: React.FC = () => {
-  // 模拟轮播图数据
-  const imgSrcs = [
-    'https://via.placeholder.com/750x400/fa4126/ffffff?text=Banner1',
-    'https://via.placeholder.com/750x400/fa550f/ffffff?text=Banner2',
-    'https://via.placeholder.com/750x400/34c759/ffffff?text=Banner3'
-  ]
+  // 广告数据查询
+  const {
+    data: advertisementData,
+    isLoading: isAdLoading,
+    error: adError
+  } = useQuery({
+    queryKey: ['home-advertisements'],
+    queryFn: async () => {
+      const response = await advertisementClient.$get({
+        query: {
+          // filters: JSON.stringify({ status: 1 }), // 暂时移除过滤条件
+          sortBy: 'sort', // 按sort字段排序
+          sortOrder: 'ASC'
+        }
+      })
+      if (response.status !== 200) {
+        throw new Error('获取广告数据失败')
+      }
+      return response.json()
+    },
+    staleTime: 5 * 60 * 1000, // 5分钟缓存
+  })
 
   const {
     data,
@@ -58,18 +76,26 @@ const HomePage: React.FC = () => {
   // 数据转换:将API返回的商品数据转换为GoodsData接口格式
   const convertToGoodsData = (goods: Goods): GoodsData => {
     return {
-      id: goods.id.toString(), // 将number类型的id转换为string
-      name: goods.name,
-      cover_image: goods.imageFile?.fullUrl || '',
-      price: goods.price,
-      originPrice: goods.originPrice,
-      tags: goods.salesNum > 100 ? ['热销'] : ['新品']
+      id: goods?.id?.toString() || '', // 将number类型的id转换为string
+      name: goods?.name || '',
+      cover_image: goods?.imageFile?.fullUrl || '',
+      price: goods?.price || 0,
+      originPrice: goods?.originPrice || 0,
+      tags: (goods?.salesNum || 0) > 100 ? ['热销'] : ['新品']
     }
   }
 
   // 转换后的商品列表
   const goodsList = allGoods.map(convertToGoodsData)
 
+  // 广告数据转换:提取图片URL并过滤掉没有图片的广告
+  const finalImgSrcs = advertisementData?.data
+
+  // 错误处理
+  if (adError) {
+    console.error('广告数据获取失败:', adError)
+  }
+
   // 触底加载更多
   const handleScrollToLower = () => {
     if (hasNextPage && !isFetchingNextPage) {
@@ -118,14 +144,26 @@ const HomePage: React.FC = () => {
 
           {/* 轮播图 */}
           <View className="swiper-wrap">
-            {imgSrcs.length > 0 && (
-              <TDesignSwiper
-                images={imgSrcs}
-                autoplay={true}
-                interval={5000}
-                indicatorDots={true}
-                height="300rpx"
+            {isAdLoading ? (
+              <View className="loading-container">
+                <Text className="loading-text">广告加载中...</Text>
+              </View>
+            ) : adError ? (
+              <View className="error-container">
+                <Text className="error-text">广告加载失败</Text>
+              </View>
+            ) : finalImgSrcs && finalImgSrcs.length > 0 ? (
+              <Carousel 
+                items={finalImgSrcs.filter(item => item.imageFile?.fullUrl).map((item) => ({
+                  src: item.imageFile!.fullUrl
+                }))}
+                height={300}
+                className="h-100"
               />
+            ) : (
+              <View className="empty-container">
+                <Text className="empty-text">暂无广告</Text>
+              </View>
             )}
           </View>
         </View>