Browse Source

✨ feat(mini-ui): add carousel component with multiple examples

- add Carousel component with customizable options: autoplay, interval, indicators, etc.
- implement 5 usage examples: basic, custom config, no indicators, circular indicators, top indicators
- support image lazy loading and error handling
- add click and change event callbacks
- include responsive design and rounded corner options
- add comprehensive documentation with usage instructions
yourname 3 months ago
parent
commit
14d6123cfd
2 changed files with 537 additions and 0 deletions
  1. 282 0
      .roo/commands/mini-ui-carousel.md
  2. 255 0
      mini/src/components/ui/carousel.tsx

+ 282 - 0
.roo/commands/mini-ui-carousel.md

@@ -0,0 +1,282 @@
+---
+description: "小程序轮播图组件使用指令"
+---
+
+```tsx
+import { View } from '@tarojs/components'
+import { Carousel, CarouselItem } from './carousel'
+import { useState } from 'react'
+import Taro from '@tarojs/taro'
+
+// 示例1:基础用法
+export function BasicCarouselExample() {
+  const items: CarouselItem[] = [
+    {
+      src: 'https://picsum.photos/400/200?random=1',
+      title: '第一张轮播图',
+      description: '这是第一张轮播图的描述文字'
+    },
+    {
+      src: 'https://picsum.photos/400/200?random=2',
+      title: '第二张轮播图',
+      description: '这是第二张轮播图的描述文字'
+    },
+    {
+      src: 'https://picsum.photos/400/200?random=3',
+      title: '第三张轮播图',
+      description: '这是第三张轮播图的描述文字'
+    }
+  ]
+
+  return (
+    <View className="p-4">
+      <View className="text-lg font-semibold mb-4">基础轮播图</View>
+      <Carousel items={items} />
+    </View>
+  )
+}
+
+// 示例2:自定义配置
+export function CustomCarouselExample() {
+  const items: CarouselItem[] = [
+    {
+      src: 'https://picsum.photos/400/300?random=4',
+      title: '精选商品',
+      description: '限时优惠,立即抢购'
+    },
+    {
+      src: 'https://picsum.photos/400/300?random=5',
+      title: '新品上市',
+      description: '最新款式,引领潮流'
+    },
+    {
+      src: 'https://picsum.photos/400/300?random=6',
+      title: '特价促销',
+      description: '全场5折起,不容错过'
+    }
+  ]
+
+  return (
+    <View className="p-4">
+      <View className="text-lg font-semibold mb-4">自定义配置轮播图</View>
+      <Carousel
+        items={items}
+        height={300}
+        interval={4000}
+        indicatorPosition="bottom"
+        rounded="lg"
+        onItemClick={(item, index) => {
+          console.log('点击了第', index + 1, '张轮播图:', item.title)
+        }}
+        onChange={(current) => {
+          console.log('切换到第', current + 1, '张')
+        }}
+      />
+    </View>
+  )
+}
+
+// 示例3:无指示器轮播
+export function NoIndicatorsCarouselExample() {
+  const items: CarouselItem[] = [
+    {
+      src: 'https://picsum.photos/400/150?random=7',
+      link: '/pages/product/1'
+    },
+    {
+      src: 'https://picsum.photos/400/150?random=8',
+      link: '/pages/product/2'
+    },
+    {
+      src: 'https://picsum.photos/400/150?random=9',
+      link: '/pages/product/3'
+    }
+  ]
+
+  return (
+    <View className="p-4">
+      <View className="text-lg font-semibold mb-4">无指示器轮播图</View>
+      <Carousel
+        items={items}
+        showIndicators={false}
+        height={150}
+        rounded="md"
+      />
+    </View>
+  )
+}
+
+// 示例4:圆形指示器轮播
+export function CircularIndicatorsCarouselExample() {
+  const items: CarouselItem[] = [
+    {
+      src: 'https://picsum.photos/400/250?random=10',
+      title: '活动预告'
+    },
+    {
+      src: 'https://picsum.photos/400/250?random=11',
+      title: '会员专享'
+    },
+    {
+      src: 'https://picsum.photos/400/250?random=12',
+      title: '积分兑换'
+    },
+    {
+      src: 'https://picsum.photos/400/250?random=13',
+      title: '限时秒杀'
+    }
+  ]
+
+  return (
+    <View className="p-4">
+      <View className="text-lg font-semibold mb-4">圆形指示器轮播图</View>
+      <Carousel
+        items={items}
+        autoplay={true}
+        interval={2000}
+        indicatorPosition="bottom"
+        rounded="xl"
+        height={250}
+      />
+    </View>
+  )
+}
+
+// 示例5:顶部指示器轮播
+export function TopIndicatorsCarouselExample() {
+  const items: CarouselItem[] = [
+    {
+      src: 'https://picsum.photos/400/180?random=14',
+      title: '顶部指示器',
+      description: '指示器位于顶部'
+    },
+    {
+      src: 'https://picsum.photos/400/180?random=15',
+      title: '美观设计',
+      description: '简洁优雅的界面'
+    },
+    {
+      src: 'https://picsum.photos/400/180?random=16',
+      title: '用户体验',
+      description: '流畅的交互体验'
+    }
+  ]
+
+  return (
+    <View className="p-4">
+      <View className="text-lg font-semibold mb-4">顶部指示器轮播图</View>
+      <Carousel
+        items={items}
+        indicatorPosition="top"
+        height={180}
+        rounded="lg"
+      />
+    </View>
+  )
+}
+
+// 综合示例:轮播图页面
+export function CarouselDemoPage() {
+  const [currentExample, setCurrentExample] = useState(0)
+
+  const examples = [
+    { title: '基础用法', component: BasicCarouselExample },
+    { title: '自定义配置', component: CustomCarouselExample },
+    { title: '无指示器', component: NoIndicatorsCarouselExample },
+    { title: '圆形指示器', component: CircularIndicatorsCarouselExample },
+    { title: '顶部指示器', component: TopIndicatorsCarouselExample }
+  ]
+
+  const CurrentComponent = examples[currentExample].component
+
+  return (
+    <View className="min-h-screen bg-gray-50">
+      <View className="p-4 bg-white">
+        <View className="text-xl font-bold text-center mb-4">
+          轮播图组件示例
+        </View>
+        
+        {/* 切换示例 */}
+        <View className="flex justify-center gap-2 mb-4">
+          {examples.map((example, index) => (
+            <View
+              key={index}
+              className={`px-3 py-1 text-sm rounded-full cursor-pointer ${
+                currentExample === index
+                  ? 'bg-blue-500 text-white'
+                  : 'bg-gray-200 text-gray-700'
+              }`}
+              onClick={() => setCurrentExample(index)}
+            >
+              {example.title}
+            </View>
+          ))}
+        </View>
+      </View>
+
+      <View className="p-4">
+        <CurrentComponent />
+      </View>
+
+      {/* 使用说明 */}
+      <View className="p-4 bg-white mt-4">
+        <View className="text-lg font-semibold mb-2">使用说明</View>
+        <View className="text-sm text-gray-600 space-y-1">
+          <View>• 支持自动播放和手动切换</View>
+          <View>• 可自定义指示器位置和样式</View>
+          <View>• 支持点击事件和切换回调</View>
+          <View>• 内置图片懒加载和错误处理</View>
+          <View>• 响应式设计,适配不同屏幕尺寸</View>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+// 实际页面使用示例
+export function HomeCarousel() {
+  const bannerItems: CarouselItem[] = [
+    {
+      src: 'https://via.placeholder.com/750x400/3B82F6/FFFFFF?text=Banner+1',
+      title: '新品上市',
+      description: '最新款式,限时优惠',
+      link: '/pages/goods/new-arrival'
+    },
+    {
+      src: 'https://via.placeholder.com/750x400/EF4444/FFFFFF?text=Banner+2',
+      title: '限时秒杀',
+      description: '每日特价,不容错过',
+      link: '/pages/goods/flash-sale'
+    },
+    {
+      src: 'https://via.placeholder.com/750x400/10B981/FFFFFF?text=Banner+3',
+      title: '会员专享',
+      description: '会员专享折扣和福利',
+      link: '/pages/member/benefits'
+    }
+  ]
+
+  const handleBannerClick = (item: CarouselItem, index: number) => {
+    if (item.link) {
+      // 使用Taro跳转
+      Taro.navigateTo({
+        url: item.link
+      })
+    }
+  }
+
+  return (
+    <View className="w-full">
+      <Carousel
+        items={bannerItems}
+        height={400}
+        autoplay={true}
+        interval={4000}
+        circular={true}
+        rounded="none"
+        onItemClick={handleBannerClick}
+      />
+    </View>
+  )
+}
+```

