carousel.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { View, Swiper, SwiperItem, Text } from '@tarojs/components'
  2. import { useState, useEffect, useRef } from 'react'
  3. import { cn } from '@/utils/cn'
  4. import { Image } from './image'
  5. import Taro from '@tarojs/taro'
  6. export interface CarouselItem {
  7. /** 图片地址 */
  8. src: string
  9. /** 图片标题 */
  10. title?: string
  11. /** 图片描述 */
  12. description?: string
  13. /** 点击跳转链接 */
  14. link?: string
  15. /** 自定义样式 */
  16. className?: string
  17. }
  18. export interface CarouselProps {
  19. /** 轮播图数据 */
  20. items: CarouselItem[]
  21. /** 是否自动播放 */
  22. autoplay?: boolean
  23. /** 自动播放间隔时间(毫秒) */
  24. interval?: number
  25. /** 是否显示指示器 */
  26. showIndicators?: boolean
  27. /** 指示器位置 */
  28. indicatorPosition?: 'bottom' | 'top' | 'left' | 'right'
  29. /** 是否循环播放 */
  30. circular?: boolean
  31. /** 图片填充模式 */
  32. imageMode?: 'scaleToFill' | 'aspectFit' | 'aspectFill' | 'widthFix' | 'top' | 'bottom' | 'center' | 'left' | 'right'
  33. /** 图片高度 */
  34. height?: number | string
  35. /** 圆角大小 */
  36. rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
  37. /** 点击轮播图回调 */
  38. onItemClick?: (item: CarouselItem, index: number) => void
  39. /** 轮播切换回调 */
  40. onChange?: (current: number) => void
  41. /** 自定义样式类 */
  42. className?: string
  43. }
  44. const roundedMap = {
  45. none: '',
  46. sm: 'rounded-sm',
  47. md: 'rounded-md',
  48. lg: 'rounded-lg',
  49. xl: 'rounded-xl',
  50. full: 'rounded-full'
  51. }
  52. const indicatorPositionMap = {
  53. bottom: 'bottom-4 left-1/2 -translate-x-1/2',
  54. top: 'top-4 left-1/2 -translate-x-1/2',
  55. left: 'left-4 top-1/2 -translate-y-1/2 flex-col',
  56. right: 'right-4 top-1/2 -translate-y-1/2 flex-col'
  57. }
  58. export function Carousel({
  59. items,
  60. autoplay = true,
  61. interval = 3000,
  62. showIndicators = true,
  63. indicatorPosition = 'bottom',
  64. circular = true,
  65. imageMode = 'aspectFill',
  66. height = 200,
  67. rounded = 'md',
  68. onItemClick,
  69. onChange,
  70. className
  71. }: CarouselProps) {
  72. const [currentIndex, setCurrentIndex] = useState(0)
  73. const swiperRef = useRef<any>(null)
  74. const autoplayTimerRef = useRef<NodeJS.Timeout | null>(null)
  75. // 处理轮播切换
  76. const handleChange = (e: any) => {
  77. const index = e.detail.current
  78. setCurrentIndex(index)
  79. onChange?.(index)
  80. }
  81. // 处理图片点击
  82. const handleItemClick = (item: CarouselItem, index: number) => {
  83. onItemClick?.(item, index)
  84. }
  85. // 处理指示器点击
  86. const handleIndicatorClick = (index: number) => {
  87. setCurrentIndex(index)
  88. swiperRef.current?.swiperTo?.(index)
  89. }
  90. // 自动播放
  91. useEffect(() => {
  92. if (autoplay && items.length > 1) {
  93. autoplayTimerRef.current = setInterval(() => {
  94. const nextIndex = (currentIndex + 1) % items.length
  95. setCurrentIndex(nextIndex)
  96. swiperRef.current?.swiperTo?.(nextIndex)
  97. }, interval)
  98. }
  99. return () => {
  100. if (autoplayTimerRef.current) {
  101. clearInterval(autoplayTimerRef.current)
  102. }
  103. }
  104. }, [autoplay, currentIndex, interval, items.length])
  105. // 页面隐藏时清除定时器
  106. useEffect(() => {
  107. const handleHide = () => {
  108. if (autoplayTimerRef.current) {
  109. clearInterval(autoplayTimerRef.current)
  110. }
  111. }
  112. const handleShow = () => {
  113. if (autoplay && items.length > 1) {
  114. autoplayTimerRef.current = setInterval(() => {
  115. const nextIndex = (currentIndex + 1) % items.length
  116. setCurrentIndex(nextIndex)
  117. swiperRef.current?.swiperTo?.(nextIndex)
  118. }, interval)
  119. }
  120. }
  121. // Taro 事件监听
  122. if (typeof Taro !== 'undefined') {
  123. Taro.eventCenter.on('onHide', handleHide)
  124. Taro.eventCenter.on('onShow', handleShow)
  125. }
  126. return () => {
  127. if (typeof Taro !== 'undefined') {
  128. Taro.eventCenter.off('onHide', handleHide)
  129. Taro.eventCenter.off('onShow', handleShow)
  130. }
  131. }
  132. }, [autoplay, currentIndex, interval, items.length])
  133. if (!items || items.length === 0) {
  134. return (
  135. <View className={cn('w-full bg-gray-100 flex items-center justify-center', roundedMap[rounded], className)}>
  136. <View className="text-center">
  137. <View className="i-heroicons-photo-20-solid w-12 h-12 text-gray-400 mb-2" />
  138. <Text className="text-gray-500 text-sm">暂无图片</Text>
  139. </View>
  140. </View>
  141. )
  142. }
  143. return (
  144. <View className={cn('relative w-full overflow-hidden', roundedMap[rounded], className)}>
  145. <Swiper
  146. ref={swiperRef}
  147. className="w-full"
  148. current={currentIndex}
  149. autoplay={false}
  150. circular={circular}
  151. onChange={handleChange}
  152. style={{ height: typeof height === 'number' ? `${height}rpx` : height }}
  153. >
  154. {items.map((item, index) => (
  155. <SwiperItem key={index} className="w-full h-full">
  156. <View
  157. className="w-full h-full relative"
  158. onClick={() => handleItemClick(item, index)}
  159. >
  160. <Image
  161. src={item.src}
  162. mode={imageMode}
  163. className="w-full h-full"
  164. lazyLoad
  165. showLoading
  166. showError
  167. />
  168. {item.title && (
  169. <View className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-4">
  170. {item.title && (
  171. <Text className="text-white text-lg font-semibold">{item.title}</Text>
  172. )}
  173. {item.description && (
  174. <Text className="text-white/80 text-sm mt-1">{item.description}</Text>
  175. )}
  176. </View>
  177. )}
  178. </View>
  179. </SwiperItem>
  180. ))}
  181. </Swiper>
  182. {/* 指示器 */}
  183. {showIndicators && items.length > 1 && (
  184. <View className={cn(
  185. 'absolute flex items-center gap-2 z-10',
  186. indicatorPositionMap[indicatorPosition]
  187. )}>
  188. {items.map((_, index) => (
  189. <View
  190. key={index}
  191. className={cn(
  192. 'w-2 h-2 rounded-full transition-all duration-300',
  193. index === currentIndex
  194. ? 'bg-white scale-125'
  195. : 'bg-white/50'
  196. )}
  197. onClick={() => handleIndicatorClick(index)}
  198. />
  199. ))}
  200. </View>
  201. )}
  202. {/* 左右箭头 - 可选 */}
  203. {items.length > 1 && (
  204. <>
  205. <View
  206. className={cn(
  207. 'absolute left-2 top-1/2 -translate-y-1/2 z-10',
  208. 'w-8 h-8 bg-black/30 rounded-full flex items-center justify-center',
  209. 'opacity-0 group-hover:opacity-100 transition-opacity'
  210. )}
  211. onClick={() => {
  212. const prevIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1
  213. setCurrentIndex(prevIndex)
  214. swiperRef.current?.swiperTo?.(prevIndex)
  215. }}
  216. >
  217. <View className="i-heroicons-chevron-left-20-solid text-white w-4 h-4" />
  218. </View>
  219. <View
  220. className={cn(
  221. 'absolute right-2 top-1/2 -translate-y-1/2 z-10',
  222. 'w-8 h-8 bg-black/30 rounded-full flex items-center justify-center',
  223. 'opacity-0 group-hover:opacity-100 transition-opacity'
  224. )}
  225. onClick={() => {
  226. const nextIndex = (currentIndex + 1) % items.length
  227. setCurrentIndex(nextIndex)
  228. swiperRef.current?.swiperTo?.(nextIndex)
  229. }}
  230. >
  231. <View className="i-heroicons-chevron-right-20-solid text-white w-4 h-4" />
  232. </View>
  233. </>
  234. )}
  235. </View>
  236. )
  237. }