index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { View, ScrollView, Text } from '@tarojs/components'
  2. import { useInfiniteQuery } from '@tanstack/react-query'
  3. import { useState } from 'react'
  4. import Taro from '@tarojs/taro'
  5. import { goodsClient } from '@/api'
  6. import { InferResponseType } from 'hono'
  7. import { Navbar } from '@/components/ui/navbar'
  8. import { Card } from '@/components/ui/card'
  9. import { Button } from '@/components/ui/button'
  10. import { useCart } from '@/utils/cart'
  11. import { Image } from '@/components/ui/image'
  12. import { Input } from '@/components/ui/input'
  13. import { TabBarLayout } from '@/layouts/tab-bar-layout'
  14. type GoodsResponse = InferResponseType<typeof goodsClient.$get, 200>
  15. type Goods = GoodsResponse['data'][0]
  16. export default function GoodsListPage() {
  17. const [searchKeyword, setSearchKeyword] = useState('')
  18. const [activeCategory, setActiveCategory] = useState('all')
  19. const { addToCart } = useCart()
  20. const categories = [
  21. { id: 'all', name: '全部' },
  22. { id: 'hot', name: '热销' },
  23. { id: 'new', name: '新品' },
  24. ]
  25. const {
  26. data,
  27. isLoading,
  28. isFetchingNextPage,
  29. fetchNextPage,
  30. hasNextPage,
  31. refetch
  32. } = useInfiniteQuery({
  33. queryKey: ['goods-infinite', searchKeyword],
  34. queryFn: async ({ pageParam = 1 }) => {
  35. const response = await goodsClient.$get({
  36. query: {
  37. page: pageParam,
  38. pageSize: 10,
  39. keyword: searchKeyword,
  40. filters: JSON.stringify({ state: 1 }) // 只显示可用的商品
  41. }
  42. })
  43. if (response.status !== 200) {
  44. throw new Error('获取商品失败')
  45. }
  46. return response.json()
  47. },
  48. getNextPageParam: (lastPage) => {
  49. const { pagination } = lastPage
  50. const totalPages = Math.ceil(pagination.total / pagination.pageSize)
  51. return pagination.current < totalPages ? pagination.current + 1 : undefined
  52. },
  53. staleTime: 5 * 60 * 1000,
  54. initialPageParam: 1,
  55. })
  56. // 合并所有分页数据
  57. const allGoods = data?.pages.flatMap(page => page.data) || []
  58. // 触底加载更多
  59. const handleScrollToLower = () => {
  60. if (hasNextPage && !isFetchingNextPage) {
  61. fetchNextPage()
  62. }
  63. }
  64. // 下拉刷新
  65. const onPullDownRefresh = () => {
  66. refetch().finally(() => {
  67. Taro.stopPullDownRefresh()
  68. })
  69. }
  70. // 跳转到商品详情
  71. const handleGoodsClick = (goods: Goods) => {
  72. Taro.navigateTo({
  73. url: `/pages/goods-detail/index?id=${goods.id}`
  74. })
  75. }
  76. // 添加到购物车
  77. const handleAddToCart = (goods: Goods) => {
  78. addToCart({
  79. id: goods.id,
  80. name: goods.name,
  81. price: goods.price,
  82. image: goods.imageFile?.fullUrl || '',
  83. stock: goods.stock,
  84. quantity: 1
  85. })
  86. Taro.showToast({
  87. title: '已添加到购物车',
  88. icon: 'success'
  89. })
  90. }
  91. return (
  92. <TabBarLayout activeKey="goods-list">
  93. <Navbar
  94. title="商品列表"
  95. leftIcon="i-heroicons-chevron-left-20-solid"
  96. onClickLeft={() => Taro.navigateBack()}
  97. className="bg-white shadow-sm"
  98. />
  99. <ScrollView
  100. className="flex-1"
  101. scrollY
  102. onScrollToLower={handleScrollToLower}
  103. refresherEnabled
  104. refresherTriggered={false}
  105. onRefresherRefresh={onPullDownRefresh}
  106. >
  107. <View className="px-4 py-4">
  108. {/* 搜索栏 */}
  109. <View className="bg-white rounded-2xl p-4 mb-4 shadow-lg">
  110. <View className="flex items-center space-x-3">
  111. <View className="flex-1 relative">
  112. <View className="absolute left-4 top-1/2 -translate-y-1/2">
  113. <View className="i-heroicons-magnifying-glass-20-solid w-5 h-5 text-gray-400" />
  114. </View>
  115. <Input
  116. type="text"
  117. placeholder="搜索你想要的商品..."
  118. className="w-full pl-12 pr-4 py-3 bg-gray-50 rounded-xl outline-none focus:bg-gray-100 transition-colors text-sm"
  119. value={searchKeyword}
  120. onChange={(value) => setSearchKeyword(value)}
  121. onConfirm={() => refetch()}
  122. />
  123. </View>
  124. {searchKeyword && (
  125. <Button
  126. size="mini"
  127. variant="ghost"
  128. className="!w-8 !h-8 !p-0"
  129. onClick={() => {
  130. setSearchKeyword('')
  131. refetch()
  132. }}
  133. >
  134. <View className="i-heroicons-x-mark-20-solid w-4 h-4 text-gray-500" />
  135. </Button>
  136. )}
  137. </View>
  138. </View>
  139. {/* 分类筛选 */}
  140. <View className="flex space-x-2 mb-4 overflow-x-auto">
  141. {categories.map((category) => (
  142. <View
  143. key={category.id}
  144. className={`whitespace-nowrap rounded-full px-4 py-2 transition-all cursor-pointer ${
  145. activeCategory === category.id
  146. ? 'bg-blue-500 text-white shadow-md'
  147. : 'text-gray-600 bg-gray-100 hover:bg-gray-200'
  148. }`}
  149. onClick={() => {
  150. setActiveCategory(category.id)
  151. refetch()
  152. }}
  153. >
  154. {category.name}
  155. </View>
  156. ))}
  157. </View>
  158. {/* 商品列表 */}
  159. {isLoading ? (
  160. <View className="flex justify-center py-10">
  161. <View className="i-heroicons-arrow-path-20-solid animate-spin w-8 h-8 text-blue-500" />
  162. </View>
  163. ) : (
  164. <>
  165. <View className="grid grid-cols-2 gap-4">
  166. {allGoods.map((goods) => (
  167. <Card
  168. key={goods.id}
  169. className="overflow-hidden bg-white rounded-2xl shadow-sm active:shadow-lg transition-all duration-300 active:scale-95"
  170. onClick={() => handleGoodsClick(goods)}
  171. >
  172. <View className="relative">
  173. <View className="w-full aspect-square bg-gradient-to-br from-gray-100 to-gray-200 overflow-hidden">
  174. {goods.imageFile?.fullUrl ? (
  175. <Image
  176. src={goods.imageFile.fullUrl}
  177. className="w-full h-full object-cover transition-transform duration-300 active:scale-110"
  178. mode="aspectFill"
  179. />
  180. ) : (
  181. <View className="w-full h-full flex items-center justify-center text-gray-400">
  182. <View className="i-heroicons-photo-20-solid w-12 h-12" />
  183. </View>
  184. )}
  185. </View>
  186. {goods.stock <= 0 && (
  187. <View className="absolute inset-0 bg-black/50 flex items-center justify-center">
  188. <Text className="text-white font-bold text-sm">已售罄</Text>
  189. </View>
  190. )}
  191. {goods.stock > 0 && goods.salesNum > 100 && (
  192. <View className="absolute top-2 left-2 bg-gradient-to-r from-orange-500 to-red-500 text-white text-xs px-2 py-1 rounded-full shadow-lg">
  193. 热销
  194. </View>
  195. )}
  196. {goods.stock > 0 && goods.salesNum <= 100 && (
  197. <View className="absolute top-2 right-2 bg-white/90 backdrop-blur text-gray-700 text-xs px-2 py-1 rounded-full shadow-md">
  198. 库存{goods.stock}
  199. </View>
  200. )}
  201. </View>
  202. <View className="p-3">
  203. <Text className="text-sm font-semibold text-gray-900 mb-1 line-clamp-2 h-10 leading-tight">
  204. {goods.name}
  205. </Text>
  206. <Text className="text-xs text-gray-500 mb-2 line-clamp-1">
  207. {goods.instructions || '优质商品,值得信赖'}
  208. </Text>
  209. <View className="flex items-center justify-between">
  210. <View>
  211. <Text className="text-red-500 text-lg font-bold">
  212. ¥{goods.price.toFixed(2)}
  213. </Text>
  214. <Text className="text-xs text-gray-400 ml-1">
  215. 已售{goods.salesNum}
  216. </Text>
  217. </View>
  218. <Button
  219. size="sm"
  220. className={`!w-8 !h-8 !p-0 rounded-full transition-all ${
  221. goods.stock > 0
  222. ? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-md active:shadow-lg'
  223. : 'bg-gray-200 text-gray-400'
  224. }`}
  225. onClick={(e) => {
  226. e.stopPropagation()
  227. if (goods.stock > 0) {
  228. handleAddToCart(goods)
  229. }
  230. }}
  231. disabled={goods.stock <= 0}
  232. >
  233. <View className={`w-4 h-4 ${goods.stock > 0 ? 'i-heroicons-plus-20-solid' : 'i-heroicons-minus-20-solid'}`} />
  234. </Button>
  235. </View>
  236. </View>
  237. </Card>
  238. ))}
  239. </View>
  240. {isFetchingNextPage && (
  241. <View className="flex justify-center py-4 mt-4">
  242. <View className="i-heroicons-arrow-path-20-solid animate-spin w-6 h-6 text-blue-500" />
  243. <Text className="ml-2 text-sm text-gray-500">加载更多...</Text>
  244. </View>
  245. )}
  246. {!hasNextPage && allGoods.length > 0 && (
  247. <View className="text-center py-8 text-sm text-gray-400">
  248. <View className="flex items-center justify-center">
  249. <View className="i-heroicons-check-circle-20-solid w-4 h-4 mr-1" />
  250. 已经到底啦
  251. </View>
  252. </View>
  253. )}
  254. {!isLoading && allGoods.length === 0 && (
  255. <View className="flex flex-col items-center py-10">
  256. <View className="i-heroicons-inbox-20-solid w-16 h-16 text-gray-300 mb-4" />
  257. <Text className="text-gray-500 text-lg mb-2">暂无商品</Text>
  258. <Text className="text-gray-400 text-sm">换个关键词试试吧</Text>
  259. </View>
  260. )}
  261. </>
  262. )}
  263. </View>
  264. </ScrollView>
  265. </TabBarLayout>
  266. )
  267. }