+ 255 - 0
mini/src/components/ui/carousel.tsx

@@ -0,0 +1,255 @@
+import { View, Swiper, SwiperItem, Text } from '@tarojs/components'
+import { useState, useEffect, useRef } from 'react'
+import { cn } from '@/utils/cn'
+import { Image } from './image'
+import Taro from '@tarojs/taro'
+
+export interface CarouselItem {
+  /** 图片地址 */
+  src: string
+  /** 图片标题 */
+  title?: string
+  /** 图片描述 */
+  description?: string
+  /** 点击跳转链接 */
+  link?: string
+  /** 自定义样式 */
+  className?: string
+}
+
+export interface CarouselProps {
+  /** 轮播图数据 */
+  items: CarouselItem[]
+  /** 是否自动播放 */
+  autoplay?: boolean
+  /** 自动播放间隔时间(毫秒) */
+  interval?: number
+  /** 是否显示指示器 */
+  showIndicators?: boolean
+  /** 指示器位置 */
+  indicatorPosition?: 'bottom' | 'top' | 'left' | 'right'
+  /** 是否循环播放 */
+  circular?: boolean
+  /** 图片填充模式 */
+  imageMode?: 'scaleToFill' | 'aspectFit' | 'aspectFill' | 'widthFix' | 'top' | 'bottom' | 'center' | 'left' | 'right'
+  /** 图片高度 */
+  height?: number | string
+  /** 圆角大小 */
+  rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
+  /** 点击轮播图回调 */
+  onItemClick?: (item: CarouselItem, index: number) => void
+  /** 轮播切换回调 */
+  onChange?: (current: number) => void
+  /** 自定义样式类 */
+  className?: string
+}
+
+const roundedMap = {
+  none: '',
+  sm: 'rounded-sm',
+  md: 'rounded-md',
+  lg: 'rounded-lg',
+  xl: 'rounded-xl',
+  full: 'rounded-full'
+}
+
+const indicatorPositionMap = {
+  bottom: 'bottom-4 left-1/2 -translate-x-1/2',
+  top: 'top-4 left-1/2 -translate-x-1/2',
+  left: 'left-4 top-1/2 -translate-y-1/2 flex-col',
+  right: 'right-4 top-1/2 -translate-y-1/2 flex-col'
+}
+
+export function Carousel({
+  items,
+  autoplay = true,
+  interval = 3000,
+  showIndicators = true,
+  indicatorPosition = 'bottom',
+  circular = true,
+  imageMode = 'aspectFill',
+  height = 200,
+  rounded = 'md',
+  onItemClick,
+  onChange,
+  className
+}: CarouselProps) {
+  const [currentIndex, setCurrentIndex] = useState(0)
+  const swiperRef = useRef<any>(null)
+  const autoplayTimerRef = useRef<NodeJS.Timeout | null>(null)
+
+  // 处理轮播切换
+  const handleChange = (e: any) => {
+    const index = e.detail.current
+    setCurrentIndex(index)
+    onChange?.(index)
+  }
+
+  // 处理图片点击
+  const handleItemClick = (item: CarouselItem, index: number) => {
+    onItemClick?.(item, index)
+  }
+
+  // 处理指示器点击
+  const handleIndicatorClick = (index: number) => {
+    setCurrentIndex(index)
+    swiperRef.current?.swiperTo?.(index)
+  }
+
+  // 自动播放
+  useEffect(() => {
+    if (autoplay && items.length > 1) {
+      autoplayTimerRef.current = setInterval(() => {
+        const nextIndex = (currentIndex + 1) % items.length
+        setCurrentIndex(nextIndex)
+        swiperRef.current?.swiperTo?.(nextIndex)
+      }, interval)
+    }
+
+    return () => {
+      if (autoplayTimerRef.current) {
+        clearInterval(autoplayTimerRef.current)
+      }
+    }
+  }, [autoplay, currentIndex, interval, items.length])
+
+  // 页面隐藏时清除定时器
+  useEffect(() => {
+    const handleHide = () => {
+      if (autoplayTimerRef.current) {
+        clearInterval(autoplayTimerRef.current)
+      }
+    }
+
+    const handleShow = () => {
+      if (autoplay && items.length > 1) {
+        autoplayTimerRef.current = setInterval(() => {
+          const nextIndex = (currentIndex + 1) % items.length
+          setCurrentIndex(nextIndex)
+          swiperRef.current?.swiperTo?.(nextIndex)
+        }, interval)
+      }
+    }
+
+    // Taro 事件监听
+    if (typeof Taro !== 'undefined') {
+      Taro.eventCenter.on('onHide', handleHide)
+      Taro.eventCenter.on('onShow', handleShow)
+    }
+
+    return () => {
+      if (typeof Taro !== 'undefined') {
+        Taro.eventCenter.off('onHide', handleHide)
+        Taro.eventCenter.off('onShow', handleShow)
+      }
+    }
+  }, [autoplay, currentIndex, interval, items.length])
+
+  if (!items || items.length === 0) {
+    return (
+      <View className={cn('w-full bg-gray-100 flex items-center justify-center', roundedMap[rounded], className)}>
+        <View className="text-center">
+          <View className="i-heroicons-photo-20-solid w-12 h-12 text-gray-400 mb-2" />
+          <Text className="text-gray-500 text-sm">暂无图片</Text>
+        </View>
+      </View>
+    )
+  }
+
+  return (
+    <View className={cn('relative w-full overflow-hidden', roundedMap[rounded], className)}>
+      <Swiper
+        ref={swiperRef}
+        className="w-full"
+        current={currentIndex}
+        autoplay={false}
+        circular={circular}
+        onChange={handleChange}
+        style={{ height: typeof height === 'number' ? `${height}rpx` : height }}
+      >
+        {items.map((item, index) => (
+          <SwiperItem key={index} className="w-full h-full">
+            <View 
+              className="w-full h-full relative"
+              onClick={() => handleItemClick(item, index)}
+            >
+              <Image
+                src={item.src}
+                mode={imageMode}
+                className="w-full h-full"
+                lazyLoad
+                showLoading
+                showError
+              />
+              {item.title && (
+                <View className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-4">
+                  {item.title && (
+                    <Text className="text-white text-lg font-semibold">{item.title}</Text>
+                  )}
+                  {item.description && (
+                    <Text className="text-white/80 text-sm mt-1">{item.description}</Text>
+                  )}
+                </View>
+              )}
+            </View>
+          </SwiperItem>
+        ))}
+      </Swiper>
+
+      {/* 指示器 */}
+      {showIndicators && items.length > 1 && (
+        <View className={cn(
+          'absolute flex items-center gap-2 z-10',
+          indicatorPositionMap[indicatorPosition]
+        )}>
+          {items.map((_, index) => (
+            <View
+              key={index}
+              className={cn(
+                'w-2 h-2 rounded-full transition-all duration-300',
+                index === currentIndex
+                  ? 'bg-white scale-125'
+                  : 'bg-white/50'
+              )}
+              onClick={() => handleIndicatorClick(index)}
+            />
+          ))}
+        </View>
+      )}
+
+      {/* 左右箭头 - 可选 */}
+      {items.length > 1 && (
+        <>
+          <View 
+            className={cn(
+              'absolute left-2 top-1/2 -translate-y-1/2 z-10',
+              'w-8 h-8 bg-black/30 rounded-full flex items-center justify-center',
+              'opacity-0 group-hover:opacity-100 transition-opacity'
+            )}
+            onClick={() => {
+              const prevIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1
+              setCurrentIndex(prevIndex)
+              swiperRef.current?.swiperTo?.(prevIndex)
+            }}
+          >
+            <View className="i-heroicons-chevron-left-20-solid text-white w-4 h-4" />
+          </View>
+          <View 
+            className={cn(
+              'absolute right-2 top-1/2 -translate-y-1/2 z-10',
+              'w-8 h-8 bg-black/30 rounded-full flex items-center justify-center',
+              'opacity-0 group-hover:opacity-100 transition-opacity'
+            )}
+            onClick={() => {
+              const nextIndex = (currentIndex + 1) % items.length
+              setCurrentIndex(nextIndex)
+              swiperRef.current?.swiperTo?.(nextIndex)
+            }}
+          >
+            <View className="i-heroicons-chevron-right-20-solid text-white w-4 h-4" />
+          </View>
+        </>
+      )}
+    </View>
+  )
+}